Keyboard shortcuts

Press ← or → to navigate between chapters

Press S or / to search in the book

Press ? to show this help

Press Esc to hide this help

The guide

README.md is a thin pointer. This is where the depth lives: what the language is, why it’s built this way, what exists, and an index of every design document. History — what shipped when, and what each slice found, including corrections — is ROADMAP.md. Neither belongs in the index below, which says what each document settles, not how it got there.

Design lands here before the code that implements it, which is the cheap place for it to be wrong. When it turns out wrong anyway, the document that made the claim is corrected in place rather than quietly edited — ROADMAP.md says which.


Where this came from

cancho began as the lower-level sibling of Lex, a high-level, interpreted language, and took its worldview from it: ownership and authority as one system, defined behaviour, a canonical content-addressable syntax tree. That is history. cancho stands on its own now: it has its own compiler, its own standard library, its own packages and its own tools (cancho authority, cancho-vcs, the products listed in the repositories of this organisation), and it depends on nothing from Lex or from lex-os, the agent runtime Lex had. If a runtime that confines a program to what its authority report says is needed, it is built in cancho, from the report the compiler already writes.

The documents that compared the two (vcs.md, under-a-grant.md, related-work.md) are kept as the record of what was measured and decided; they are not a plan to integrate with Lex.


Why

Three things this philosophy buys that no systems language currently combines:

  1. Capabilities all the way down. Ownership and effects are the same idea — both are resource tracking. Allocation is an effect, a heap value is a linear resource, FFI is a capability you must be granted. Owning a capability discharges its effects and borrowing declares them, so main’s row is [] however much it does.
  2. Determinism as a language property. No UB, defined evaluation order, deterministic layout — which is what makes replay, attestation and content-addressing mean anything, and exactly what C throws away. Written out operation by operation in defined-behaviour.md.
  3. A checker that is fast and total, because the guarantee is only worth what it costs to verify.

What exists

Settled by
CapabilitiesWorld, Io, Fs(prefix), Ffi(lib), Heap, Args, File, Net(bound) — linear values, split once, released by namelinearity-and-effects.md
Effect rowsA canonically ordered set, exact in both directions, every label tracing to a builtinlinearity-and-effects.md
NarrowingPrefix extension, one way, and it consumes what it attenuatesfilesystem.md, reach.md
Authority reportcancho authority, computed from reachability; --output json for a supervisor, and it fails closed on foreign codeauthority.md
BorrowingLexical regions, no borrow checker; &! is a lock on the bindingaliasing.md
MemoryArenas, a general heap with recursive types, boxed slices, growable buffersheap.md, boxed-slices.md
Typesint byte bool float (and f32 from edition 6), structs, enums with exhaustive match, tuples, generics with [T: val] boundsfloating-point.md, tuples.md
Defined behaviourChecked arithmetic that traps, left-to-right evaluation, every C hole named and closeddefined-behaviour.md
ThreadsCompiler-provided spawn/join, never crossing the C ABI; one pointer-width payload today; atomics designed, not builtthreads.md, atomics.md
Program identitycancho ids — per-declaration content hashes, checked against golden fixturescanonical-ast.md, hash-stability.md
A content-addressed op logcancho vcs publish/log — every declaration as a typed, gated operationvcs.md, vcs-publish.md
Compile timePure calls on constant arguments folded; static items whose bodies run during compilationcompile-time-data.md
I/OThe console in three directions, file handles as linear resources, bulk reads and writes, a Net capability for socketsfile-handles.md, bulk-io.md, net.md
RefusalsEvery rule carries a stable tag; check --output json reports every independent one as dataagent-errors.md
Standard libraryWritten in cancho, including shortest round-trip float printing and a UTF-8 decoderstandard-library.md
Two backendsCranelift (dev) and LLVM (release, default) — an opt-in second backend became the default once nothing it refused had an asker leftllvm-backend.md

What is not there yet, and why, is ROADMAP.md.


The language in one page

A value is res or val. A res value is consumed exactly once on every path. Mode is structural — a res member makes the whole aggregate res — so Held[File] is res where Held[int] is val.

res struct Ticket { serial: int }

fn redeem(t: Ticket) -> [] int {
    let Ticket { serial } = t;      // the whole is spent, the parts produced
    return serial;                  // `int` is `val`, so nothing is owed now
}

There is no drop and no destructor: a resource is destroyed by naming the function that knows how, which is what keeps an effect row honest once there are effect rows.

Looking without spending is a borrow. borrow freezes for a read, borrow mut locks for a write — nothing else may touch the value at all, not even a read.

fn serial_of[&r](t: &r Ticket) -> [] int { return t.serial; }

borrow held as &r in {
    putchar(i, 48 + serial_of(r));    // `held` is frozen; `r` reads it
}                                     // owned again here

There is no borrow checker. A region is a block, so a reference’s validity is lexical rather than inferred: no non-lexical lifetimes, no variance, no dataflow. A binding is Owned, Frozen or Locked, set at block entry and restored at block exit; r_inner <= r_outer holds exactly when the outer block encloses the inner one, which is a walk up a stack; and escape is an occurs-check over one type.

An effect is a borrowed capability.

fn triple(n: int) -> [] int { return n * 3; }                // pure, and says so
fn show[&i](io: &!i Io, n: int) -> [io_write] int { ... }    // borrows the console

fn main(world: World) -> [] int {
    let Split { io, ffi, fs, heap, args } = split(world);    // the one source of authority
    release(args); release(heap); release(fs); release(ffi); // unused authority is still a resource
    borrow mut io as &!i in { show(i, 7); }
    release(io);                                             // destroyed exactly once
    return 0;
}

[io_write] on a signature means the function was handed an &!i Io it did not create — so reading the row and reading the parameter list are the same act, and a function that was given nothing cannot print however much it wants to. There is no ambient constructor: Io { } is refused, main’s World is the only authority in the program, and it is linear, so forgetting to release it does not compile.

main’s own row is [] even though it prints, because it owns the capability rather than borrowing one — and ownership is already visible in the parameter list.

A row is a canonically ordered set, so it hashes, which is what per-unit identity is made of. It is exact in both directions: performing an effect you did not declare and declaring one you never perform are both errors, because an inexact row means [] stops meaning pure. And there is no list of legal labels — every label traces back to a builtin that performs one, so a label with nothing underneath it is refused the moment it is written.

A foreign call is the same idea pointed at C.

extern fn labs[&f](ffi: &f Ffi("libc"), n: int) -> [ffi("libc")] int;

let libc = narrow(ffi, "libc");        // `Ffi("")` -> `Ffi("libc")`, one way only
borrow libc as &f in { n = labs(f, 0 - 7); }

Narrowing is prefix extension and goes one way. Ffi("") names no library, so it authorises nothing until narrowed; an Ffi("libcrypto") can never become an Ffi("libc"). It also consumes what it attenuates, so there is no way back to the wider capability. The capability is checked and then erased: what libc receives is the integer and nothing else.

Memory has three shapes, and the same region machinery checks all of them.

region a {                                   // an arena: released in one call
    let xs = alloc_slice[a](5, 0);           // xs : &!a [int]
    xs[0] = 3;                               // bounds-checked; out of range traps
}

let node = box(h, Node { value: 1 });        // the heap: `Box[T]` is `res`
let tail = unbox(h, node);                   // the only thing that ends one

An arena is a region — same block, same parent chain, same occurs-check. Nothing whose type mentions a leaves the block, and release is one free whatever was allocated. Box[T] is res, so the exactly-once rule written for capabilities and file handles turns out to be a memory safety rule for free: no leaks, no double frees, no use-after-free, none of them checked by anything new.

A string is a run of bytes and claims no encoding. str is not a type; a string is &r [byte], an ordinary slice and therefore an ordinary reference, so regions, the escape check and the unique-to-shared coercion all came for free.

Arithmetic is checked. +, -, * and unary - produce the right answer or trap — they never wrap. Wrapping is expressible but has to be asked for by name. Division traps on a zero divisor and on int::MIN / -1 rather than being undefined. Evaluation order is left to right everywhere, including a struct literal’s fields, and that is enforced rather than merely intended.

Real programs run on this, byte-for-byte checked against the originals: GNU coreutils base64 and sort (porting.md), a REST endpoint answered over a real socket (net.md), and examples/seek/, not a port — a search tool shaped for an agent calling it rather than a person typing at a shell (agent-tools.md).


cancho builds on other people’s ideas, and the closest of them got there first.

  • Austral — the nearest relative: linear types, capabilities as linear values with a root capability handed to the entry point, lexical borrowing, no borrow checker. Most of this core is Austral’s first. cancho adds the same authority stated as an exact effect row, which is what cancho authority reads.
  • Koka — effects as a row of labels. Kept the row; no handlers, no row polymorphism.
  • Effekt — effects as capabilities, from the effect-handler side: the closest statement of “an effect is a borrowed capability”.
  • Cyclone — lexical regions; here without Rust’s inference.
  • Rust — ownership as move; the borrow checker declined.
  • Vale — generational references, which Gen is.
  • Zig — defer.
  • Pony, Hylo — other answers to aliasing and ownership. Pony’s val means something different from this one.
  • The object-capability model (E, Robust Composition) and Capsicum — no ambient authority.
  • Lex — where the worldview came from (history; cancho no longer depends on it).

The competitor is WASI, not Rust. For running code you did not write, WebAssembly with WASI enforces authority at run time, by trying. cancho knows it before execution, from the program’s text, with no runtime cost. What each project contributed, traced to the document that used it: related-work.md.


Design commitments

AreaCommitmentWhy
MemoryLinear/affine types + regions/arenas — not an NLL borrow checkerLocal, cheap, total to check
EffectsCapability-typed effects in the type system, unified with linearityOne resource system, not two
BehaviourNo UB, defined evaluation order, deterministic layoutReproducibility is load-bearing
ASTCanonical, content-addressable, stable per-unit identityDesigned in, never retrofitted
TypesFast, total, decidable inferenceThe guarantee must be cheap
MetaprogrammingHygienic deterministic comptime — no textual or proc macrosMacros break stable content-addressing
GenericsMonomorphisedZero-cost, matches Rust’s codegen
FFIExplicit, capability-gatedC’s effects must not be invisible
BackendLLVM by default, Cranelift for fast dev buildsReuse; own backend only if zero-C becomes a goal

Taken from Lex’s design, long ago: examples-as-tests, [budget], effect declarations as the function’s contract.

Explicit non-goals

  • Not a Rust clone. No trait-system maximalism, no GATs, no specialisation, no borrow checker. Chasing Rust’s power means inheriting Rust’s implementation cost and abandoning totality — the failure mode this design exists to avoid.
  • Not self-hosting-first. The compiler started in Rust, as a starting point, not an end-state; it is now being written in cancho itself, stage by stage, with the Rust compiler as the check at each one (self-hosting.md).

Performance

Linearity and effects are erased at compile time; generics monomorphise. The gap to C is a backend question, not a price of ownership: Rust sits within 4% of C on the same kernels (against-c-and-rust.md), and once the overflow trap and a vectorising backend (--backend llvm, now the default) are both in place, this language measures statistically indistinguishable from C on the one kernel checked directly against it.

The derivation — what the trap costs, what each backend can and cannot express, and every kernel measured — lives where it was measured rather than restated here: overflow-cost.md, check-cost.md, backend-limits.md, llvm-backend.md, benchmarks-game.md.


Layout

crates/cancho-syntax    lexer, canonical-shaped AST, parser
crates/cancho-types     the type vocabulary: representation and unification
crates/cancho-ir        resolution, type checking, monomorphisation; the IR
crates/cancho-codegen   Cranelift lowering, native object emission
crates/cancho-codegen-llvm  the LLVM backend, --backend llvm (default)
crates/cancho-id        canonical encoding and content hashes
crates/cancho-vcs       content-addressed operation log (vcs.md)
crates/cancho           the CLI
std/                     the standard library, as Lex source
examples/                programs meant to be read
packages/                published cancho-vcs packages (package-system.md)
tests/accept             fixtures that must compile and run
tests/reject             fixtures that must be refused, each stating why
benches/                 checked/wrapping pairs; what the overflow trap costs
scripts/bench.py         runs them and prints the table
docs/                    this directory

Everything a program can be refused for is refused in cancho-ir, so the backend has no error path for a program — only for the environment. Each fixture declares its own expectation in its header (//~ STDOUT, //~ EXIT, //~ ERROR), so adding a rule to the language means adding a fixture.


Every design document

One line of purpose, one short phrase of status. What each slice found — the measurements, the bugs, the corrections — is ROADMAP.md, not repeated here; a doc’s own header carries its own detail.

DocPurposeStatus
linearity-and-effects.mdCore type-system rules: linear/affine ownership, capability-typed effects, how they unifysettled and built (#2)
bootstrap.mdWhere self-hosting stands and how to use the compiler in cancho; below it, what M0 settled (host language, file extension, the M0 surface)current; history below
canonical-ast.mdAST shape, canonicalisation, per-unit identity (cancho ids)written and built
memory-model.mdRegions/arenas, escape, the escape hatchesnot written — settled piecemeal by heap.md/sharing.md; nothing left to write on its own
strings.mdA string is bytes, not an encoding; byte as storagesettled and built
boxed-slices.mdBox[[T]]: a pointer and a lengthsettled and built
many-files.mdA program in more than one file; identity by contentsettled and built
arguments.mdThe Args capabilitysettled and built
reading-references.mdReading through a reference; a reference gives referencessettled and built
heap.mdHeap and Box[T]: one value, one allocationsettled and built
filesystem.mdFs(prefix), the path checksettled and built
sharing.mdWhy Rc is not expressible, and Gensettled and built — corrects linearity-and-effects.md §9
tuples.md(A, B) as a structural anonymous structsettled and built
shadowing.mdShadowing: allowed exactly when the binding is deadsettled and built
standard-input.mdgetchar: a second label on Io, not a new capability; read_bytes (edition 9): the bulk read behind the same label, 73x getchar on macOSsettled and built
modules.mdmodule/import/pub; a module is a namespace, not a trust boundarysettled and built
standard-library.mdWhat belongs in std, and how a function earns its way inwritten and built
mode-polymorphism.md[T: val] boundssettled and built — found a leak and a double free
collections.mdWhich collections hold a resource, and why it’s about shapesettled and built
json.mdstd.json: a strict, zero-copy tape parser and a Writer that cannot write bad JSONbuilt; 13,000 numbers bit-exact against Rust, 1,500 mutated documents against serde_json, 355 MB/s to the tape; parse_with (§3.1): no region per call, 5.8x on macOS and 8.7x more at 16 threads, identical to parse on 58,733 documents and the 318 files of JSONTestSuite
map.mdstd.map: a hash map from byte strings to copyable values, iterating in insertion orderbuilt; tested against a model over 30,000 random operations; found the LLVM backend growing the stack inside loops
http.mdstd.http and std.route: a strict request parser (refuses what request smuggling lives on) and a router that answers with an id you match onbuilt; 1,089 requests identical to httparse field for field, none accepted that it refuses; 1.0-1.4 µs a request, flat to 5,000 routes
server.mdexamples/api: a JSON API server over std.http, std.route, std.json and native sockets – one thread, a Poller, keep-alive, pipelining, no Ffibuilt; 17 tests over real sockets; migrated off poll(2)/libc (§8): authority now exact, throughput 134,000 → 73,000 a second – the kernel’s epoll in this VM, not the program (a C server falls the same way) – still ~25× FastAPI
http-server.mdhttp.server: the server loop as a package – wait/next/respond, the application owns its loop; why a callback does not type-check; hold/answer for a request whose answer is not ready, and the server’s poller shared with the application’s own handles (§10); the byte-fed mode (§11): open_bytes/attach/input/output/detach for a server with no sockets, so it can sit behind the TLS engine, and examples/https_hello that does; streaming request bodies and Expect: 100-continue (§12): limits, then body_part/body_take/body_state/proceed on a held request’s ticket, in-place chunked decoding, rule-tagged refusals, a head deadline and a body stall timeoutbuilt; first package that imports std; examples/api migrated, 21 + 2 tests, ~7% faster than the loop inside the example; §10 tested against 7 mutations; §11: 15 tests without a socket, 79 of 90 mutants, HTTPS example against curl, OpenSSL and Python, cost measured; §12: 22 tests without a socket and 4 with, uploads to 1 GiB hashed, mutants (§12.13)
http-client.mdhttp.client: a non-blocking HTTP/1.1 client for a program with one poller (cancho-gateway’s upstream side, http-server.md §11.8 item 4): a table of connections fed bytes and returning events, no I/O and no authority in the package; streamed request bodies with back-pressure, status and headers as soon as complete, bodies in pieces by length / chunked / until close, Expect: 100-continue, 1xx skipped, early responses; pooling with the strict reuse rules and the one retry of cancho-hooks §53; timeouts on the caller’s clock; TLS to upstreams through packages/tls in the example’s driver; DNS the caller’sbuilt; 118 unit tests on both backends, 517 mutants, a million damaged responses, live against Python, nginx, examples/https_hello and a hostile server over plain TCP and TLS 1.3, measured cost (§10)
parallelism.mdthreads and vectorization: what exists, what was measured (LLVM vectorizes what cannot trap; a multi-field payload crosses by reference; DuckDB on one core), two ways of keeping the overflow guarantee that did not work, and the order to do it in, each step with a gate that can failstrategy, with its measurements (benches/parallel/)
atomics.mdatomics and a bounded channel: hard constraint, or nobody asked? – nobody asked (the compiler already emits an atomic, both backends have all five operations, &Atomic already crosses); one res Atomic of n words in heap memory because a borrow copies, sequentially consistent only, row conc, edition 8; and the finding that the checker does not stop two threads writing one value (threads.md §3 corrected). A gate fixed first: litmus tests with a power check, stress, nine mutants, a model check; stages A0-A3design, with measurements (benches/atomics/); not built
zeroed-slices.mdbox_slice(h, n, 0) (and byte_of(0), false, 0.0) is calloc with no fill loop; untouched pages are never resident. jsonq: 0.46 s to 0.15 s, 427 MB to 89 MB peakbuilt on both backends; freed-memory and residency checks; seven of eight mutants killed, the eighth equivalent
checked-output.mdflush_out(io) -> [io_write] Done, edition 5: flush standard output and learn whether what was written arrived – fflush and then ferror, because fflush answers 0 after an earlier failure emptied the buffer (measured); gap L1 of agent-toolbox.mdbuilt on both backends; /dev/full, a closed descriptor and an earlier failure each observed; seven mutants killed
memory-moves.mdcopy_within(buf, dst, src, n): a bounds-checked memmove inside one slice, edition 5; why a byte loop cannot do it (overlap, direction, 0.5 GB/s: 114.7 ms to compact a 64 MiB arena) and what the two backends emitbuilt, with a fixture, nine trapping shapes on both backends and three mutants checked
byte-search.mdindex_of_byte(text, b): the first b in a slice as one memchr, edition 5, and std.bytes’ find and count_byte on it; four to five times faster when the byte is rare, and the measured LLVM regression when a needle’s first byte is commonbuilt, with fixtures on both backends and mutants checked
word-scan.mdload_le64, byte_mask64, trailing_zeros, leading_zeros, popcount (edition 9): scanning memory a word or 64 bytes at a time with a checked unaligned load, a 64-byte compare mask and the bit counts, on both backends and wasm32-wasip1; measured against the byte loop and memchr on 1 GiB, on arm64 and x86-64built, with fixtures on both backends, a differential test against Rust over random buffers and every offset and length, and the trapping edges
bulk-copy.mdcopy_into(dst, src): all of one slice onto the front of another as one memmove, edition 5; why overlap is possible, and std.buffer’s copies rewritten on it (640 MiB of appends: 2.00 s to 0.026 s on Cranelift, 0.47 s to 0.026 s on LLVM)built, with a fixture on both backends, six trapping and edge shapes, and mutants checked
native-sockets.mdServers without Ffi("libc"): typed Listener/Conn/Poller handles, Clock, and the four-stage road to a toolchain with no Cslice 1 built (edition 5): tcp_listen/tcp_accept/conn_read/conn_write over typed handles on both backends, no Ffi; fixed-signature fd builtins rejected because an int descriptor is forgeable (§2); Poller, Clock, tcp_connect next; tcp_listen_on (edition 9): listen on one IPv4 literal, with the host in the authority row (§12)
udp.mdDatagram sockets without Ffi: a connected Udp for one upstream (net_out), a bound one for a server (net_in), replies to a sender only through a ticket the runtime issued; no sendto with a free destinationbuilt, both halves (udp_connect/udp_send/udp_recv/Poller; udp_bind/udp_recv_from/udp_send_to with peer tickets); both backends; round-trip measurement not done (#355, #356; asker cancho-dns#18)
signals.mdSignals/SignalWatch (edition 6): knowing you were asked to stop without Ffi("libc") – a capability narrowed to the signals it claims, so the authority report names them and stays bounded; a bitmask signals_pending, a handle the Poller waits on, and signals_close for “a second signal kills at once”built on both backends: signalfd on Linux, measured; kqueue EVFILT_SIGNAL on macOS, written and not run
conn-peer.mdThe other end of a connection: conn_peer(&Conn, &![byte]) (getpeername, 19 bytes, no effect row, not authority), std.addr (Peer, canonical text, parse, IPv4-mapped normalised, a /32 and /64 key) and std.conns.peer; asked at call time, not stored at accept; the socket peer, not an identity (PROXY protocol out of scope)built on both backends; examples/tls_echo logs it and bounds by it (#367); Linux and macOS arm64 run, x86-64 is CI’s; WASI refuses it
directory-handles.mdDir (edition 6): a path opened beneath a directory one component at a time, following no link – the fix for a symlink inside a narrowed prefix or a tool’s --root reading outside it (#227, gap L6). open_dir, dir_enter, dir_open_read, dir_close, and std.dirs for a whole path; measured against openat2slices 1 and 2 built on both backends (read beneath; create, append, rename, remove, sync beneath); cancho-tools on top is slice 3; slice 4 (edition 7) is dir_rename_new, the rename that never replaces a name, with the filesystems measured
processes.mdExec (edition 7): starting a program without Ffi("libc") – a capability narrowed to a path prefix of the programs it may start, a linear Child that must be reaped, Pipe ends the parent cannot leak, an exact environment and descriptor set, and the Poller waiting on both (#237, cancho-tools#10)slices 0 to 6 built: close-on-exec everywhere, the capability with children and channels, the Poller watching both, std.process.capture (#271, #274, #275, #276), and the MCP server that uses them (cancho-tools#17); slice 5, the two prerequisites of a run tool: a child’s working directory (exec_spawn_in, §4.10) and capture_both (§7.2); letting go of a child for a job-starting server – exec_spawn_detached, child_pid, child_release and the exec_detached label (edition 9, §4.11, #524); stopping what a detached worker started – child_kill_group, which capture uses on its kill paths (edition 9, §4.12, #533). Slices 0 to 6 are built, and the status line says which is which (#536)
directory-listing.mdListing a directory and a file’s status on a Dir handle rather than a path (#222, gaps L2 and L3): dir_list, dir_next, dir_list_close, dir_stat, and std.dirs.list sorted bytewise; the dirent/stat layout table for three targets; measured cost per entryslices 1 and 2 built on both backends (dir_list, dir_next, dir_list_close, std.dirs.list; dir_stat); the list tool is slice 3
foreign-authority.mdWhat a program that calls C can reach, symbol by symbol: Ffi("libc,libssl") is a set of libraries (one capability, lent narrower), the report lists every reachable foreign symbol as scope:symbol (unbounded_by, one a line, so a CI pin diffs an added symbol), and a foreign function must borrow exactly one Ffibuilt on both backends; closes a hole: an extern fn with no capability was accepted, ran a shell and reported bounded: true. Symbol-level rows and capabilities weighed and declined; the statx argument shape and \x found, pinned, not fixed
narrowing-into-several.mdWhy a program that reads two unrelated files (/dev/urandom and a CA bundle) holds Fs("") and reports fs_read(""): narrowing consumes, so one Fs makes one chain. Options weighed (a set of paths, a remainder, a fork from a borrow, lending a root narrower, directory handles); recommended: narrow(fs, "a", "b") answers a tuple of children, for Fs and Exec, with no report change. Found a latent row bug: Label::covers compared path labels by byte prefix; fixed in PR #359built (PR #364; the covers fix first, PR #359); all six questions decided as proposed 2026-10-07; found by cancho-pg#14; examples/tls_echo_fixed is the payoff
websocket-spike.mdA WebSocket/OCPP connection layer on cancho against the lex runtime: 10,000 connections, measured, plus the gaps found and the cancho-cache-for-Redis questionmeasured: 80 MB and 0% idle CPU at 10,000 connections (the runtime’s thread-per-connection stand-in saturates a core near 1,500); the service-level gaps are listed in §10
formatting.mdcancho fmt: the canonical layout with comments, blank lines and literal spellings keptbuilt; refuses, rather than risks, a file it cannot reproduce
testing.mdtrap() and std.test’s assert — what a program needed to state “this must be true” at alltrap(), std.test, and cancho test are built; no per-test timeout, no message on a failed assertion
satisfy.mdThe spec-to-implementation loop: a contract is a .cho file whose tests call the candidate; satisfaction is check plus test composed, with the verdict and the recomputed SigId answered (#406)built; the first contract (tests/satisfy/) is CI-checked
slicing.mds[a..b], half-open and trappingsettled and built
defer.mddefer E; as sugar, expanded during loweringsettled and built
authority.mdcancho authority; release at main is the declarationsettled and built
budget.mdWhether [budget] is a type-system featuresettled — no
reach.mdWhat a program can reach, via a real REST endpointsettled and built; corrected by opaque-pointers.md and foreign-linking.md
opaque-pointers.mdc_ptr: one opaque foreign-pointer shapesettled and built (edition 3)
foreign-linking.md-l/-L: linking beyond libcsettled and built — examples/tls_client/, a real handshake
tls-nonblocking.mdhttps for a service with one thread: a non-blocking OpenSSL client on the Poller (memory BIOs), certificate verification with every failure told apart, the authority it costs, a resolver that does not stop the loop and a pinned address, what cancho-hooks would change, and 12 gaps with reproducersspiked and measured (examples/tls_nb/): 64 handshakes on one thread, ~0.6 ms of CPU each (0.3 resumed), 26-48 KiB a connection; not built into any service; OpenSSL through FFI by decision, a pure cancho TLS is a research project
overflow-cost.mdWhat the overflow trap costs, measuredmeasured — corrects the README and defined-behaviour.md §2.1
bitwise.md& | ^ ~ << >>, hex literalssettled and built
porting.mdReal programs ported and checked byte-for-byte: base64, sortdone twice
against-c-and-rust.mdcancho against C and Rust on the same algorithmmeasured
purity.mdThe checked purity proof C can only promise and Rust can’t statemeasured and unspent
floating-point.mdfloat, IEEE-754 binary64settled and built
float-printing.mdShortest round-trip decimal printingsettled and built
f32.mdf32, IEEE-754 binary32, asked by cancho-gpu: the type, the f32 suffix, f32_of/float_of32/bits_of32/f32_of_bits, the bit-for-bit gate against binary64, sqrt32 and the int conversions, and std.fmt32 (Rust’s {:?} and {:.N} and a decimal read straight to the nearest f32, every 2³² pattern checked)F1, F2 and F3 built (edition 6); F4 cancho-gpu not
compile-time.mdConstant folding and pure-call evaluationsettled and built
compile-time-data.mdstatic items evaluated during compilationsettled and built
layout.mdWhat every leaf costs; packing and transposingmeasured, deferred with a trigger (cancho layout)
benchmarks-game.mdFive kernels from the Computer Language Benchmarks Gamemeasured
bulk-io.mdwrite_bytes: a whole slice in one callsettled and built
file-handles.mdAn open file as a linear resourcesettled and built
file-writes.mdThe write side of a file handle: append, positional read/write, fsync, truncate, rename, lock — what a durable log needs; fopen-based opens to avoid variadic openslice 1 built (edition 5): open_append/open_write/open_new/open_rw, file_write/file_pwrite/file_pread/file_sync/file_truncate/file_size on both backends; the cost of sync measured (0.4 µs written, ~190 µs durable, 4.2 µs batched by 100); slice 2 adds fs_rename, fs_remove and file_lock (a lock the kernel drops when the holder dies)
utf8.mdDecoding &r [byte] into code pointssettled and built
standard-error.mderr_write: a third label on Io, not an eighth capabilitysettled and built
../AGENTS.mdHow to write cancho in one pagewritten and enforced
float-math.mdsqrt as a builtin, then exp/log/pow, then floor/ceil/round/fabs/fmin/fmax/sin/cos, then exp/log/pow fixed to 1–4 ulp and expm1/log1p/log2/log10/the hyperbolics added, then tan/atan/atan2/asin/acos and a Payne–Hanek reduction for every finite argumentsettled and built; every function measured against libm in ulps
gpu.mdWhether cancho can run on a GPU, and whether lex-gpu should existmeasured; decided
line-reading.mdWhether std needs a line readermeasured — no; found and fixed a silent truncation bug
agent-errors.mdRefusals a machine can read: stable rule tags, check --output jsonsettled and built
structured-ingest.mdThe AST as JSON and back: cancho print --output json and cancho ingest, the ingest path an agent writesbuilt
tty.mdTty, the serial-port capability (#422 T1): both measured termios ABIs, the surface, the labels — design
crc.mdstd.crc: CRC-16/XMODEM, CRC-32/IEEE and two CRC-64s (XZ, and Redis’s Jones), and the two-halves designbuilt
net-bounds.mdSeparate ways in and out, set-valued bounds, exact host matching (#362)design; §2.4 is a report fix recommended first
shared-store.mdSeveral threads, one store: the answer is process-per-core, with the two missing primitives named (#354)design; answered, topic closed
agent-tools.mdA tool genuinely shaped for an agent’s own loopbuilt (examples/seek/)
agent-toolbox.mdWhether a set of unix-like tools in cancho, with a JSON contract, rule-tagged errors with repair hints and a compiler-derived authority, is worth building — and the protocol that would say sodesign; the probes it rests on are run and recorded, nothing is built. Found: Fs extent cannot be static (narrow takes a literal), no directory listing or regex, sha256 traps past 64 KiB, a failed stdout write is invisible, and lex-os reads a cancho net_out label as no network; corrects bulk-io.md §3.3 and agent-tools.md §1
aliasing.mdWhether &! should mean Rust’s &mutmeasured — no
check-cost.mdThe price of every check this language emitsmeasured — corrects overflow-cost.md and gpu.md
poison.mdA per-lane flag instead of a trap, on a vector ISAmeasured — depends on the check
backend-limits.mdWhat Cranelift can and cannot be asked foran audit, checked against source
llvm-backend.mdA second backend, --backend llvmcomplete, and the default
effect-polymorphism.mdWhether an effect row can be polymorphica documented no, counted by reading
hash-stability.mdHow often a content hash actually movesmeasured — the plateau vcs.md needed
stability-exemptions.mdThe plateau rule as a checked property: every historical .cho revision that fails today’s compiler, by rule, with a reason and a count; scripts/history.py --explain and the stability-plateau CI job fail on any increasebuilt; measured baseline 210 revisions, 140 failing (67%)
vcs.mdHow much of lex-vcs cancho can reuseplateau answered yes; foundation, gate, op log and attestation built
vcs-publish.mdThe first real caller of cancho-vcs: publish, no diffing neededbuilt (cancho vcs publish/log)
package-system.mdWhat a package would be, built from what already exists rather than invented freshcancho vcs resolve/vcs lock/vcs fetch are the first three real slices; net.sockets, net.connect, agent.wire, http.request, and http.response (packages/) are five real packages, examples/fetch/fetch.cho composes two of them at once with no new tooling, and http.request/net.connect/http.response each depend on net.sockets – a true closure of stores, resolved recursively by vcs publish --requires/vcs resolve/vcs fetch (§4.6), the one thing this row used to call missing. §7 (packages across repositories) is built for its first two steps: a lock can carry an origin (a git URL, a full commit hash and a path) and vcs fetch/resolve/lock --git fetch it into a cache of checkouts keyed by commit, with every pin re-checked as for a local store; vcs publish --dir publishes a library of several files in dependency order, and static declarations now publish. §8 (the project file) is built, cancho test in a project included: cancho.toml, cancho install, cancho add, cancho build with no files, and a compiler revision in --version that a project checks. §9 (prebuilt compilers) is built, unreleased: scripts/package-release.sh, scripts/install.sh (checksum and reported commit both checked) and a release workflow whose tag is the commit. Still missing: the first published release, and a human-readable version string
character-literals.md'a': a third spelling of an integersettled and built
emitted-checks.mdThe price list, read out of the binaryan audit; found a real compiler bug
flags.mdstd.flags: a cursor, not a getopt_long tablesettled and built
first-page.mdWhat a reader of README.md actually learnsmeasured, and acted on — this guide is the result
under-a-grant.mdWhether a lex-os grant can decide a cancho authority reportmeasured — filesystem is enforceable; network and exec are not
net.mdNet: sockets, taken out of libcsettled and built
related-work.mdWhat cancho took from Cyclone, Koka, Rust, Vale, Zig, Lex, Australwritten
differential.mdThe constant folder against the backenda test; found and fixed one disagreement
connect.mdexamples/fetch/: an HTTP client, and what connect neededa probe
listen.mdexamples/collect/: the inbound counterparta probe; cleared Net’s last bar
internal-errors.mdA backend failure as a located refusal, not an environment errorsettled and built
fuzzing.mdA mutation fuzzer over the whole corpusa test; found four printer bugs
function-values.mdWhether closures exist, and what mode a capturing one hassettled and built
threads.mdCompiler-provided spawn/join, never crossing the C ABIsingle-leaf slice and owned-capability case built; multi-field payload decided not yet
editions.mdAn edition marker, a path for renamesdesign, measured
defined-behaviour.mdEvery place C/Rust leave behaviour open, defined insteadwritten and enforced
crypto.mdstd.crypto’s first slice: SHA-256built
sha512.mdstd.crypto’s second slice: SHA-512built
ed25519.mdstd.ed25519: sign and verifybuilt
chacha20.mdstd.chacha20: ChaCha20, Poly1305 and the AEAD (RFC 8439), for the pure TLS clientbuilt; not independently reviewed
hkdf.mdSHA-384, std.hmac and std.hkdf for the TLS 1.3 key schedule; the 64 KiB trap every std hash hadbuilt; not independently reviewed
tls-pure.mdThe TLS 1.3 client with no C library: where the code lives, the sans-io API both backends share, cipher policy, threat model, trust, refusal tags, open questionsdesign; the protocol built in packages/tls (#205, tls-core.md), chain validation not yet (#206)
x25519.mdstd.x25519, and std.field25519: one constant-time field for X25519 and Ed25519built; checked with Valgrind (ctgrind); not independently reviewed
x509.mdpackages/x509: a strict DER reader and an X.509 v3 parser, the pure TLS client’s certificatesbuilt; not independently reviewed
rsa.mdstd.bigmod (Montgomery arithmetic to 4,096 bits) and std.rsa (PKCS#1 v1.5 and PSS verification)built; not independently reviewed
ecdsa.mdstd.ecdsa (P-256 and P-384 verification, DER and raw signatures) on std.bigmod’s new register APIbuilt; not independently reviewed
tls-core.mdpackages/tls: the TLS 1.3 client handshake and record layer – the PR split, a slot, the state machine, pinned certificates until #206, and why the byte-for-byte gate uses a recorded ChaCha20 handshake instead of RFC 8448’s AES tracebuilt: the protocol, the 64-slot engine, live servers, a lying server, 22 mutants killed
x509-verify.mdpackages/x509’s chain verification for #206: the API over a sent chain’s ranges, path building with backtracking and a budget, what leaf, intermediate and root are each checked for, name constraints and the types it cannot read, and the four gates (x509-limbo, an OpenSSL matrix, saved real chains against the system roots, mutants); §10, a chain verified with no name for a purpose (verify_chain, for verify-ca and client certificates), the name as its own step, and the hazardbuilt: 9,743 of limbo’s 9,802 cases pass (every BetterTLS one), 39 disagreements each explained against OpenSSL, 22 mutants killed; packages/tls verifies with it. §10: a 56-case no-name matrix against openssl verify -purpose sslclient/sslserver, one known disagreement listed; 33 mutants killed
tls-parity.md#207: parity with the OpenSSL backend (“AEAD parity”): what OpenSSL 3.0.13’s ClientHello offers, measured; constant-time AES-GCM, P-256/P-384 key exchange and HelloRetryRequest, TLS 1.2 with ECDHE and AEAD suites only; what stays different and why; client certificates and ALPN, and the revocation and post-quantum decisions (§6)built: AES-GCM (§3.1.1), P-256/P-384 (ecdh.md), TLS 1.3’s AES-GCM and HelloRetryRequest (§3.3.1), TLS 1.2 (§3.4.1); §6 (#386): client certificates and ALPN built, revocation and X25519MLKEM768 decided (proposed)
ecdh.mdstd.ecdh: P-256 and P-384 key exchange with a secret scalar – complete formulas, a masked table, std.bigmod’s reductions made constant time, and the timing test that found LLVM turning the masks into branchesbuilt; not independently reviewed
value-barrier.mdvalue_barrier, edition 6: a value clang -O2 cannot reason about, so a constant-time mask stays an andbuilt
crypto-builtins.mdHardware AES and carry-less multiply: aes_encrypt_block, ghash_update (since #382 ghash_powers, gcm_tag, gcm_tag_diff; aes_ctr32) and hw_aes_gcm, block-level over byte slices so no 128-bit type is needed; measured so far: Cranelift has no such instruction, and LLVM compiles the intrinsics only with the target feature onbuilt: DIT, the builtins, per-key caching and the std hardware path
wide-multiply.mdmul_wide, add_carry and sub_borrow: a 64x64->128 multiply and add/subtract with carry as builtins over int pairs, lowered without a branch or a libcall on both backends and on wasm32; prototyped on X25519 (5 limbs of 51 bits); then P-256’s kernels (five 56-bit limbs inside them), std.bigmod (64-bit limbs) and Poly1305 (44-bit limbs) moved onto them (#381, §12)built; measured on x86-64 and arm64; dudect, mutants and differentials in §12
gcm-wide.md#382: multi-block AES-GCM and aggregated GHASH: where a record’s time goes (the cipher is 17% of 16 KiB, a region’s malloc is 88% of 64 B on Darwin), aes_ctr32 eight blocks at a time, GHASH reflected with one reduction for eight blocks and the powers of H in the prepared key, a tag in one call with no scratchbuilt: 5.9 to 11.2 times at 16 KiB, 89% (x86-64) to 68% (M4) of OpenSSL’s speed; the record layer is what dominates now
sha256-builtin.md#445 and #446: hw_sha256, sha256_blocks and sha256_block_words (SHA-256’s compression on SHA-NI and ARMv8 SHA-2, a state of eight ints and N blocks of bytes, or one block of words where std.crypto keeps its partial block), std.crypto on them, and HMAC with a key’s pads hashed once (hkdf.md §9); Valgrind hides SHA-NI on x86-64, so the guard counts the software path therebuilt
tls-hooks.mdThe pure TLS backend in cancho-hooks (#210): the two interfaces are not the same (an adapter is needed), one source cannot carry both capability rows (no effect polymorphism), the pure client has no resumption, and “no Ffi at all” cannot hold because hooks also holds libc; the build, the tests on both backends, the measurementsdesign
tls-resumption.mdTLS 1.3 resumption for packages/tls: the full handshake’s 2.8 ms is two X25519 and two ECDSA verifications (measured); PSK with (EC)DHE removes the verifications and keeps forward secrecy, about 1.2 ms; eight rules bound the hazard that a resumed session skips verification. Built: resumes against OpenSSL, Go, rustls, wolfSSL, nginx and GnuTLS; a resumed connection measured at half a full one’s CPUbuilt
tls-server.mdA TLS 1.3 server for packages/tls, for cancho-mqtt and cancho-gateway: constant-time ECDSA P-256 signing first (ecdsa-sign.md, built), then the server handshake with SNI, ALPN and HelloRetryRequest (built: interop with OpenSSL, curl, Go, wolfSSL and mosquitto, a lying client of 110 cases, a differential against openssl s_server, fuzzing and mutants; a full handshake measured at 3.5 to 4 ms of CPU on X25519 or P-256 (6 ms on P-384), 17 to 20 times OpenSSL’s); then examples/tls_echo, the server the broker and the gateway copy (§11: handshakes bounded in progress and a second, delayed not refused; reload on SIGHUP; close_notify on stop; 3.0 ms a handshake on an M4 and 5.5 ms on x86-64; no foreign code); session tickets (§12, #394); client certificates (§13): modes off, optional and required, a trust store of its own that is never the system bundle and is replaced at run time, the CertificateRequest, the client’s chain verified for clientAuth and its CertificateVerify (P-256, P-384, RSA-PSS, Ed25519), the verified identity given to the program per slot, no slot memory; interop with OpenSSL, curl, Go, wolfSSL and mosquitto, a lying client of 78 connections, a differential against openssl s_server, a fuzz harness, 202 mutants; a client certificate costs 1.7 to 4.2 ms of the server’s CPU on an M4; TLS 1.2 as a later stepsteps 1 to 5 built; not independently reviewed
tls-memory.mdMemory per TLS connection (#383, of #378): measured at 50 to 10,000 connections on arm64 and x86-64 beside OpenSSL’s server (an established idle tls_echo connection is 119 KiB, OpenSSL’s 14 to 49); where the slot’s 263 KiB and the 119 KiB are (the work area std.ecdh needs is 75 KiB of integers every handshake writes, and nothing is released); six ways out, and the one chosen: a small core per slot, and the handshake’s scratch and the record buffers leased from a pool of work areasbuilt, measured: an established idle tls_echo connection 17 KiB (from 119), 4.5 KiB of it the engine’s; §11: one whose client presented a verified certificate 19 KiB (from 126: #398 kept the leaf in the work area), the leaf in a 2 KiB room a slot
ecdsa-sign.mdstd.ecdsa_sign: ECDSA P-256 signing in constant time on std.ecdh’s ladder, RFC 6979’s nonce hedged with the caller’s randomness, a signature verified before it is given out; and packages/x509’s x509_key, P-256 private keys from PKCS#8 and SEC 1 PEM. Step 1 of tls-server.mdbuilt; not independently reviewed
tls-performance.mdTLS performance (#378), first deliverable: where the time of a full server and client handshake goes on x86-64 and arm64, measured beside OpenSSL. Three operations (two X25519, the signature, the verifications) are 77 to 91% of a handshake and each costs inside it what it costs alone; the engine’s copies, zeroing, hashing, HKDF and event loop are about 5%; every region is a malloc and free of 64 KiB, which is 6 to 10% of a handshake and 45 to 53% of a request on a kept connection (glibc told not to trim made a request 1.9 to 2.5 times cheaper on x86-64). Targets and gates stated first; a ranked lever list with gains computed from the measured parts (the 5-times end of the target is reached without a new builtin, by 1% (arithmetic); wide multiply is the one thing the language cannot do); a regression guard counting instructions under valgrind, in CI; open questions. §10, as built after #392 to #398: the server’s full handshake 2.86 times OpenSSL’s cycles on x86-64 (2.12 times its CPU on arm64), the client’s 2.77 (2.26), a resumed one 2.34 (2.17), a kept request 1.11 to 1.15; the allocator now the largest cost of every handshake (telling glibc not to trim removed a third of a full one and half a resumed one); each lever against its projection, T1 to T4 met and T5 not, the next levers rankedmeasured; design; the levers built in #392 to #397, the guard’s baselines complete
record-in-place.mdL1 and R1 of #378 (#447, #448): a kept https_hello request spent 24% copying a connection’s core into a work area and back and clearing it, and that was the 296 words copied one at a time, not the bytes; a TLS record went through a region and byte loops around the cipher. Block copies; an established connection that rests in its area until another needs one (at most 32, bounded memory); seal_in_place/open_in_place for AES-GCM (both paths) and ChaCha20-Poly1305, the record sealed where it is sent from and opened straight into the received data, no regionbuilt: a kept request 1.20 to 0.94 times OpenSSL’s cycles (x86-64); a record through both engines 5.7 (64 B) to 1.5 (16 KiB) times cheaper; 2.1 MiB more per engine at most, idle connections unchanged
region-limit.md#514: a region is one chunk, 65,536 bytes, and an allocation past it was a bare SIGILL learned from a crash in production. Measured exactly (8,192 ints or 65,536 bytes, the whole chunk, on both backends); a constant size is refused at check with region-chunk-exceeded, the message saying the limit and box_slice; a size the compiler cannot see still traps but writes a named line to stderr, on both backends. A heap-backed chunk is argued and not built (a language decision)built on both backends; the boundary measured; mutants listed in §6
region-chunk-cache.mdA1 of #378 and #375, one mechanism: every region was a 64 KiB malloc and free, and glibc trimmed and regrew its heap for nearly each (112 brk calls a full TLS server handshake, 136 a resumed one, measured; 0.7 µs a pair on macOS and serialised across threads). Each thread keeps the chunks its regions give back, at most 16 (1 MiB), last in first out, which is the region stack (regions nest strictly); the bytes a region used are cleared when it ends; a thread’s chunks are freed when it ends; the same runtime on both backends, a global on WASI. Options measured or argued (mallopt is glibc-only and changes every allocation), hazards (secrets, threads, memory), gates stated first. As built: 9 brk calls in a whole server run; a full TLS server handshake 34.6% cheaper (1.86 times OpenSSL’s cycles on x86-64), a resumed one 55.8% (1.04 times OpenSSL’s resumed: T5 met for the server; the client’s 1.21, not met); 16 threads on macOS 0.04 s where 8.8built on both backends and WASI; counted under strace and an LD_PRELOAD shim; 14 of 14 mutants killed
p256-fast.md#380 (of #378): faster P-256 – where the time goes (field operations are 93-98% of every operation, a bigmod.mul is 115 ns of which 55% is checked arithmetic), then a ten-limb 28-bit field with straight-line generated kernels and the NIST prime’s form, a fixed-base table for k·G as a static, wNAF verification; §11, a table for a key that verifies many signatures (the server’s own, B2; a client’s P-256 trust anchors, B3): four runs of 65 wNAF digits sharing 64 doublings, 10,305 bytes a key, once per engine and key; a verification 2.1 times cheaper (2.26 times fewer instructions), a full server handshake 24% fewer instructions (1.46 times s_server), a client’s 18% (1.41 times tconn); §12, P-384 measured (8 to 17 times OpenSSL) and decided: its verification is worth it, on #381’s field; §13 its multiplications on mul_wide (#381), four 64-bit limbs measured and not chosenbuilt; gates in §8, §11.6 and §13.3; not independently reviewed
self-hosting.mdWhether cancho could host its own toolchainrun; decided not yet — no asker
agent-cli.mdcancho introspect/skill: the CLI surface as data, imported from lex-lang’s ACLI integrationbuilt; found a false-familiarity bug in the process (-o/-l/-L would have rendered as --o/--l/--L)
next-phase.mdReplacing episodic duplication/staleness hunts with a mechanical check, argued from MANIFESTO.md and three of this session’s own mistakes§3’s real instance (10 examples duplicating std.io) migrated, 7 of them (§3.1 has the three exceptions); §4’s own standing check, the actual proposal, not built yet

linearity-and-effects.md was the gating artifact: the decision set that determined whether this is a three-month prototype or a three-year project. It was settled before M2 started, its must-reject list was read as what it is — the M2 conformance suite, stated in advance — and M2 was then built against it slice by slice.

Linearity and effects

Status: settled, implementation in progress. This is the gating artifact for M2 (#2), and it was reviewed and accepted before any M2 code was written. The rules below are now the specification the implementation is measured against; §12 lists what is still open, and nothing there blocks the rest.

Implemented: §3 through §8 — modes, linearity, both borrow modes, arenas, exact effect rows, narrowing, capabilities and FFI. §2’s claim now holds in code: an effect is a borrowed capability, [io] on a signature means the function was handed an &!i Io it did not create, and the only checker that runs over any of it is the linearity and borrow checker §3–§5 already needed.

§7.4 and §8.4 arrived together, because Ffi(library) is the first capability that carries data and so the first thing there is to narrow. A foreign call is now a real call into libc, gated by a capability that names the library it reaches.

§6 was the last of them, and it is where §5’s bet is paid for: lexical regions are cheap to check precisely because a reference cannot outlive its block, and an arena is what data that outlives its creator lives in instead. Both open a region, both are checked by the same occurs-check.

Not implemented: §9 — the escape hatches.

Where the concrete syntax below differs from what was implemented, §5.3 says so and why. The syntax here was always illustrative (§1); the rules are what this document is for.

This document is the spec the M2 conformance suite encodes. Section 11 is that suite, written out as a fixture list, and it is the part to argue with hardest: design churn on this page is cheap, and churn after the checker exists ripples through the checker and every fixture.


1. How to read this

The syntax below extends M0’s. It is illustrative — a concrete syntax is not settled and the formatter’s rules are still open (#1) — but it is used consistently, so the examples can be read as programs rather than sketches.

Every rule in this document comes with at least one accept example and at least one must-reject example. A rule with no must-reject example is not a rule; it is a hope.

Three words are used precisely throughout:

  • consume — to use a resource in the one way that ends its life.
  • borrow — to obtain a non-owning reference, valid for a lexically delimited region and no longer.
  • capability — a value that is the authority to do something. Not a ticket that is checked, not a permission that is looked up: the authority itself, which you either hold or do not.

2. The one rule

The thesis of cancho is that ownership and effects are the same idea, and that a language designed from scratch can have one system where Rust and Koka have two. Here is that claim made precise:

Prior art, credited late (#75). Austral had already shown the first half: capabilities as linear values, with a root capability handed to the entry point, checked by the same machinery as every other linear value. What this document adds is the second half — that same authority stated as an exact effect row — and related-work.md §2 says what that buys and what Austral got to first.

The resource rule. Every resource is created once, used exactly as its mode permits, and destroyed once. A function’s signature names every resource that crosses its boundary: moved (by value), borrowed (by reference, for a named region), or performed (as an effect). There is one judgment, and the checker makes it once.

The unification is not a slogan about two checkers agreeing. It is an elaboration:

An effect is a borrowed capability, and an effect row is surface syntax for the capability parameters a function requires. [io] on a signature means the function takes an &!io Io it did not create. Elaboration inserts the parameter; after elaboration the only checker that runs is the linearity and borrow checker. There is no second pass that reasons about effects.

Everything else in this document follows from that sentence. In particular:

Lex-level claim (#1)What it actually is after elaboration
Allocation is an effectalloc takes &!h Heap; a function that allocates borrows a heap capability
A heap value is a linear resourceBox[h] T has mode res; free is the function that consumes it
FFI is a capability you must be grantedextern calls take &!f Ffi; there is no ambient way to obtain one
Effects are declared, not discoveredThe row is part of the signature because the parameters are
[] means pureNo capability parameters, so nothing outside the function can be observed to change

The last row is what buys examples-as-tests, carried over from Lex: a function whose row is [] can be run by the checker, because a pure function has nothing to run against. That is not a bonus feature bolted on later; it is the reason the row must be exact.


3. Modes: val and res

Every type has a mode, fixed at its declaration.

  • val — unrestricted. Copyable, discardable, no obligations. int, bool, &r T, and any struct or ADT built only from val fields.
  • res — linear. Exactly one use, no implicit copy, no implicit discard. Declared with res, or inferred for any aggregate containing a res field.
struct Point { x: int, y: int }          // val: all fields are val
res struct File { fd: int }              // res: declared
struct Pair { a: int, f: File }          // res: inferred, because File is res

Mode is structural and computed in one pass over a type’s fields. It is never inferred from use, and there is no mode polymorphism in M2 (see §10).

Reject — a res value copied:

fn twice(f: File) -> [] (File, File) {
    return (f, f);          // ✗ `f` is used twice
}

Reject — a val type declared to contain a res one:

val struct Wrapper { f: File }   // ✗ a val type may not contain a res field

An explicit val on a type whose members are all val asserts exactly what absence already checks, so the two are the same declaration and hash the same (docs/canonical-ast.md §3). res is different: it is a fact about the type that every caller can observe, so it changes the hash.

3.1 Generics carry mode; type parameters do not

Mode is computed after substitution, so a generic type takes its mode from its arguments: Held[File] is res and Held[int] is val, and neither needed a word written on it. Nothing new is required for that — it falls out of mode being structural.

A type parameter, though, is val, because mode is never inferred from use (§3) and a rigid T has no members to read. That has a consequence worth stating plainly, because §12 lists mode polymorphism as open and monomorphisation has quietly answered half of it:

A generic function body is checked once with its parameters rigid, where T is val, and then again for each copy, where T is whatever that copy instantiated it at. So fn id[T](x: T) -> T does work for both modes — and fn sink[T](x: T) -> int { return 0; } is accepted where it is written and refused at the instantiation that leaks.

That is the price, and it is the price §12 predicted: the error lands on the definition’s line, from a call site elsewhere. The message names the instantiation so it can be found, and tests/reject/res_leaked_from_generic.cho pins the behaviour. What is not available is a signature that says “this works for every mode” and is checked once — that remains open, and is the part that interacts with every other rule here.


4. Linearity: linear, not affine

Decision: linear. A res value must be consumed exactly once on every path. A value that reaches the end of its scope unconsumed is a compile error.

Two rejected alternatives, and why:

  • Affine (use at most once, silent drop allowed) permits exactly the failure the system exists to prevent: a file that is never closed, a capability that is never returned, an arena handle that is silently abandoned. Affine is linear with the interesting case removed.
  • Linear with implicit destructors, Rust’s answer, reintroduces invisible effects. A destructor is code — it can write, it can close, it can fail — and running it at a scope exit nobody wrote means the effect row on the enclosing function is a lie. “C’s effects must not be invisible” (#1) applies to our own drop glue first.

So: no implicit drop, ever. The obligation is discharged by naming the consumer.

4.1 What consumes a value

Exactly four things, and nothing else:

  1. Passing it by value to a parameter that takes ownership.
  2. Returning it.
  3. Destructuring it in a let or a match arm. The whole is consumed and the parts are produced, each subject to the rule in turn.
  4. Storing it into a res aggregate, which is case 1 by another name — the constructor is a function that takes ownership.

There is no drop(x) built into the language. A resource is destroyed by the function that knows how: close(f) for a File, free(b) for a Box, release(c) for a capability. Each such function is either a primitive or ends in destructuring to val parts.

Four places would otherwise be a fifth consumer by accident, and each is refused instead. They are consequences of the list above rather than new rules, but each has a fixture, because a rule with no must-reject fixture is a hope:

WrittenWhy it is refusedFixture
open(1); as a statementthe value is produced and droppedres_discarded.cho
f.fd on a res fa part read without taking the whole apart is a borrow, which is §5res_field_read.cho
_ => on a res scrutineeconsumes the value and produces no partsres_matched_by_wildcard.cho
Slot::Full(_) on a res payloadthe same, one level downres_payload_ignored.cho

Assignment is the fifth: f = open(2) overwrites whatever f held, so a live res binding must be spent before it can be reassigned (assign_over_live_res.cho). A spent one may be, and is live again after.

Destructuring takes the whole value apart — every field named, exactly once. A pattern that names some of the fields would be a partial move, which is not on the list (destructure_partial.cho).

Accept:

fn read_and_close(f: File) -> [fs] Bytes {
    let (bytes, f) = read_all(f);   // read_all gives the File back
    close(f);                        // consumed exactly once
    return bytes;
}

Reject — unconsumed at scope end:

fn leak(f: File) -> [] int {
    return 0;                        // ✗ `f` is still live
}

Reject — unconsumed on one path only:

fn sometimes(f: File, flag: bool) -> [fs] int {
    if flag {
        close(f);
        return 0;
    } else {
        return 1;                    // ✗ `f` is live on this path
    }
}

Reject — use after move:

fn after_move(f: File) -> [fs] int {
    close(f);
    return size(f);                  // ✗ `f` was consumed by `close`
}

4.2 Conditional consumption

Every path must agree about what is live. The checker joins branch states at a merge point and rejects any disagreement, rather than inserting the fix-up Rust calls drop flags — a dynamic drop flag is a runtime cost hidden behind a static guarantee, and hidden costs are what a systems language is judged on.

Reject — branches disagree:

fn disagree(f: File, flag: bool) -> [fs] int {
    if flag { close(f); }            // ✗ `f` is dead here, live in the else
    return 0;
}

Accept — both paths agree:

fn agree(f: File, flag: bool) -> [fs] int {
    if flag { close(f); } else { close(f); }
    return 0;
}

Yes, that is verbose. defer close(f); is the obvious sugar and it is deliberately not in M2: sugar that expands to a consumption on every exit path can be added later without changing one rule in this document, and adding it now would mean debugging the expansion and the checker at the same time.

Built — docs/defer.md. And the prediction held: not one rule in this document changed. defer is expanded during lowering into the statement it stands for, once per exit path, so the checker replays exactly the events it would have replayed for the version above.

4.3 Loops

A loop body must leave the live set exactly as it found it. A body that consumes a variable declared outside it is rejected, because the second iteration would use it again.

Reject:

fn loop_consume(f: File, n: int) -> [fs] int {
    var i = 0;
    while i < n {
        close(f);                    // ✗ consumed inside a loop body
        i = i + 1;
    }
    return 0;
}

The check is a comparison of two live sets at the back edge. It is not a fixpoint: the body is walked once, and the sets must match.


5. Borrowing: non-owning reads exist, and they are lexical

Decision: yes, M2 has non-owning references — and no, there is no borrow checker. The difference is that a reference’s validity is a lexical fact here, not an inferred one.

The mechanism is a region variable introduced by a block:

borrow f as &r in {
    let n = size(r);                 // r : &r File
    print_int(io, n)
}
// f is owned again here; r does not exist

Four rules, all local (the spelling differs a little from what was built — see §5.3):

  1. borrow x as &r in { .. } freezes x for the block and binds a reference of type &r T. Frozen means: not movable, not consumable, not uniquely borrowable.
  2. borrow mut x as &!r in { .. } locks x for the block and binds &!r T, a unique reference. Locked means: nothing else may touch x at all — no read, no second borrow, no move.
  3. &r T and &!r T have mode val. They are copyable and discardable, which is sound precisely because the referent is frozen or locked for the whole region and the region is a block.
  4. Escape is an occurs-check. The type of a borrow block’s result may not mention r. That is the entire escape rule, it is one traversal of one type, and it cannot diverge.

This is Cyclone’s idea with Rust’s inference deleted. What is gone:

  • no non-lexical lifetimes — a region is a block, full stop;
  • no lifetime inference — regions are written, or they are function parameters;
  • no variance, no subtyping of general types;
  • no borrow-checker dataflow — a variable’s state is Owned | Frozen | Locked, a three-valued flag set at block entry and restored at block exit.

Accept — a reference used and discarded inside its region:

fn describe(f: File, io: &!i Io) -> [io] int {
    borrow f as &r in {
        print_int(io, size(r))
    };
    close(f);
    return 0;
}

Reject — a reference escaping its region:

fn escape(f: File) -> [] &r File {   // ✗ `r` is not a region in scope here
    return borrow f as &r in { r };  // ✗ the block's result type mentions `r`
}

Reject — moving a frozen value:

fn move_frozen(f: File) -> [fs] int {
    borrow f as &r in {
        close(f);                    // ✗ `f` is frozen by the enclosing borrow
        0
    };
    return 0;
}

Reject — two unique borrows at once:

fn double_unique(buf: Bytes) -> [] int {
    borrow mut buf as &!a in {
        borrow mut buf as &!b in {   // ✗ `buf` is locked
            0
        }
    }
}

Accept — two shared borrows at once:

fn double_shared(buf: Bytes) -> [] int {
    borrow buf as &a in {
        borrow buf as &b in {
            return len(a) + len(b);
        }
    }
}

5.1 Region parameters on functions

A function that takes a reference is region-polymorphic, with the region written. The binder wears an &, so which parameters are regions is a fact about the declaration rather than something read off the parameter list:

fn len[&r](s: &r Bytes) -> [] int

At a call site the region parameter is instantiated with the caller’s region. That instantiation is first-order unification of a single name — not constraint solving, and not something that can fail to terminate.

5.2 The outlives relation, and why it is a stack

Two references obtained from different borrow blocks have different regions and cannot be used where one region is expected. Rust solves this with subtyping and variance. M2 solves it by noticing that regions are introduced by nested blocks, and nesting is a stack:

r_inner <= r_outer holds exactly when r_outer’s block lexically encloses r_inner’s. A value of type &r_outer T may be used where &r_inner T is expected. Nothing else coerces, and T never changes.

A function may declare the same relation between its region parameters, and the call site discharges it with the same lexical lookup:

fn copy_into[&dst, &src where src <= dst](d: &!dst Bytes, s: &src Bytes) -> [] int

Checking src <= dst is a walk up a stack whose depth is the block nesting of the function being checked. It is O(depth), it has no fixpoint, and the relation is total because a stack is a total order.

Reject — unrelated regions:

fn unrelated(a: Bytes, b: Bytes) -> [] int {
    borrow a as &x in { borrow b as &y in { same_region(x, y) } }
    // ✗ `x` and `y` are siblings; neither outlives the other
}

Instantiation is one assignment, which has a consequence worth stating: a function with one region parameter requires all its reference arguments to be at one region, and whichever argument is checked first decides which. Two references from different blocks cannot both fit, even when one outlives the other — the coercion runs from the argument to the parameter, not between two arguments. Declaring two parameters and the where clause that relates them is how a function says it accepts both, and that is exactly what copy_into above is for. Widening this is constraint solving, which §10 spends its whole argument refusing.

5.3 What was built, where it differs

§1 says the syntax here is illustrative. Three places where the implementation chose differently, and why:

  • borrow is a statement, not an expression. cancho blocks are statement lists with no tail expression, so borrow f as &r in { r } has nowhere to put a result. It is shaped like if and while instead, and a value leaves a block the way it always has — by return, which is the case rule 4 is about.
  • A region binder wears its &: fn len[&r], not fn len[r]. Reading which bracket entries are regions off the parameter list leaves a region nobody used ambiguous, and makes every diagnostic about the declaration a riddle.
  • Escape is checked in two places, not one. Rule 4 says the block’s result may not mention r. With no block result, the two ways out are a return and a binding declared outside the block whose type inference fills in from inside it. Both are the same occurs-check over one type; tests/reject/reference_escapes_via_inference.cho is the second one, and it is not hypothetical.

A place is narrower than it looks. Writing through a &!r needs somewhere to write to, and the left side of an assignment is a whole binding or a field reached through a unique reference — not a field of an owned local. c.n = 2 on a local is refused: that is a partial write, and what a partial write means for a binding holding a res field is a question §4 does not answer. Assigning the whole value says the same thing and asks nothing new. The restriction is not load-bearing and can be lifted once §4 has an answer.

There is no *r yet: a reference to a struct is read with .field, which covers what §5’s own examples do. A reference to a scalar can be made and passed and not otherwise read, which is a wart rather than a rule.

A unique reference may be used where a shared one is expected, and never the reverse. &!r T is &r T plus permission to write, so passing one read-only hands the callee strictly less than it already held. This arrived with §6 rather than here: arenas hand back &!a and nothing else, so without the coercion no function written against &r could touch allocated data at all. The referent stays invariant either way — “T never changes”.

Locking is what makes the implementation cheap. A reference is a pointer at a buffer the referent is spilled into for the block. A shared borrow needs no write-back, because the referent is frozen and the two cannot drift. A unique borrow reads the buffer back when the block closes — sound precisely because nothing else may touch the value while it is locked, so the buffer is the only version that moved. Two copies of one &!r are two copies of one pointer, so writes through them alias correctly rather than racing to be last writer.


6. Arenas are regions

A region variable is introduced by borrow, and by one other thing:

region a {
    let node = alloc[a](Node { value: 1 });   // node : &!a Node
    walk(node)
}   // the whole arena is released here, in O(1)

region a { .. } opens an arena. alloc[a] allocates in it and hands back a unique reference carrying the region. At block exit the arena is released wholesale — one pointer reset, no traversal, no per-object bookkeeping.

What escapes: nothing whose type mentions a. This is the same occurs-check as §5, which is the point — an arena’s lifetime and a borrow’s lifetime are one mechanism, not two that happen to look alike.

Nesting is the same stack: an inner region may hold references into an outer one (r_outer <= r_inner by §5.2), and never the reverse.

Reject — a reference escaping an arena:

fn escape_arena() -> [] &a Node {    // ✗ `a` is not a region in scope here
    return region a { alloc[a](Node { value: 1 }) };   // ✗ result mentions `a`
}

Reject — an inner region’s reference stored into an outer one:

fn outlive(outer: &!o Slot) -> [] int {
    region i {
        let n = alloc[i](Node { value: 1 });
        store(outer, n);         // ✗ `&!i Node` does not outlive `o`
    }
    return 0;
}

6.1 Arenas hold val data only

An arena releases memory. It does not close files, release capabilities or run anything. So:

alloc[a] requires its argument’s type to have mode val. A res value placed in an arena would have its memory reclaimed at block exit with its linear obligation undischarged — a leak with a static blessing.

Reject:

fn arena_res(f: File) -> [] int {
    region a {
        let p = alloc[a](f);     // ✗ File is res; arenas hold val data only
        0
    }
}

This is restrictive and knowingly so. The alternative — arena teardown as a consumption event, with per-object finalisers — reintroduces implicit destructors and turns an O(1) release into a traversal. If it turns out to bite in practice, the fix is a separate scope construct with explicit registration, not a weakening of this rule.

What was built. region a { .. } and alloc[a](v), spelled as above. The region name is written bare in both, because & is the reference constructor and there is nothing in either position for it to construct.

An arena is a borrow block with the referent taken out. Opening one pushes a block onto the same table §5 uses, with the same parent link — so §5.2’s outlives relation, §5’s occurs-check and the scope rules apply to it without a line of new reasoning. reference_escapes_arena.cho is refused by the code that refuses reference_escapes_borrow.cho, and inner_region_stored_in_outer.cho by the code behind unrelated_regions.cho. That is §6’s claim — “an arena’s lifetime and a borrow’s lifetime are one mechanism, not two that happen to look alike” — and it is structural rather than asserted.

alloc[a] hands back &!a T, an ordinary unique reference. There is no second notion of a pointer-into-an-arena, so reading, writing through it and passing it to a region-polymorphic function all work unchanged. a must be an arena open right now: a borrow block’s region is a region but has no chunk behind it, and a region parameter is a caller’s, not this function’s to allocate in.

One new coercion, which §6 forces. A unique reference is accepted where a shared one is expected, never the reverse — &!r T is &r T plus permission to write, so handing one over read-only gives the callee strictly less than it already had. Without it nothing written against &r could touch arena data at all, since alloc hands back &!a and nothing else. The referent stays invariant.

At runtime an arena is one malloc when the block opens and one free when it closes (as built since region-chunk-cache.md: one chunk taken from the thread’s stack of freed ones, or malloced, and given back, cleared, when it closes), with a bump pointer in between; a return out of a region releases every arena it leaves, innermost first. Release is one call whatever was allocated, which is the O(1) the section is trading expressiveness for — and it is only a free, because §6.1 kept everything with an obligation out of the chunk.

Exhausting the chunk traps. A single chunk is what makes release one call; growing it would make release a walk. The alternative to trapping is writing past the end of an allocation, which is undefined behaviour, which this language does not have. So the arena is 64 KiB and asking for more kills the process, deterministically, the same way division by zero does. Growth that keeps both properties is an M3 question and the trap is what keeps the answer honest until it is answered.


7. Effects

7.1 Sets, not rows

Decision: an effect row is a canonically ordered set of labels. No duplicate labels, no row variables in M2.

“Rows or sets” (#2) is a question about what duplicate labels buy. They buy effect handlers and masking — a Koka catch that handles the innermost exn and leaves an outer one alone. M2 has no handlers, so duplicates would be a cost with no purchase. Sets also have the property the rest of this project needs: a canonical order makes an effect row hashable, and a signature that hashes deterministically is what per-unit identity is made of (#1).

Two operations are needed, and only two:

  • union, to compute a body’s effects;
  • subset, to check a call against a declaration.

Both are linear in the number of labels, which is small and statically bounded.

7.2 Declared at boundaries, inferred inside

Decision: every function signature declares its row. Inference happens only within a body.

Whole-program effect inference is exactly the non-local, potentially-diverging analysis the totality commitment forbids, and an inferred row is a contract nobody wrote and everybody depends on. Inside a body there is nothing to infer but a union over the calls, which is a fold.

The check at a call site is: the callee’s row is a subset of the enclosing function’s declared row.

Reject — an undeclared effect:

fn quiet(io: &!i Io) -> [] int {
    print_int(io, 1);                // ✗ `print_int` performs `io`; row is []
    return 0;
}

Every signature writes its row, including []. An absent row would be an inferred one, and “inferred at the boundary” is the thing this section refuses — so purity is written down and visible rather than worked out.

The grounding runs the other way from what a reader might expect: a label is not declared anywhere, it is performed by a primitive, and a label nothing performs can never appear in an exact row. So [telepathy] is refused by §7.3 without anyone maintaining a list of legal effect names, and adding a new effect means adding something that performs it.

7.3 A declared effect that is not performed is an error

Not a warning. The row is exact or it is decoration, and an inexact row means [] no longer means pure — which costs examples-as-tests, and costs a signature hash that means something.

Reject — an over-wide row:

fn pure_after_all(io: &!i Io) -> [io] int {
    return 1;                        // ✗ declared `io`, performs nothing
}

This is the same rule as lex agent-guidelines §1.2 (“narrow effects, always; if the checker rejects, narrow the body, not the signature”) turned from a guideline into a type error. The cost is that a signature cannot reserve room for a future implementation; that tension is recorded in §12.

7.4 Narrowing

An effect label may carry a value, which is what makes [fs_write("/tmp/x")] different from [fs_write]. The value comes from the capability:

fn write_log(fs: &!f Fs) -> [fs_write(f)] int

and f’s own type records what it was narrowed to. Narrowing is a function on capabilities:

let logs = narrow_path(fs, "/var/log/app");   // Fs("/var") -> Fs("/var/log/app")

Narrowing only, in both directions. A capability can be attenuated and never widened — the same commitment lex-os makes for manifests, for the same reason: a program must not be able to grant itself what it was not given.

In M2 the argument to a narrowing function must be a literal, so the refinement is checkable structurally. General compile-time refinement waits for comptime, which is deliberately excluded from “minimal” (#1).

Reject — widening:

fn widen(fs: &!f Fs) -> [fs_write("/")] int {
    return write(fs, "/etc/passwd");  // ✗ `f` is narrowed to "/var/log"
}

What was built. narrow(cap, "libc"), on the one capability that carries a value: Ffi. What a capability was narrowed to travels in its type — Ffi("libc") and Ffi("libm") are different types — and the label carries the same text, so ffi and ffi("libc") are different labels and a row containing one is a different SigId from a row containing the other.

Refinement is prefix extension, which is what makes it checkable by reading rather than by solving: narrow(c, t) is accepted exactly when t extends what c already names, and refused otherwise.

(M3 added Fs(prefix) as the second capability carrying a value, over the same narrow. One rule had to be made sharper for it: a path prefix extends at a / or not at all, because /tmp is a byte prefix of /tmpevil and does not contain it — fs_sibling_prefix.cho. A library name has no such structure, so Ffi keeps the plain textual rule. See docs/filesystem.md §1.1.) The unnarrowed root is the empty string, a prefix of everything, so the Ffi that split hands out can still become any library while an Ffi("libcrypto") can never become Ffi("libc") — effect_widened.cho. Narrowing to what a capability already names is refused too: it grants nothing and would read as though it had.

Narrowing consumes. The wider capability is spent, which is the whole point — after narrowing there is no way back to it, because linearity says there is no second use. For the same reason a borrowed capability cannot be narrowed: a borrow is the promise to give it back.

Narrowing into several. For the two path capabilities, Fs and Exec, narrow(cap, "a", "b", ...) spends cap once and answers a tuple of cap’s type narrowed to each literal, in the order written. The lattice still has one chain per capability, but a program may now hold several capabilities that are not on one chain, each strictly inside the one consumed, so the children together reach nothing the parent did not and main’s narrowing line still names everything the program will ever touch. The literals are checked as the single form checks them, and must be pairwise unrelated. docs/narrowing-into-several.md.

Covering, not equality. §8.2’s discharge rule reads the same prefix order: owning Ffi("") discharges every ffi(...) label, because its holder can narrow to any of them, and owning a World discharges both io and the Ffi root. That is why main still declares [] while the program calls into libc. For a path label (fs_read, fs_write, exec) the order is the path prefix, extends_path, not the byte prefix: owning Fs("/tmp") does not discharge fs_read("/tmpevil") (docs/filesystem.md §1.1).


8. Capabilities

8.1 What one looks like

What was built. World, Io, Ffi(library) and — from M3 — Fs(prefix), plus the Split that split hands back: let Split { io, ffi, fs } = split(world);. Heap still waits for general allocation: a capability for an effect nothing can perform is decoration, which is what §7.3 refuses for rows and what this refuses for the same reason.

A capability the program does not need is still a resource, so a program that calls into no library releases its Ffi rather than ignoring it. Adding a capability to Split is therefore a breaking change every program has to acknowledge — which happened twice, for Ffi and again for Fs, each time touching every program in the tree. That is the honest cost of there being no ambient authority, and it is the cost being paid rather than avoided.

The prelude’s types are predeclared rather than written in a program, and a program may not declare its own — capability_redeclared.cho. Nor write one as a literal: Io { } would be an ambient constructor spelled differently, so it is refused (no_ambient_capability.cho). Since World and Io carry no fields, taking one apart would end authority without naming a consumer, so that is refused too and release is the only way (capability_destructured.cho). Split is destructured on purpose.

Owning discharges; borrowing declares. §8.2’s remark that main’s row is [] is implemented as a rule: an effect whose capability a function owns by value does not appear in its row, because ownership is already visible in the parameter list and is strictly stronger than borrowing. A borrowed &!i Io discharges nothing — it is exactly what [io] names.

A capability is an ordinary res value. It has no special kind, no special syntax, and no runtime representation beyond what its type says — most are zero-sized, and the ones that are not (a file descriptor, an arena pointer) are the size of the thing they authorise. Capabilities erase at compile time except where they carry data. Threading one costs nothing.

res World                     // the root of all authority
res Heap                      // allocation
res Io                        // console
res Fs(prefix: Path)          // filesystem below a prefix
res Ffi(library: Name)        // foreign calls into one library

8.2 Where one comes from

One place:

fn main(world: World) -> [] int {
    let Split { heap, io, fs, ffi } = split(world);   // consumes `world`
    ...
}

World is linear and the runtime hands over exactly one. split consumes it. There is no ambient constructor, no Io::global(), no unsafe { } that conjures one. A function that is not given a capability cannot perform its effect, which is the whole safety story, stated as a type.

That main’s own row is [] is not a quirk: a row lists the capabilities a function borrows, and ownership is already visible in the parameter list. Owning authority is strictly stronger than borrowing it, and strictly more visible.

8.3 How one is threaded

By borrow, not by move — a callee should not consume its caller’s authority:

fn greet(io: &!i Io) -> [io] int {
    return write_str(io, "hello\n");
}

fn main(world: World) -> [] int {
    let Split { heap, io, fs, ffi } = split(world);
    borrow mut io as &!i in { greet(i) };
    release(heap); release(io); release(fs); release(ffi);
    return 0;
}

The four release calls are the linearity rule doing its job: authority is a resource, and a resource is destroyed exactly once. A program that forgets one does not compile.

Reject — an effect performed without the capability:

fn sneaky() -> [io] int {
    return write_str(global_io(), "hello\n");   // ✗ no such thing as global_io
}

Reject — a capability used after release:

fn after_release(io: Io) -> [io] int {
    release(io);
    borrow mut io as &!i in { greet(i) }        // ✗ `io` was consumed
}

8.4 FFI

A foreign call is an effect like any other, and its capability names the library:

extern fn strlen(f: &!ffi Ffi, s: &r Bytes) -> [ffi("libc")] int

The declaration is the only place a foreign signature is written, the capability is the only way to reach it, and the row makes the call visible in every caller’s signature all the way up. C’s effects stop being invisible at the exact point they enter the program.

What was built. extern fn declarations, lowered to real imports and called through the platform’s own convention:

extern fn labs[&f](ffi: &f Ffi("libc"), n: int) -> [ffi("libc")] int;

A foreign declaration is a signature with no body, so its SigId and BodyId are the same hash — there is nothing else it could be a hash of. It is region-polymorphic like any other function, because the capability it takes is borrowed, but it takes no type parameters: C has none to instantiate.

The declaration is where the rules are enforced, because it is the only place the signature is written. Three of them:

  • the row and the capability parameters must agree exactly, in both directions — a declaration naming an effect it holds no capability for is ffi_without_capability.cho, and one holding a capability it does not declare is foreign_effect_undeclared.cho;
  • the borrowed capability must name a library. The unnarrowed root names none, so Ffi("") is refused here — narrow first;
  • only what C can name crosses: int, bool, (), and borrowed capabilities, which do not cross at all. An aggregate has no layout agreement between the two sides yet, and a reference to anything but a capability would be a pointer this compiler has not promised to lay out.

int at the boundary is the platform’s 64-bit integer, not C’s int — which is why the fixtures call labs rather than abs. A narrower C integer type needs a type to name it, and M2 does not have one.

The capability does not travel. It is checked, then erased: what libc receives is the integer and nothing else. narrowed_capability.cho calls labs(-7) and prints 7, which it could not do if a zero-sized capability were pushed in front of the argument.

A foreign name is not also a written function’s name: two answers to one call is one answer too many.


9. Escape hatches

Built, and this section was half wrong. See docs/sharing.md.

Both hatches below were written. Gen is a library exactly as described and now lives in examples/slab/. Rc is not expressible at all: it needs a copyable pointer, and this language has none — Box[T] is res, a reference is bounded by its region, and there is no third thing. Three ways of trying are three fixtures in tests/reject/, each refused by a different rule that was not written with Rc in mind.

So “neither touches the checker” is the claim that did not survive being built: a real Rc is a language feature, not a library. sharing.md §2.3 has what you can have instead — shared ownership is reachable, the ambient handle is not.

The rest of this section stands, and sharing.md supersedes the Rc row of the table.

Linearity cannot express shared mutable graphs, observers, or any structure where “who owns this” has no answer. Two hatches, both libraries, not language features — neither touches the checker, and that is the point:

HatchWhat it isCost, paid where
Rc[h] TNon-atomic reference count, allocated from hClone: one increment. Release: one decrement and a branch. Dereference: free. Cycles leak — there is no collector and there will not be one
Gen[T]A generational handle {index, generation} into a Slab[T]Dereference: a bounds check, a generation compare, and a branch. A stale handle fails, returning None — it is never undefined

Both are res. Rc is consumed by release_rc, a Slab by free_slab. Neither is reached for by default, and neither hides its cost: the runtime cost table above is part of the contract, in the same spirit as [budget].

Gen is the one to prefer. A stale generational handle is a value — None — and the program decides what to do about it. A dangling pointer is undefined behaviour and the program does not get a say. That asymmetry is most of why this language exists.

That last paragraph turned out to be stronger than it knew: Gen is not merely the one to prefer, it is the only one of the two there is. See docs/sharing.md.


10. Why the checker stays fast and total

The commitment is “fast and total” (#1), which means: every check terminates, and the cost is bounded by the size of the function being checked. What earns that here:

  1. No global inference. Every signature is complete — types, regions, effects. A function is checked against its callees’ signatures, never against their bodies.
  2. No fixpoints. The loop rule compares two live sets at a back edge rather than iterating to convergence. Branch joins compare and refuse rather than patching up.
  3. Regions are a stack. <= is a lexical lookup, not a constraint graph.
  4. Effects are sets. Union and subset over a small, statically bounded label set.
  5. No subtyping except the one region coercion, which never changes the type it decorates. No variance. No trait resolution — there are no traits (#1, non-goals).
  6. No higher-order functions in M2, so no effect row variables and no mode polymorphism. This is the single largest simplification in this document, and it is why the row-versus-set question in §7.1 could be answered so cheaply. When closures arrive they bring row variables with them, and this section is where the argument has to be re-made. (function-values.md §4.3 re-made it for captureless function values, which bring no row variables: a call through one is checked against the row in its type. Row variables arrive only with abstracting over rows.)

Each function’s check is one walk of its body carrying a three-valued state per local and a set per call. That is linear in body size with an O(nesting-depth) lookup, and nothing in it can diverge.


11. The must-reject suite

This is the deliverable the gate actually cares about: the rules above, restated as fixtures. M0 already has the harness — tests/reject/*.cho, each fixture declaring its own expected message in a //~ ERROR header, run by crates/cancho/tests/conformance.rs. M2 adds to it; it does not invent anything.

A ✓ in the last column means the fixture exists and the rule is enforced. Every row carries one.

FixtureRule§
res_copied.choA res value may not be used twice3✓
val_contains_res.choA val type may not contain a res field3✓
unconsumed_at_scope_end.choA live res value at scope end is an error4✓
unconsumed_on_one_path.choEvery path must consume4✓
use_after_move.choA consumed value may not be used4.1✓
branches_disagree.choBranches must agree about what is live4.2✓
consume_in_loop.choA loop body may not consume an outer binding4.3✓
reference_escapes_borrow.choA block’s result may not mention its region5✓
move_while_frozen.choA frozen value may not be moved or consumed5✓
read_while_locked.choA uniquely borrowed value may not be read5✓
two_unique_borrows.choOne unique borrow at a time5✓
unrelated_regions.choSibling regions do not outlive each other5.2✓
region_param_unsatisfied.choA declared <= must hold at the call site5.2✓
reference_escapes_arena.choNothing mentioning the arena’s region escapes it6✓
inner_region_stored_in_outer.choAn inner region’s reference may not be stored outward6✓
arena_holds_res.choalloc takes val data only6.1✓
undeclared_effect.choA call’s row must be a subset of the declared row7.2✓
effect_declared_not_performed.choAn over-wide row is an error7.3✓
effect_widened.choA capability may be narrowed, never widened7.4✓
no_ambient_capability.choThere is no way to obtain a capability but to be given one8.2✓
capability_used_after_release.choA capability is a resource8.3✓
capability_leaked.choA capability must be released8.3✓
ffi_without_capability.choA foreign call requires its Ffi capability8.4✓

§7 adds three more: effect_not_propagated.cho (a row is transitive), ungrounded_effect_label.cho (a label nothing performs) and effect_row_required.cho (the syntax).

§8.4 adds one beyond the table — foreign_effect_undeclared.cho, a foreign declaration holding a capability its row does not name.

§8 adds three: world_leaked.cho (the root is a resource too), capability_destructured.cho (taking one apart is not releasing it) and capability_redeclared.cho (a program may not declare its own Io). main_takes_the_world.cho replaces M0’s main_takes_arguments.cho, since main now takes exactly one thing.

§5 adds eight must-reject fixtures beyond the table, for the rules §5.3 describes and for the syntax: region_not_in_scope.cho, reference_escapes_via_inference.cho, assign_while_frozen.cho, borrow_after_move.cho, borrow_mode_mismatch.cho, unique_borrow_of_frozen.cho, write_through_shared.cho and assign_field_of_local.cho. Its accepting counterparts are borrow_and_return.cho, two_shared_borrows.cho, nested_regions.cho and unique_borrow.cho.

§4.1’s four accidental consumers and §3.1’s instantiation rule add six more must-reject fixtures beyond the table — res_discarded.cho, res_field_read.cho, res_matched_by_wildcard.cho, res_payload_ignored.cho, assign_over_live_res.cho, destructure_partial.cho and res_leaked_from_generic.cho — plus var_destructure.cho for the syntax. Their accepting counterpart is destructuring.cho.

Each fixture is the smallest program that triggers its rule and nothing else. An accepting counterpart goes in tests/accept/ for every one of them — a rule that rejects everything is not a rule either:

FixtureShows
consume_once.choThe straight-line happy path✓
consume_on_both_paths.choBranch agreement✓
borrow_and_return.choA borrow used and discarded inside its region✓
two_shared_borrows.choShared borrows nest✓
nested_regions.choAn inner region reading an outer one✓
arena_roundtrip.choAllocate, walk, release in O(1)✓
effect_exact.choA row that is exactly what the body performs✓
narrowed_capability.choAttenuation, and a call that fits inside it✓
threaded_io.chomain splitting World and threading Io down three frames✓

12. Open questions

These are genuinely open. They are not blocking — each can be settled after M2 code exists without invalidating a rule above — but they are the places to push if something here feels wrong.

  • Reserving room in a row. §7.3 makes an unperformed declared effect an error, which forbids a signature that anticipates its implementation. Is that right for a published library, where widening a row later is a breaking change? An explicit opt-out is easy to add and hard to take back.

  • defer. Answered — see docs/defer.md. Yes, still visible, and the question turned on what the word means here: not “written on the line where it runs” but the type says what happened. The effect is still in the row, the exactly-once rule is still enforced on every path, and the line is still written — one below the acquisition, which is where a reader looks for the pairing. A destructor would have failed that test; defer names the function.

  • Capability release at main. Answered no — see docs/authority.md. The affine-hole argument holds, and it is not the interesting one: even if reclaiming were safe it would be wrong, because those four lines are the only place a program’s authority surface is written down. main owns rather than borrows, so its row is [] and every entry point in this repository has the same signature — the releases are the declaration, not ceremony around it. What the tooling owed was to read them, which cancho authority now does.

  • Mode polymorphism. Answered — see docs/mode-polymorphism.md. The half-answer below was right: monomorphisation does make fn id[T](x: T) -> T work at both modes. The signature that says so is [T: val], checked once and enforced at the call site, and an unbounded parameter is checked as res. Checking the claim also found a leak and a double free: a val on a generic declaration was trusted rather than checked, so Wrap[Box[int]] was val by assertion. The original text follows.

    Half-answered by §3.1: monomorphisation does make fn id[T](x: T) -> T work at both modes, because each copy is checked at the type it was instantiated at. What is still open is a signature that says so and is checked once — the part that interacts with every rule here at once, and the part that would keep the error off the definition’s line.

  • Rc cycles. Documented as a leak. A systems language may be entitled to say exactly that, or a Weak may be table stakes.

  • Budget. Answered no — see docs/budget.md. It does not belong here. lex-os already separates the three questions a capability language faces — may this reach X at all (the type check, which is this document), which host or path (the perimeter), and how much (the supervisor, charged per mediated command) — and a budget’s units settle it: wall-clock seconds, commands, money in cents. None of those is a property of a program’s text. What was wanted is legibility rather than enforcement, and that is cancho authority --output json.


13. What this forces on M1

M2 cannot be bolted onto an M1 that made these impossible. Three things the type checker must get right the first time, before any of the above is written:

  1. Signatures are complete and are the unit of checking. A body is checked against signatures only. If M1 lets any part of a signature be inferred from a body, §10.1 is already lost.
  2. Types are values the compiler can compare cheaply and hash canonically — including, eventually, regions and effect rows. An effect set’s canonical order is not an afterthought; it is what makes a signature hash.
  3. The branch join is a real operation. M1’s checker should join branch states even when the only state is types, because §4.2 is that same join with a live set added.

Nothing in M1 needs to know what a capability is. It does need to be built so that adding one is adding a rule, not rewriting a pass.

Bootstrap and self-hosting

cancho’s compiler started in Rust, because Cranelift is a Rust library and the first milestone had to prove a code generator. It is now being written in cancho, stage by stage, with the Rust compiler as the check each stage is held to. This page says where that stands (2026-10-15) and how to use it; self-hosting.md has the stage-by-stage record and the measurements, and the plan to finish is epic #491.

Where it stands

  • The compiler in cancho is examples/selfhost/: about 22,000 lines of cancho. It reads a program and writes LLVM IR; clang finishes the build.
  • It is the default. cancho build, run and test write the code with it on macOS and Linux (x86-64 and aarch64), after the Rust front end has checked the program (see below). It is built once, from sources compiled into the cancho binary, and cached; nothing has to be next to the binary. A build it cannot do (it has no code for a construct, or it fails) is done by the Rust compiler, and the Cranelift backend and WebAssembly stay the Rust compiler’s. CANCHO_COMPILER=rust asks for the Rust compiler alone.
  • It compiles itself to a fixed point, on three systems: built on macOS, on Linux x86-64 and on Linux aarch64 (a Raspberry Pi 5), it writes byte-identical IR for its own source.
  • It builds real programs. The whole conformance suite passes with every build going through the default (710 tests on macOS aarch64), and CI runs the suite through the compiler in cancho alone, with no Rust check and no fallback (CANCHO_COMPILER=auto), on macOS, Linux x86-64 and Linux aarch64, minus the few differences listed in .github/selfhost-known-differences.txt. The projects cancho-cache, cancho-oci, cancho-robot, cancho-gpu and cancho-hooks build with it.
  • It nearly refuses what the language refuses. Of the 337 programs in tests/reject/ that are not a property of a target (marked //~ TARGET), it refuses 333 with the rule the Rust compiler gives; the 4 left are listed in reject_gaps.txt: a row in the type of a function value, what an owned capability discharges by path, and a region settled by inference (#512). That is why the default still lets the Rust front end check first, and it stops when the list is empty (a test says so).
  • A refusal says why without the Rust front end when the compiler in cancho is named alone: where (file:line:column), the sentence of the rule (Rule::explanation, generated into the compiler’s tables), the rule, the line and what of it. The sentence is the rule’s, the same for every program it refuses; the names and types in the Rust compiler’s messages are a later stage.
  • Only code generation goes through it. check, authority, ids, fmt, vcs and the Cranelift and WebAssembly backends are still the Rust compiler’s. Windows is not a target of either compiler.

Using it

cancho build program.cho --std -o program               # the default
CANCHO_COMPILER=rust cancho build program.cho -o program  # the Rust compiler alone
CANCHO_COMPILER=auto cancho build program.cho -o program  # the one in cancho alone: what it refuses, no fallback
cancho build --compiler ./some-compiler program.cho       # a compiler you built yourself

The first build takes a few seconds more: it builds the compiler in cancho (with the Rust one) into the cache, keyed by a hash of its sources ($CANCHO_CACHE, or ~/.cache/cancho). CANCHO_SELFHOST_SOURCES=<dir> builds it from a directory instead, for working on it. Named alone, a construct it has no code for yet is a refusal that says so, at that construct.

What is left

Tracked in #491: the last four refusals; the other commands (check, fmt, …); the Cranelift and WebAssembly backends; the runtime text that is still generated from the Rust crates; keeping golden outputs for when the Rust compiler is gone, and a seed that needs no Rust; compile time and whether Windows is a target.

Code quality, measured once (Linux x86-64, tests/programs/tls_prims_bench.cho built with --std, instructions a call under valgrind as scripts/tls_perf.py check counts them, against the baseline recorded for the Rust compiler): the Rust compiler is within 1.06 of the baseline on all 36 rows; the compiler in cancho is 1.26 to 1.32 times it on the P-256 rows (p256_shared 1.32, p256_public 1.30, ECDSA 1.26 to 1.28), about the same on the AES-GCM, ChaCha20-Poly1305, HKDF, HMAC, SHA-2 and X25519 rows (0.97 to 1.13) and 4.97 on calibrate (a loop of a single instruction a step for the Rust compiler, 5,007 instructions a call against 1,008). The binary is 360,208 bytes against 318,656. So the tls-perf job builds with CANCHO_COMPILER=rust: its baseline is the Rust compiler’s code, and it guards that. Closing this gap, or recording a baseline of the compiler in cancho, is code quality’s share of #491.

History: the v0 compiler

Decisions M0 had to settle to exist, recorded here so they are not re-litigated by accident. Each was an open question in #1.

Bootstrap host language: Rust

The v0 compiler is written in Rust. The alternative was hosting it in current Lex.

Rust wins for one reason that outweighs the rest: Cranelift is a Rust library. Hosting in Lex means reaching a code generator through FFI on day one, which is the part of the pipeline M0 exists to prove.

This was a bootstrap decision, not a permanent one: the compiler is now being written in cancho itself (above), and the Rust one is the check it is held to.

File extension: .cho

.cho over .lxs. Shorter, and the collision risk (LiveScript) is not one this ecosystem will meet in practice. cancho build hello.cho.

Layout of the Rust compiler

crates/cancho-syntax    lexer, canonical-shaped AST, parser
crates/cancho-ir        resolution and well-formedness checks; the M0 IR
crates/cancho-codegen   Cranelift lowering, native object emission
crates/cancho           the CLI
examples/                programs that are meant to be read
tests/accept             fixtures that must compile and run
tests/reject             fixtures that must be refused, each stating why

Everything the compiler can refuse, it refuses in cancho-ir. The backend takes IR that has already been proven well-formed, so Cranelift lowering has no error path for a program — only for the environment.

The M0 surface

Historical: this is what M0 shipped. M1 adds bool, &&, || and !, and the rules below are unchanged by it.

One type (int, 64-bit signed), functions and calls, arithmetic, comparison, if/else, while, and let/var bindings. That is the whole language.

Rules M0 already enforces, because they were cheaper to write than to retrofit:

  • Every path through a function must return. The check is structural and conservative: a while never counts as a terminator, even while 1 { }.
  • Code after a return is refused rather than silently dropped.
  • let is immutable and so are parameters; var is the mutable binding.
  • A binding may shadow one from an enclosing block, but not one in its own.
  • An initialiser cannot see the binding it initialises: let x = x; reads an outer x or is refused.
  • Functions are not values. M0 has no closures and no function pointers, so a bare function name is an error rather than a mystery.

Things that are scaffolding, and what replaces them

ScaffoldWhy it existsReplaced by
putchar as a compiler builtinA program must be able to produce output before there is FFIM2 made output an effect that must be granted: putchar now takes an &!i Io. It stays a builtin because Io is the console rather than a named library; an extern fn reached through Ffi("libc") is what replaces it once there are strings to write
Wrapping arithmetic on +, -, *Overflow semantics are still an open decision (#1)M3: Defined-behaviour arithmetic
cc as the linkerThe C runtime supplies _start and libcMuch later; “no Rust” is reachable long before “no C”

Two entries have already left this table. M0 had comparisons yield int (0 or 1) and had if/while test any integer for non-zero, both because it had no bool. M1 introduced one, so a comparison has the type it always meant and a condition is required to be that type. The convention became a rule, which is the shape every scaffold above is meant to end in.

Division is not on that list. a / b and a % b trap on a zero divisor and on int::MIN / -1. A trap is defined behaviour; C’s answer here is not, and that difference is the whole point (#1). tests/ has a fixture that runs the trap and asserts the process dies rather than continuing with nonsense.

What the AST already does for canonicalisation

Nothing hashes the AST yet — per-unit identity is M3 work and docs/canonical-ast.md is unwritten. But an AST retrofitted for hashing is an AST whose hashes are already wrong, so the shape is settled now:

  • Nodes live in arenas; spans live in parallel side tables. A hash walks the arenas and never sees a byte offset.
  • Whitespace, comments and redundant parentheses die in the lexer and parser. Two files differing only in layout produce identical arenas — there is a test that asserts exactly this.
  • Integer literals are stored as values, not spellings: 007 and 7 are one node.
  • Names intern to dense indices, assigned in order of first appearance.
  • Every list is a Vec in source order. No set, no map, no iteration order that depends on an address or a hash seed reaches any output.

Exit codes

codemeaning
0success
1the program was refused (a located diagnostic was printed)
2the command line was wrong
3the environment failed: no linker, unwritable output, unsupported host

cancho run is the exception: it exits with the compiled program’s status, so cancho run p.cho and cancho build p.cho && ./p agree.

The canonical AST and per-unit identity

Status: proposed. Written alongside the first implementation of hashing (M3, #1). The rules below are what cancho-id implements; the parts that are not yet contracts are called out in §8, and nothing here is frozen until that section is empty.

cancho claims a “canonical, content-addressable AST, designed in from day one” (#1). M0 shaped the AST for it and M1 left that shape intact. This document is the other half: what exactly gets hashed, what two programs must agree on for their hashes to agree, and — the part that matters most — what a hash is allowed to change with.


1. What a unit is

A unit is one top-level declaration: a function, a struct, or an enum. Each has an identity of its own, independent of the file it sits in and of what sits beside it.

That is the whole point of per-unit identity. Moving a function between files, or reordering declarations, changes nothing about it. A unit is not a line range.


2. Two identities, not one

Every function has two hashes, and the split is load-bearing:

coverschanges when
SigIdthe signature: name, parameter types, return typethe contract a caller depends on changes
BodyIdthe implementationthe code changes, however little

A caller depends on a callee’s SigId and on nothing else about it. So rewriting a function’s body — a faster algorithm, a clearer loop — leaves every caller’s identity untouched. Only a change a caller could observe propagates.

This is also what makes the graph acyclic. A body refers to its callees by their SigId, and a SigId never depends on a body, so mutual recursion cannot produce a cycle:

fn even(n: int) -> bool { if n == 0 { return true; } return odd(n - 1); }
fn odd(n: int) -> bool  { if n == 0 { return false; } return even(n - 1); }

BodyId(even) contains SigId(odd), BodyId(odd) contains SigId(even), and neither SigId contains either body. A Merkle graph over cyclic source.

Struct and enum declarations have a single TypeId, since they have no body to separate. A foreign declaration (extern fn) is a signature with no body, so its SigId and BodyId are the same hash — there is nothing else a body hash could be a hash of.


2a. The printer is the other direction

cancho print <file> renders a parsed unit back to text. It is the AST→text half of the same pipeline: a store that addresses code by hash needs a way to show a declaration it fetched, and that rendering has to be canonical or the display would depend on who stored it.

Two contracts, both enforced by a test that walks every .cho file in the repository:

  • Identity-preserving. Parsing the output gives back the same SigId, BodyId and TypeId for every declaration.
  • Idempotent. Printing the output again changes nothing.

The printer is deliberately not a formatter. §3 below says comments never reach the AST, precisely so that formatting cannot change a hash — which means anything built on the AST cannot put them back, and a fmt that silently deleted every comment in a file would be a bad trade. Rendering a stored declaration, where there were no comments to lose, is the job this does.

Writing it found two places where the canonical form is decided by the grammar rather than by the tree, both of which the round-trip test caught rather than review:

  • a struct literal in an if, while or match head has to keep its parentheses, because its braces would otherwise be taken for the block — and parentheses leave no node behind to remember that;
  • - immediately before an integer token is one literal rather than a negation of one (which is how -9223372036854775808 is writable at all), so a negation of a literal has to stay visibly apart from one.

3. What must not change a hash

These are the properties the AST shape was built for, and each has a test:

  • Formatting. Whitespace, line breaks and indentation never reach the AST.
  • Comments. Discarded in the lexer.
  • Redundant parentheses. (a + b) * c and (((a + b)) * c) are one tree; grouping leaves no node behind.
  • Literal spelling. 007, 7 and 1_000 are values, not text. 0x7 will be too, when it exists.
  • Position in the file. Spans live in side tables, never inside a node.
  • Neighbours. A unit hashes alone. Adding, removing or reordering other declarations changes nothing.
  • Local names. See §5 — this one is a decision rather than a consequence.
  • A redundant val. A declaration’s mode is val unless it says otherwise, so writing the word on a type whose members are all val asserts exactly what absence already checks — and a val that was not true is refused before anything hashes it. The two declarations are one type.

3.1 What must change a hash

  • Any operator, literal value, or control-flow structure.
  • Any type in a signature, and the order of parameters.
  • A callee’s signature, transitively.
  • Field and variant order in a declaration, because they are positional to the backend and observable through construction.
  • A declaration’s res (docs/linearity-and-effects.md §3). A linear type is not the same type as a copyable one, and every caller can tell.

4. The encoding

A hash is taken over a byte string, and the byte string is what has to be canonical. The rules:

  1. Every node starts with a one-byte tag. Tags are assigned explicitly and never reused; §8 explains why they are not yet frozen.
  2. Integers are fixed-width little-endian. i64 as eight bytes, counts as four. No variable-length encoding, because two encoders must not be able to disagree about the short form.
  3. Strings are length-prefixed UTF-8 — four bytes of length, then the bytes. Never null-terminated, so a name cannot smuggle a delimiter.
  4. Sequences are length-prefixed, then their elements in order.
  5. Nested hashes go in as their 32 raw bytes.

4.1 Names are text, never interner indices

The AST interns names to dense indices, and those indices are assigned in order of first appearance across the whole file. They are deterministic for one file and meaningless between two. Encoding an index would make a unit’s hash depend on what else the file happened to mention first — exactly the neighbour dependence §1 exists to remove.

So the encoding carries the name’s bytes. This is the one place where the convenient in-memory representation is the wrong thing to hash, and writing this document is how it was noticed.


5. Local names do not matter; the decision and the cost

Decision: bodies are hashed up to alpha-equivalence. Renaming a local binding does not change BodyId.

fn f(n: int) -> int { let doubled = n * 2; return doubled; }
fn f(n: int) -> int { let d = n * 2; return d; }

Both have the same BodyId. A reference to a bound name encodes the binder’s position — how many binders back it was introduced — rather than its text. A reference to something not bound locally, such as a function, encodes its identity instead.

The case for: a rename is a refactor that cannot change what a function computes, and an attestation that says “this function does X” should survive one. The case against: it costs a scope walk during hashing, and it means two textually different files can produce byte-identical hashes, which surprises anyone expecting a hash of the source.

The second is a real cost and is accepted deliberately: this is a hash of a program, not of a file. Anyone wanting the latter should hash the file.

Parameter names are excluded from SigId for the same reason — cancho has no named arguments, so a caller cannot observe them. Generic parameter names are excluded and their count included, since fn f[T](x: T) and fn f[U](x: U) differ in no way a caller can see.


6. Domain separation

Every hash begins with a domain tag, so a signature’s bytes can never be mistaken for a body’s even if they coincide:

cancho.sig.v1      a function signature
cancho.body.v1     a function body
cancho.type.v1     a struct or enum declaration

The v1 is not decoration. When a rule in this document changes, the version changes with it, and old hashes stay readable as what they were rather than becoming silently wrong.

The hash is BLAKE3, 32 bytes, rendered as lowercase hex and abbreviated to 16 characters for display only. Never for comparison.


7. What this buys

Nothing in M3 consumes these hashes yet, which is worth saying plainly. They are built now because:

  • The AST shape can be checked. M0 claimed the AST was built for canonicalisation. Until something hashed it, that was an assertion. It is now a test suite.
  • Attestation needs them (#1), and so does any incremental build, cache or registry. Each of those is easier to add to a compiler that already has stable identities than to retrofit.
  • The self-hosting spike depends on exactly this. #1 proposes porting lex-ast/lex-vcs canonical forms and checking byte-identical OpId/SigId/StageId against a 136k-op corpus. A cancho that cannot hash its own AST cannot run that experiment.

8. Not yet contracts

Per docs/INVARIANTS.md’s convention in lex-lang: these are implemented but not promised, and may change without a version bump until this section is empty.

  • Tag values. The byte assigned to each node kind is stable within a build and not yet frozen across releases. Freezing them means writing them down here as a table, which is worth doing once the AST stops growing — every milestone from M2 on adds nodes.

    Until then the moves are at least visible: crates/cancho-id/tests/golden.rs pins 35 fixtures, one per node family, to the hashes they emit today. It is not a freeze and a failure is not a bug report — it asks which of two things happened, an intended encoding change or something reaching the hash that should not have, and says to record the answer in the commit message. The point is the rate: the question of whether this section can ever be emptied is a question about how often these actually move, and nothing was measuring that. The 61 tests beside the encoder are all relational — they compare two hashes to each other and none of them says what a hash is.

    Measured (#69): hash-stability.md, and the instrument has never been exercised. Twenty commits have touched the encoder and none of them since the fixtures landed, so fourteen green commits are worth less than they look — the measurement was installed after the movement stopped. What has moved is the vocabulary a body is written in: across 67 commits, 71% of this repository’s own .cho revisions no longer type-check under today’s build, and one label rename (io into io_read and io_write) accounts for 42% of that. The rate a hash-keyed tool would experience is the second one.

  • BodyId across a type-checker change. Bodies hash from the AST, not the typed IR, so inference changes do not move them today. Whether that survives M2 — where a body’s meaning depends on effects and linearity that are not syntactically present — is open.

  • Cross-version stability. No claim is made that a hash from this build matches one from any other build. The domain tags exist so that claim can be made later.


Struct literal and pattern field order was the fourth entry here and is closed, but not the way it was written. Both halves of the claim were wrong, so the correction is recorded rather than the text quietly replaced.

It said a struct literal “lists its fields in whatever order it likes and the checker reorders them”, making P { x: 1, y: 2 } and P { y: 2, x: 1 } the same value hashing differently. The checker does not reorder them — it refuses the second spelling:

field `x` is written after `y`, but `P` declares it before; a struct literal's fields run in declaration order, so writing them in another order would hide what runs first

That is defined-behaviour.md §3 doing its job — the order you read is the order it runs — and it means there was never a gap here. There is only one spelling of a given literal, so the hash could not disagree with the value.

It also said the destructuring pattern had “the same gap for the same reason — the declaration’s order is not known where the encoder runs”. That gap was real: let P { y, x } = p compiles and used to hash differently from let P { x, y } = p, though a pattern binds by name and runs nothing. But the stated reason was wrong twice over. The encoder holds the whole Ast and could look a declaration up; and it does not need to, because sorting by name settles it without a lookup and is the better rule anyway — it also survives a struct reordering its own fields, which binds nothing different and should move no body’s hash.

So the pattern’s field names are now sorted where they are encoded. The binders are sorted with them, because they are positional from the destructure onwards: encoding [x, y] while pushing [y, x] would give two programs that bind differently the same bytes, and a collision is worse than the spurious difference being removed. There is a test for that, and one for shadowing.

A tuple destructuring and an enum pattern’s bindings are positional and stay that way: let (a, b) = t and let (b, a) = t are different programs.

Defined behaviour

Status: settled for what exists; the open list at the end is what does not exist yet. Written for M3 (#1), and implemented in the same slice rather than after it — every rule below has a fixture, and the ones that end a process have a conformance test that runs the binary and checks it died.

C and Rust both leave behaviour open in places, and C leaves it open in the places that matter most. This document is the list of those places and what cancho defines them to instead.

This is not pedantry, and it is not a safety feature bolted on the side. It is what makes the rest of the project mean anything: content-addressing, replay and attestation are claims about a program’s behaviour, and a program with undefined behaviour has no behaviour to make claims about. A hash over source that can compile to two different answers is a hash over nothing.


1. The rule

Every operation either produces a defined result or ends the process. Nothing is left to the implementation, the optimiser, or the target.

There is no third outcome. In particular there is no “this is undefined, so the compiler may assume it never happens” — the assumption C makes, which is where its worst miscompilations come from.

Ending the process is a trap: the instruction stream contains an instruction that faults, the operating system kills the process, and the exit status says so. On both supported targets that is SIGILL. A trap is not a panic: there is no unwinding, no handler, no destructor, nothing to catch. The program stops.

Trapping is not a cop-out. A trap is deterministic — the same input traps on every target, every run, every build — which is exactly what undefined behaviour is not.


2. Integers

int is 64-bit, two’s complement, signed. There is one integer type; unsigned types and other widths are not in the language yet (§8).

2.1 Overflow traps

+, -, * and unary - produce the mathematically correct result, or they trap. They never wrap.

let n = 9223372036854775807;
n + 1                      // traps
0 - (0 - 9223372036854775807 - 1)   // traps: -int::MIN has no counterpart

Wrapping silently is defined behaviour — C has it for unsigned types, Rust has it in release builds — so it would satisfy §1. It is refused anyway, on the same grounds division by zero is: a silently wrong answer is worse than a stopped process, because the wrong answer propagates and the stop does not. A checksum that wraps is doing arithmetic; a balance that wraps is a bug that will be found somewhere else, later, by someone else.

The cost is real and is paid on purpose: every addition is a checked addition.

This paragraph used to say “a branch that is never taken is close to free on both targets, and ‘close to free’ is the right price for an answer that is always right.” The first half is true and the second half was a guess. Measured (docs/overflow-cost.md): the branch costs 2.8% where calls dominate and 3.6% where memory does — close to free, as claimed. But an arithmetic-bound reduction pays 40%, and not because of the branch: a trap is observable, so the additions cannot be reassociated, so the loop cannot vectorise. The same guarantee costs clang 46% and gcc 74% on the same loop, which is how we know it is the semantics rather than the backend.

None of that changes the argument above — a silently wrong answer still propagates where a stopped process does not — but the price is a range with a rule attached, not a number: it is whether arithmetic is on the critical path. §2.2 exists for the loops where it is.

2.2 Wrapping is spelled out

Wraparound is the intent in a hash, a checksum, a PRNG or a cycle counter, and a language that cannot express it forces a workaround worse than the thing it forbids. So the three wrapping operations exist as builtins:

wrapping_add(a, b)
wrapping_sub(a, b)
wrapping_mul(a, b)

Each is two’s-complement wraparound, defined for every input, and never traps. The asymmetry is the point: + is what you write when you mean arithmetic, wrapping_add is what you write when you mean the bits. You cannot get the second by accident.

There is deliberately no wrapping division: / has no overflow case that wrapping would help with (see below).

2.3 Division and remainder

/ and % trap on two inputs:

  • a zero divisor — there is no answer, and C’s answer is undefined;
  • int::MIN / -1 — the quotient has no representation, which is the same overflow as §2.1 and traps for the same reason.

int::MIN % -1 was in that second row until emitted-checks.md §4.1 read the binary, and it does not trap: a % -1 is 0, for every dividend including int::MIN, and 0 is representable where the quotient is not. The reason this document gave was the quotient’s, borrowed by an operator that produces no quotient. The compiler was right and the rule was wrong, and §4.2 there records that the constant folder had copied the rule rather than the compiler — so the same expression was a compile error written down and a 0 computed.

Otherwise division truncates toward zero and the remainder takes the sign of the dividend, which is C99’s rule and Rust’s:

 7 /  3 ==  2       7 %  3 ==  1
-7 /  3 == -2      -7 %  3 == -1
 7 / -3 == -2       7 % -3 ==  1

2.4 Literals

An integer literal that does not fit in int is refused by the parser, not truncated. 007, 7 and 0_0_7 are the same value and the same hash: the AST stores the value, never the spelling.


3. Evaluation order

Left to right, everywhere, always. Every rule below is a consequence of that one, and each is enforced rather than merely intended.

  • Binary operators evaluate the left operand, then the right. f() + g() runs f first.

  • && and || short-circuit. The right operand is not evaluated when the left already decides the answer. These are the only operators that skip an operand, and they are control flow rather than instructions.

  • Call arguments evaluate left to right, all of them, before the call. This includes arguments a builtin discards: a capability argument is checked and erased, but an expression that computes one still runs.

  • Enum payloads evaluate left to right, in the order written, which is also the order declared — a variant’s payload is positional.

  • Struct literal fields run in declaration order, and writing them in any other order is refused rather than silently reordered:

    struct P { x: int, y: int }
    P { y: f(), x: g() }     // ✗ refused: `x` is declared before `y`
    

    A struct’s fields are laid out and evaluated in declaration order, so a literal written the other way round would run g() before f() while reading as though it did the opposite. In a language whose whole argument is that effects are visible in the text, having side effects reorder underneath the text is the wrong trade. The order you read is the order it runs, and the compiler keeps it that way.

  • Assignment evaluates the right-hand side first, then the place. For r.f = g() that means g() runs before r is evaluated. Nothing in the language can currently observe the difference — a place is a binding or a field through a reference, neither of which can have a side effect — and it is written down here so that it stays true when places grow.


4. Memory

  • There is no uninitialised memory. let and var both require an initialiser; the grammar has no form that declares a binding without a value. So there is nothing to read before it is written.
  • There are no null references. A reference comes from borrow or from alloc, and both produce one that points at something.
  • There are no dangling references. A reference’s region is a block, and nothing whose type mentions that region leaves it (linearity-and-effects.md §5, §6). The check is an occurs-check over one type, not an analysis.
  • Every index is bounds-checked, and an out-of-range one traps. s[i] compares i against the length the slice carries, and the comparison is unsigned — so a negative index, read as an enormous unsigned one, is caught by the same instruction as an index past the end. There is no unchecked form and no release mode that removes the check: reading past the end of an allocation is the undefined behaviour §1 says this language does not have.
  • byte_of traps outside 0..255 rather than truncating (strings.md §2), and one unsigned comparison covers both ends, so byte_of(-1) dies with byte_of(256). int_of is always defined and always lands in 0..255, which is why it widens unsigned.
  • A slice’s length is checked where it is made. alloc_slice[a](n, v) traps on a negative n, and the byte count n * stride is a checked multiplication like any other (§2.1) — an overflowing length would otherwise ask the arena for less memory than it is about to write.
  • Arena exhaustion traps. An arena is one 64 KiB chunk; asking for more than it holds kills the process rather than writing past the end of the allocation (§6 of linearity-and-effects.md). Growing the chunk would make release a walk instead of one free, and that trade has not been made.
  • Allocation failure traps. If malloc returns null when a region opens, the process dies there rather than storing through it.
  • No aliasing rules to violate. There is no restrict, no noalias and no pointer provenance to lose, because the language has no raw pointers. Two copies of one &!r alias, and that is defined: they are two copies of one address, and writes through them land in the order they run.

5. Layout

Struct fields are laid out in declaration order. A value is scalarised into leaves — an int or a bool is one leaf, a struct is its fields’ leaves in order, an enum is a tag leaf followed by the widest variant’s — and where a value has to live in memory, each leaf takes 8 bytes. A reference is one leaf, a pointer; a slice is two, a pointer and a length, because [T] is the one referent whose size is not in its type. A slice’s elements are contiguous, each one stride apart — the leaf count times 8, except for byte, which is packed one per byte (strings.md §3). That is the only size in the language that is not a multiple of 8, and it is confined to byte so that a string is something C could read.

This is deterministic, and the same compiler on the same source produces the same layout every time. It is not yet a stability contract. Nothing depends on it across processes: no aggregate crosses the FFI boundary (§8.4 refuses one), nothing is serialised, and layout does not reach any hash. When something does depend on it, the rule and its guarantees go in canonical-ast.md beside the other invariants, and this paragraph gets shorter.


6. The foreign boundary

C’s behaviour is C’s. What this language defines is what reaches it:

  • only int, bool and () cross, plus borrowed capabilities, which do not cross at all (linearity-and-effects.md §8.4);
  • int at the boundary is the platform’s 64-bit integer, not C’s int;
  • a value narrowed on the way out — putchar’s character reaches libc as a 32-bit int — is truncated by two’s-complement truncation, which is defined, and what libc does with it afterwards is C’s rule, not ours.

Past that point the guarantee stops, and it stops visibly: reaching C at all requires an Ffi capability, and the effect row of every caller above says so. That is the trade §8.4 makes — C’s effects are not eliminated, they are made impossible to reach silently.


7. What is not defined, and never will be

Nothing. There is no list of “implementation-defined” behaviour and no “unspecified” category. If an operation exists, this document says what it does or says that it traps.


8. What does not exist yet

These are not undefined — they are absent. Each gets a rule here in the slice that adds it, before the code that needs one:

MissingWhat will need deciding
Shifts and bitwise operatorsDone — docs/bitwise.md. Both questions answered as predicted: >> is arithmetic, and a shift amount outside 0..64 traps rather than being masked the way Cranelift’s instruction would. It also answered two this row did not ask: a shift does not trap on the value it produces (§4), and the precedence is Rust’s rather than C’s (§5)
Unsigned integers and other widthsConversion rules, and whether unsigned arithmetic also traps (it should). byte exists but has no arithmetic at all (strings.md §2), which is what lets this stay deferred
Casts and conversionsNarrowing, and whether a lossy one traps or is refused
Floating pointDone — docs/floating-point.md. IEEE-754 binary64 in full, NaN and infinity included, and the optimiser may not reassociate. §2.1 of that document is why the no-silently-wrong-answers rule does not make 0.0 / 0.0 trap: wrapping lies about a value, NaN announces the absence of one. What does trap is truncate, on exactly the inputs C leaves undefined
ConcurrencyEverything. There is none, and a memory model is the price of adding any

9. How this is enforced

Not by assertion. Every rule above is a fixture:

RuleWhere
Overflow trapscrates/cancho/tests/conformance/traps.rs — builds a program and checks the process died by signal
Indexing past a slice trapssame, for xs[5] and xs[-1] on a slice of 3
byte_of traps outside a bytesame, for byte_of(256) and byte_of(-1)
Division by zero trapssame, and it predates this document
Arena exhaustion trapssame
Wrapping does not traptests/accept/wrapping_arithmetic.cho
Struct fields run in declaration ordertests/reject/struct_fields_out_of_order.cho
A slice is a reference, and [T] is not a valuetests/accept/slices.cho, tests/reject/unsized_slice_value.cho
An oversized literal is refusedcrates/cancho-syntax unit tests

A rule with no fixture is a rule this project does not have.

And a rule with no price is a rule nobody has finished arguing for. overflow-cost.md prices the overflow trap and check-cost.md prices the rest: of the eight checks above that can appear in a loop body, six make their operation non-reassociable, the worst being truncate(f) at 3.35×, and the three that are free are free for three unrelated reasons. None of that is an argument against the rules — a silently wrong answer is what §2.1 exists to refuse — but the cost of a guarantee belongs next to the guarantee.

poison.md prices the one alternative anybody has proposed, a flag reduced at a boundary instead of a stop, and finds it a good trade for per-element checks and no help at all for the overflow check carried by a reduction. It would be a change to §2.1 rather than an optimisation underneath it, because what it gives up is where — and a defined-but-wrong fallback used until the boundary is the silently wrong answer §2.1 is about.

Reading through a reference

Status: settled, and built in the same change. The gate for the two limits heap.md §3.0 and §4.1 found and did not fix. They looked like two problems and are one, which is why they get one document.


1. One gap, two symptoms

This language can make references — borrow, &!a T from an arena, contents on a box — and it can follow them in exactly two ways: field access (h.fd) and indexing (s[n]). That is all.

Two things fall out of the hole that leaves.

A scalar behind a reference cannot be read. &r int has existed since M2 and there has never been a way to get the int out of one. heap.md §3.0 records it: contents on a Box[int] hands back a reference with nothing you can do to it.

A recursive structure cannot be read without destroying it. match requires ownership, so taking a list apart to look at it is taking it apart. heap.md §4.1 records it, and examples/tree.cho is built around it: the walk that prints the tree is the walk that frees it, because no other walk is expressible.

The second is the one that matters. A structure you can only read by consuming is a structure you can read once, and “traverse this twice” is not an exotic requirement — it is len then sum.


2. The rule

A reference gives references.

Matching &r E binds every payload as a reference into the enum, carrying the scrutinee’s mode and its region:

match list {                       // list: &l List
    List::Empty => { .. }
    List::Cons(value, rest) => { .. }   // value: &l int, rest: &l Box[List]
}

Nothing moves out of a reference, ever. That single sentence is the whole type rule, and it is what keeps linearity intact: a payload that is res binds as a borrow of that res, so no obligation is created, nothing is consumed, and the scrutinee is exactly as owned after the match as before it.

2.0 The same rule, for field access

Stated here for match, and it holds for the other way to reach into a value. Field access is not an exception, and for two separate stretches it was one.

The rule decides what comes back, not whether anything does:

Through a referenceval memberres member
match payloadborrows — *value reads itborrows
field access h.fcopiesborrows

A val field is copied out, which costs the referent nothing and is what a reference is for. A res field cannot be copied — that is what res means — so what comes back is a reference to it, exactly as match on a reference binds a res payload. h.held on a &r Holder is a &r Box[int], carrying the base’s mode and its region: a field of a &!r is reachable uniquely, a field of a &r is not, and either way the field’s reference lives exactly as long as the one it was reached through.

The remaining difference in that table — a val field copies where a val payload borrows — is the ergonomic one, and it is the reason b.used is not *b.used.

The hole this closed, twice

Reading a res field through a reference was first allowed and unsound. It produced a second owner of a value the referent still owned, and under valgrind that was an Invalid free() from ordinary code with no unsafe anywhere — which this language does not have. It contradicted heap.md §3.1’s “a double free is unexpressible”.

It was then refused outright, which fixed the unsoundness and overshot: a struct with a res field could not be read through a reference at all, while the equivalent enum could. Two standard-library modules paid for that with an accessor that takes the whole value and hands it back — std.buffer’s write and std.vec’s get — and collections.md §5.1 recorded it as the next thing to settle.

A borrow is the answer to both, and the reason is worth stating plainly: the double free was never about reading, it was about owning. So the refusal belongs at the use rather than at the read, and it is already there. Every route to a second owner is refused by the ordinary type rule, because &r Box[int] is not Box[int]:

unbox(heap, holder.held)          // expected `Box[?0]`, found `&r Box[int]`
Holder { held: holder.held }      // expected `Box[int]`, found `&r Box[int]`
return holder.held;               // expected `Box[int]`, found `&r Box[int]`

That is one rule doing the work two were doing, and the surviving one is the rule the language already had.

Reaching a res field on an owner is still refused

Unchanged, and for a different reason: there is no reference involved, so reading the field would move it out of a value that is still whole, leaving a half-consumed aggregate the checker cannot describe. Take it apart with a destructuring let, which names every part at once.

2.1 Why not Rust’s binding modes

Rust infers whether a pattern binding is a move, a copy, a & or a &mut, from the scrutinee and the pattern together. It is genuinely convenient and it is genuinely subtle — the rules have been revised more than once, and “what mode is this binding in” is a question a reader has to reconstruct.

This language does not do implicit. The rule above has no modes to infer and no defaults to remember: match an owned enum and you get owned payloads, match a reference and you get references of the same mode. Which one you are in is visible in the scrutinee, one line up.

2.2 Two unique bindings from one match

match on &!r E binds each payload as &!r T — several unique references at once, into one referent.

That is sound for the same reason two fields of a struct can both be written: the payload positions of a single variant are disjoint. They are different offsets in the same value, no two patterns name the same one, and a variant’s payload cannot overlap itself. Nothing here needs an aliasing analysis; it needs the layout, which the backend already has.


3. *r — the dereference

*r          // r: &q T  ->  T,  when T is `val`
*r = v      // r: &!q T,  when T is `val`

Reading copies the value out. Writing replaces it. Both require T to be val:

  • copying a res out of a reference would duplicate an obligation — two values, one of which nobody is required to consume. §4 exists to make that impossible and this does not get an exception;
  • writing over a res would drop whatever was there without naming a consumer, which is the same silent drop assign_over_live_res.cho already refuses.

A res behind a reference is read the way it always was — by borrowing it further, or by field access — and ended by whatever function ends it, on the owner.

* is prefix, so it never collides with multiplication: a * *b is a product of a and what b points at, and the parser knows which position it is in. It binds tighter than any binary operator and looser than postfix, so *p.x is *(p.x) and (*p).x is written with the parentheses — which p.x already does for you, since field access reaches through a reference on its own.


4. What this unlocks

A read-only traversal of a recursive structure, which is the thing that was impossible:

fn total[&l](list: &l List) -> [] int {
    match list {
        List::Empty => { return 0; }
        List::Cons(value, rest) => { return *value + total(contents(rest)); }
    }
}

value is &l int and *value reads it. rest is &l Box[List], contents follows the box to &l List, and the recursion borrows for exactly as long as this frame does. The list is not touched: it is still owned by the caller, still owes exactly one traversal that ends it, and can be read as many times as anyone likes first.

examples/tree.cho stops having to compute everything in the pass that frees: contains, deepest and tally take &t Tree, hold no capability at all, and leave the tree as owned as they found it. The consuming walk is still there and still the only thing that ends the tree — it simply is no longer the only thing that can look at one.

tests/accept/match_a_reference.cho is the minimal version: a list read three times, then freed once.


5. What this does not add

  • No ref patterns and no & patterns. There is one rule and the scrutinee decides it. A pattern that could override the mode would be the inference §2.1 is refusing.
  • No moving out of a reference, with or without a marker. res ownership lives with the owner; a reference is a promise to give it back.
  • No nested patterns. A pattern is still one variant and a name per payload position, matching M1. Nesting is orthogonal to this and can come later without changing anything here.
  • No match on a reference to a struct. A struct is destructured with let, and reaching its fields through a reference already works.

6. Open

QuestionWhy it waits
Nested patternsOrthogonal; this rule composes with them unchanged
match on a sliceNeeds patterns over lengths, which is a different feature
Moving out of a unique referenceWould need the referent marked as moved-from; a real feature, and not one anything needs yet
Reading a res field as a borrowDone, and the reason given here was wrong. It said this “is exactly the binding-mode question §2.1 declines”. It is not: §2.1 declines inferring among several possible modes, and for a res field there is only one — it cannot be copied, so a borrow is the only thing reading one could mean. Nothing is inferred, and §2.0 hands back &r Field
*r on a res behind a unique referenceA swap, not a read: it would have to put something back. Wants its own operation

7. The must-reject suite

FixtureRule§
deref_a_res.choCopying a res out of a reference duplicates an obligation3
deref_a_non_reference.cho* follows a reference; there has to be one3
write_through_shared_deref.cho*r = v needs a unique reference3
match_reference_binding_escapes.choA binding from a matched reference dies with the region2
match_reference_payload_consumed.choA res payload bound by reference may not be consumed2
res_field_read_through_reference.choA borrowed res field may not be consumed — refused at the use, by the type2.0
res_tuple_component_through_reference.choThe same, where the aggregate is a tuple2.0

And the accepting counterparts:

FixtureShows
borrowed_fields.choA res field and a res tuple component read through a reference, at both modes — and std.buffer printed twice without being spent
match_a_reference.choA list read twice and then freed
deref_roundtrip.cho*r reads, *r = v writes, through the right modes

The heap

Status: settled, and built in the same change. The gate for the last unchecked box on M2’s list (#1): “escape hatches with documented runtime cost”. §9 of linearity-and-effects.md named two — Rc and Gen — and both are libraries over an allocator that did not exist. This document is that allocator.

It is also the answer to the one thing the README has said was missing since M2: a general heap.


1. What is actually missing

Everything in this language so far lives in a frame or an arena. Both are lexical: a value’s lifetime is a block, and §5’s escape check exists to make sure nothing outlives the block it was written in.

That is a real memory discipline and it covers most programs. What it cannot express is a value whose lifetime is decided by the program rather than by the source text — a node added to a structure that outlives the function that made it, a result handed back up, anything recursive.

The concrete symptom, today:

struct Node { value: int, next: Node }
// error: type `Node` contains itself, so it has no finite size
//        (M1 has no references)

Every linked structure in computing is that declaration plus one indirection. This document adds the indirection.


2. Heap is the fifth capability

linearity-and-effects.md §8.1 listed it first and built it last, for the reason §7.3 gives: a capability for an effect nothing can perform is decoration.

res Heap                      // allocation

It carries no value, like Io and unlike Ffi(library) and Fs(prefix) — there is nothing to narrow, because a heap has no parts to name. It discharges one plain label, heap, and a function that allocates says so:

fn make[&x](h: &!x Heap, n: int) -> [heap] Box[int]

It is borrowed uniquely, like Io and unlike Ffi and Fs. Those two are keys: holding one changes nothing, and two holders at once is the same as one. An allocator has state, and the honest type for shared mutable state is the one that says only one reference reaches it.

split therefore hands back four capabilities:

let Split { io, ffi, fs, heap } = split(world);

which breaks every program in the tree for the third time. §8.1 already calls that the honest cost of there being no ambient authority, and it is still the right trade: a program that does not allocate releases its Heap and says so in one line.


3. Box[T] — one value, one allocation

res Box[T]

Three operations, and they are the whole surface:

box(h: &!x Heap, value: T) -> [heap] Box[T]
unbox(h: &!x Heap, b: Box[T]) -> [heap] T
contents(b: &r Box[T]) -> [] &r T        // and &!r Box[T] -> &!r T

box allocates size_of(T) bytes and moves the value in. unbox consumes the box, frees the allocation, and yields the value back. contents is the dereference: it borrows what the box owns, for exactly as long as the box itself is borrowed, and preserves the borrow’s mode.

borrow b as &r in { total = contents(r).x + contents(r).y; }   // read
borrow mut b as &!w in { contents(w).x = 10; }                 // write

3.0 What contents cannot do, and why that is not the heap’s fault

This language has no dereference operator. A reference is read through field access (h.fd) or indexing (s[n]), and there is no syntax for reading a whole scalar through one — &r int has been unreadable since M2, and nothing in this document changes that.

So contents is what you want for a box of a struct, an enum or a slice, and for a box of an int it hands back a reference with nothing to do. Read a boxed scalar with unbox, which is the operation that was going to end the box anyway.

That is a real sharp edge and it is stated rather than papered over. It is also not a heap problem: the missing thing is a general dereference, it was missing before this document, and giving Box a private one would hide a gap the language should close in the open. §7 lists it.

Box[T] is res whatever T is, including Box[int]. T’s mode says whether the contents must be consumed; the box is res because it owns an allocation, and that is true of a box of anything.

3.1 The heap cannot leak

This is the property worth the whole section.

A Box[T] is a linear resource. §4’s rule is that a res value is consumed exactly once on every path — so a box that is never unboxed is a compile error, at the point the program forgot it, with the same message an unreleased capability gets.

(That this holds at all depends on reading-references.md §2.0, which was added later. Until it was, a res field could be read out of a shared reference — including a Box — which produced a second owner and a real double free. The claim in this section was false for as long as that hole was open, and is true with it closed.)

So in this language, the general heap does not leak. Not “should not”: cannot, checked, before the program runs. Nor can it double-free or use-after-free, for the same reason from the other direction — unbox consumes, and using a consumed value is refused.

That is a stronger claim than either escape hatch in §9 will be able to make. Rc leaks cycles by construction, and says so. A Box does not, because there is nothing for a cycle to be made of: a box is owned by exactly one place, always, and the compiler knows which.

The checker guaranteeing unbox runs is a claim about the program, so the claim about the emitted code is checked separately, two ways:

  • under valgrind on linux-x86_64 — one million allocs, one million frees, “in use at exit: 0 bytes in 0 blocks”, no errors;
  • in CI on both targets, by the_heap_actually_frees: eight million 2 KiB boxes one at a time, which is one box’s footprint if freeing works and 16 GB if it does not. A regression that dropped the free does not produce a worse number there, it fails outright.

3.2 What it costs

In the spirit of [budget] and §9’s cost table, stated rather than hidden:

OperationCost
box(h, v)One malloc, plus storing v
unbox(h, b)One free, plus loading v
contents(b)One load. Not a check — there is nothing to check

No header, no refcount, no tag: Box[T] at run time is a pointer and nothing else, which is why contents is a load and why a box costs the same as the malloc it is. malloc returning NULL traps, the way an exhausted arena does (§6) — this language does not have undefined behaviour to continue into.


4. Recursive types, through a box and only through a box

The size check stays exactly as it was, with one hole in it:

A type may contain itself if every path back to itself passes through a Box.

Because Box[T] is a pointer, it is one leaf however large T is, so the size computation terminates. The refusal in §1 is unchanged for a type that contains itself directly — that still has no finite size, and the error still says so.

enum Tree {
    Leaf,
    Node(Box[Tree], int, Box[Tree]),
}

This is the payoff. It is also, in a linear language, more interesting than it looks:

fn drop_tree[&x](h: &!x Heap, t: Tree) -> [heap] int {
    match t {
        Tree::Leaf => { return 0; }
        Tree::Node(left, value, right) => {
            let l = unbox(h, left);
            let r = unbox(h, right);
            return drop_tree(h, l) + drop_tree(h, r) + 1;
        }
    }
}

Every node is freed exactly once, and a version of this function that forgot a subtree would not compile — left and right are res values produced by the match, and §4 requires both. The traversal that frees a tree and the proof that it freed all of it are the same code.

4.1 Reading a recursive structure means consuming it

The second sharp edge, found the same way as §3.0’s — by writing the fixture.

match requires ownership. A borrowed enum cannot be matched (&l List is refused with “cannot be matched”), and that has been true since M1; nothing about the heap changes it. So there is no way to walk a linked list without taking it apart, and a read-only traversal of a recursive structure is not expressible today.

For a box of a struct this does not bite, because field access already reaches through a reference: contents(r).x reads without consuming, and tests/accept/box_roundtrip.cho does exactly that. It bites for enums, which is what every recursive type needs a variant of.

The consequence is worth stating plainly rather than hiding: in this language today, walking a list frees it. That is a real restriction and also a strangely honest one — the traversal and the proof it released everything are one piece of code, which is the property §3.1 is about. What is missing is matching through a reference, which needs binding modes and is a type-system feature of its own. §7 lists it.


5. Heap or arena?

Both exist on purpose and neither replaces the other. The choice is not about performance first; it is about whether a lifetime is lexical.

Arena (§6)Heap
LifetimeA blockDecided by the program
Escapes its scopeNo — that is the escape checkYes — that is the point
AllocationsOne malloc for the regionOne malloc each
ReleaseOne free, whole region, O(1)One free each, by linearity
Forgetting to releaseImpossible — the block endsImpossible — the box is res
Recursive typesNoYes

The arena is still the right default: bulk lifetime, bulk cost, nothing to track. Reach for a box when a value has to outlive the block that made it, or when a type has to contain itself.


6. What this does not add

  • No Box[[T]]. Boxing an unsized referent means a box carrying a length, which is a second shape with its own rules. Slices come from arenas (docs/strings.md §5) and that stays true.
  • No Rc, no Gen. §9’s two hatches are libraries, and this language has no module system to put a library in. They also need the thing this document deliberately does not have: a way to share. Both wait, and §9’s cost table already says what they will cost when they arrive.
  • No realloc, no growable buffer. Vec is a library over a heap and a copy, and it waits for the same reason.
  • No custom allocators. Heap is one capability naming one allocator. An allocator parameter is Rc[h] T’s h, which is §9’s problem.

7. Open

QuestionWhy it waits
Box of a sliceA second shape: pointer plus length, and who owns the length
Sharing at allRc and Gen (§9). Needs modules first
Growable buffersA library over box + copy; needs Box[[T]]
Custom allocatorsWants a heap parameter, not a heap capability
contents on a moved-from boxNot expressible — linearity refuses it before it can be asked
A dereference operator§3.0. &r int has been unreadable since M2; the fix is a language feature of its own, not a Box method
Matching through a reference§4.1. Needs binding modes; until then a recursive structure is read by consuming it

8. The must-reject suite

Stated in advance, as every section of linearity-and-effects.md §11 was.

FixtureRule§
box_without_heap.choAllocating requires a Heap2
box_leaked.choA Box that is never unboxed is refused3.1
box_used_after_unbox.chounbox consumes; using the box after is refused3.1
heap_effect_undeclared.choA row that allocates must declare heap2
recursive_without_box.choA type containing itself directly still has no size4
contents_escapes_its_borrow.cho&r T from a box dies with the borrow3
box_destructured.choA box is ended by unbox, not by a pattern3

And the accepting counterparts:

FixtureShows
box_roundtrip.choBox a value, read it through a borrow, unbox it
linked_list.choA recursive type, built and freed by linearity

Plus the_heap_actually_frees (§3.1), because whether the emitted code frees is not a property any single program’s types can state.

An allocation that fails traps rather than returning a null nobody checked, the way an exhausted arena does (§6). That one has no conformance test of its own and this document will not claim otherwise: a test would have to exhaust the machine’s memory, where the arena’s chunk is a fixed 64 KiB and can be exhausted in a loop. It is the same trapz on the same malloc result, and exhausting_an_arena_traps_rather_than_running_past_the_chunk covers that shape.

Boxed slices

Status: settled, and built in the same change. heap.md §5 deferred this with one sentence — “Boxing an unsized referent means a box carrying a length, which is a second shape with its own rules” — and §7 listed it as open. This is those rules.

It is the foundation every collection needs. A growable buffer is a boxed slice plus a copy, and Vec, a hash map, a slab and a string builder are all a growable buffer plus a policy.


1. What is missing

A run of values can live in two places today and neither is enough.

An arena slice (alloc_slice[a]) is bounded by its block. That is the whole design of §6 and it is right for what it is for, but a collection that outlives the function that built it cannot be one.

A box (Box[T]) escapes its block, which is the point of heap.md, but it holds exactly one value of a sized type. [T] is unsized — its length is a runtime value — so Box[[T]] was refused.

Between them there is no run of values whose lifetime the program decides. So there is no Vec, no buffer that grows, and no collection at all.


2. The second shape

Box[T] at run time is a pointer and nothing else (heap.md §3.2). Box[[T]] cannot be, because nothing else knows how many elements there are:

A boxed slice is a pointer and a length — two leaves, where an ordinary box is one. The box owns the length, because the length is what was allocated.

That is the same pair &r [T] already is, which is what makes §3’s dereference work without a new rule.

2.1 Elements are val, as everywhere else

Same rule as an arena (§6.1) and for the same reason: ending a boxed slice frees memory and runs nothing, so a linear obligation put inside one would be dropped rather than discharged. The error is the one arenas already give, because it is the same rule rather than a new one.


3. Three operations

box_slice(h: &!x Heap, count: int, fill: T) -> [heap] Box[[T]]
unbox_slice(h: &!x Heap, b: Box[[T]])       -> [heap] int
contents(b: &r Box[[T]])                    -> [] &r [T]

box_slice allocates count elements and writes fill into each, the way alloc_slice does — a loop, because the length is a runtime value, which is what makes it a slice. A negative count traps, and so does a count * stride that overflows: asking for less memory than you are about to write is not a small allocation, it is a mistake.

unbox_slice is a different operation from unbox, and has to be. unbox hands back what the box held; [T] is unsized, so there is nothing to hand back. This frees the allocation and answers how many elements it freed. It is the only consumer a boxed slice has, so §3.1 of heap.md holds here unchanged: a boxed slice that is never unboxed is a compile error, and the heap still cannot leak.

contents is the same contents, mode- and region-preserving, and needed no new rule in the checker at all — a boxed slice’s two leaves are a &r [T], so the dereference that reads a box reads this one too. Only the backend had to learn that it is loading two leaves rather than one.

3.1 What it costs

OperationCost
box_slice(h, n, v)One malloc, plus n writes
unbox_slice(h, b)One free
contents(b)Two loads, a pointer and a length
s[i] through itOne bounds check, as every index has

4. Growing is not an operation

There is no grow, push or realloc, and that is deliberate.

Growing a buffer is: allocate a bigger one, copy, end the old one. Every part of that is already expressible, and writing it as a library rather than a builtin means the policy — double each time? add a fixed amount? — belongs to the program that chose it rather than to the language.

examples/buffer/ is that library: buffer.cho holds a growable byte buffer built on these three operations, and main.cho uses it to build a string whose length nothing knew in advance. It is about sixty lines, and it needed nothing this document did not add.


5. What this does not add

  • No realloc. §4. It would also be a lie about cost: realloc can move or extend, and a language whose whole claim is that costs are visible should not hide which one happened.
  • No res elements. §2.1.
  • No slicing a slice. s[a..b] is a second referent into one allocation, which needs a decision about who owns what and is a different feature.
  • No Vec in a standard library. There is no standard library. §4’s example is a library, and it lives with its program.

6. Open

QuestionWhy it waits
Sub-slicingTwo referents, one allocation; an ownership question of its own
reallocHides which of two very different things happened
A standard libraryWants a decision about where a library ships from, not just where it lives
Boxing other unsized thingsThere are no other unsized things yet

7. The must-reject suite

FixtureRule§
boxed_slice_of_res.choEnding a boxed slice runs nothing, so elements are val2.1
boxed_slice_leaked.choA boxed slice is res; the heap still cannot leak3
unbox_a_boxed_slice_wrongly.chounbox hands back a value; a slice has none to hand3

And the accepting counterparts:

FixtureShows
boxed_slice.choAllocate, write through it, read it back, end it
A growable bufferexamples/buffer/, as §4 describes

Plus a conformance test: a negative count traps, and so does a count whose byte size overflows.

Sharing

Status: settled and built, and it corrects the document it implements.

§9 of linearity-and-effects.md is the last unbuilt item on M2’s list. It names two escape hatches, Rc and Gen, and says of both:

“both libraries, not language features — neither touches the checker, and that is the point.”

That is true of Gen and false of Rc. This document is what building them found, and §9 now points here.


1. What §9 was for

Linearity says a value has exactly one owner. That is most of why this language can promise no leaks, no double frees and no use-after-free without a garbage collector or a borrow checker.

It also cannot express a graph, an observer, or anything where “who owns this” has no answer. §9 is the admission that such structures exist and the plan for reaching them: two libraries, paying a documented runtime cost, neither of which the checker has to know about.

The plan was right about the cost and about Gen. It was wrong about whether Rc can be written at all.


2. Rc is not expressible, and that is not a small thing

Rc needs N owners of one allocation. An owner has to be a value, so the question is what value could refer to that allocation. This language has three, and none of them works:

  • Box[T] is res — used exactly once. Two of them referring to one allocation is precisely what linearity forbids.
  • &r T is bounded by its region. An Rc outlives any block, and a reference cannot.
  • &static [byte] outlives everything, and there is no way to make one except a literal.

So there is no copyable pointer, and Rc is a copyable pointer with a count attached.

2.1 Three ways to try, three different refusals

This was not reasoned out in the abstract. Each shape was written and compiled, and the compiler refused each for its own reason:

AttemptRefusal
clone from a borrow — Rc { held: rc.held }“expected Box[Cell], found &r Box[Cell]”
Consume one and hand back two“held has already been consumed; a res value is used exactly once”
Declare Rc itself val so it copies“Rc is declared val, but it holds Box[Cell], which is res”

Three rules, none of which was written with Rc in mind, and each of which is individually right. Together they say the same thing: nothing here copies a pointer.

The first refusal has since got plainer, which is worth recording because it makes the argument stronger rather than weaker. reading-references.md §2.0 used to refuse reading a res field through a reference outright; now it hands back a borrow of that field, and rc.held is a &r Box[Cell]. The Rc still does not compile, because the struct literal wants a Box[Cell] it owns and a borrow is not an owner. So what refuses clone is no longer a rule about reading at all — it is the type — and the three refusals are still three, still unrelated, and one of them is now something no design decision had to choose.

2.2 What it would take, and why not yet

A val type that holds a heap address. That is a raw pointer, and adding one would put back exactly the hole this language spent M2 closing: a copyable address is a use-after-free waiting for the allocation to end.

It could be done safely — a pointer whose only consumer is a checked operation, with the count in the allocation — but that is a language feature with its own document, not a library. §9’s “neither touches the checker” is the part that does not survive contact.

2.3 What you can have instead

Shared ownership is not impossible here; the ambient handle is. An Rc whose operations thread a slab — clone(slab, handle), release(slab, handle) — is expressible today, because then the handle is an index rather than a pointer and the slab is the one owner.

That is Gen with a reference count where its generation goes, which is why §9’s own advice — “Gen is the one to prefer” — turns out to be stronger than it knew.


3. Gen is a library, exactly as promised

Credit, added late (#75). A handle that is a plain index plus a generation, checked against the arena that issued it on every use, is Vale’s generational reference. This section was written without saying so; related-work.md §1 now does.

val struct Gen { index: int, generation: int }

Two plain ints. val, so it copies freely; and it points at nothing, which is why copying it is safe. The Slab owns every value, is res, and is ended exactly once like any other resource.

A handle is checked on every use, against the slab it was made for:

  1. is the index in range?
  2. is that slot live?
  3. does its generation match the handle’s?

Any of those failing gives Missing — a value the program decides what to do about. A dangling pointer is undefined behaviour and the program gets no say. §9 says that asymmetry “is most of why this language exists”, and it is the whole reason Gen is worth its cost.

3.1 What it costs

OperationCost
insertA scan for a free slot. A free list would make it O(1) and is a policy the library could choose
getTwo comparisons for the bounds, one for liveness, one for the generation, and a branch
removeThe same checks, plus one increment — which is what makes every outstanding handle to that slot stale at once
A handleTwo ints, copied like any other val

The slab is one boxed slice (boxed-slices.md), so the values are contiguous and the whole thing is one allocation and one free.

Generations are not recycled and int is 64-bit, so a slot would have to be reused more times than a program can count before a stale handle could collide with a live one. This document will not pretend that is a proof; it is a bound, and it is the same one every generational-index library relies on.


4. What building it found about the language

A library is a better test of a language than a test suite is, and this one surfaced three things that made it more verbose than it should be. None is a soundness problem and all three are ergonomics:

  • No tuples. (Closed — docs/tuples.md.) insert must answer with both a slab and a handle, so it returns a res struct Inserted { slab, handle } declared for the purpose. Every operation that threads the slab needs one.
  • No renaming in a destructuring pattern. (Closed for tuples, by the same change.) let Slab { entries, live } binds those names and no others, so two slabs cannot be taken apart in one scope.
  • No shadowing within a block. (Closed — docs/shadowing.md.) Threading a value through several steps means fresh, live, emptied, stale — four names for one slab — because let slab = ... twice in a block is refused.

Together they make value-threading APIs wordy, which is the exact style a linear language pushes you toward. They were listed in §6 rather than fixed here, because each is a language change and this document is a library.

All three were fixed in the two slices after this one, and that is the useful thing about having written them down: docs/tuples.md and docs/shadowing.md are the first features here whose case was made entirely by a library rather than by a design. examples/slab/ has since lost Inserted, Looked, one whole function and seven of the eight names it had for one slab — without weakening a rule, and without its object file changing.


5. What this does not add

  • No Rc. §2.
  • No raw pointers. §2.2 — and that is the point, not an omission.
  • No generic Slab[T]. The library is a slab of int, because monomorphised generics over a boxed slice work but the example is clearer without the extra parameter. Nothing stops a Slab[T].
  • No free list. §3.1: insert scans. Making it O(1) is a policy the library would choose, the way buffer.cho chose doubling.

6. Open

QuestionWhy it waits
A safe copyable pointer, and a real Rc§2.2. A language feature with its own document
Tuples, or multiple return valuesBuilt — docs/tuples.md
Renaming in patternsBuilt, for tuple patterns, by the same change
Shadowing in a blockBuilt — docs/shadowing.md, and it was a real rule stated too bluntly rather than an omission
A free list in the slabPolicy; the library can have one whenever it wants

7. The suite

FixtureRule§
rc_clone_from_a_borrow.choNothing moves out of a reference2.1
rc_cloned_twice.choA res value is used exactly once2.1
rc_declared_val.choA val type may not hold a res2.1

Three fixtures for one conclusion, which is unusual and deliberate: the claim in §2 is that Rc fails whichever way it is attempted, and one fixture would only show that it fails one way.

AcceptingShows
examples/slab/Gen as a library: a handle used, the slot removed, and the same handle coming back Missing

Does &! mean unique? Measured, and the answer is no — twice over

Status: a documented no, and the README asked a question its own table had already answered.

ROADMAP.md’s first what is next row, README.md’s performance section and gpu.md §4 all carry the same open question: &! does not mean what Rust’s &mut means, both(s, s) compiles, and whether to change that is “a real question with a real cost”. The stated cost was “it refuses programs that compile today”.

That is not the cost. Across 82 programs, the rule refuses one fixture, and that fixture exists to document the behaviour being changed. The real cost is in two places neither row named:

  1. Provenance across a call is a borrow checker. Two of the three ways to alias a &! today close syntactically. The third does not, and closing it means tracking where a returned reference came from — the machinery README.md’s design commitments rule out by name, two sections above the question.
  2. The backend cannot hold the answer. Cranelift 0.121.2 has no noalias. Its whole aliasing vocabulary is three fixed WebAssembly regions, and AbiParam carries no attribute for it. A proved fact would have nowhere to go.

So the performance half of the question is unreachable at both ends, independently, and the correctness half is a research budget rather than a pass. §6 is what would change that.


1. What &! means today

slicing.md §4 already said it, in the opposite direction from the README:

Two copies of one &!r are two copies of one pointer, so writes through them alias correctly rather than racing to be last writer.

&! is a lock on the binding for a block. borrow mut x as &!r in { … } freezes x — nothing may read it, move it, assign to it or borrow it again while the block runs, and linear.rs enforces all four (tests/reject/two_unique_borrows.cho and four siblings). That is a real guarantee and it is the one the language delivers.

What it is not is a promise about references. A &!r T value is val: copyable, storable, returnable. Once one exists, the lock on the binding says nothing about how many copies of the pointer are in flight.


2. The corpus, counted

A temporary probe in crates/cancho-ir/src/lib.rs — at the call-argument loop, and at the lowering of a name to Expr::Load — reported every reference-typed argument’s root place and every read of a &! binding, under an environment variable. The corpus is every program the repository builds: 11 single-file examples, 8 example directories, 53 tests/accept/ fixtures and 10 benchmark programs, 82 programs, each compiled with --std. Counts below are distinct source positions, with the standard library’s own sites counted once rather than once per program.

corpusof which std
call sites passing two or more references12115
…passing two or more unique references340
…where two reference arguments are the same place10
…where that pair involves a &!00
places a &! binding is read1,943154
places a &! is copied into a new binding20
functions returning a &! in a parameter’s region11

The counts are from before §3’s four fixtures were added. Those fixtures alias on purpose, so they would each add a row to the table; leaving them out is what makes it a measurement of the corpus rather than of this document.

The single aliasing call site is before in examples/sort/sort.cho (named by function, not line: this sentence said :148 when the call was already on line 177, and a formatter moves lines):

bytes.compare(text[a_at..a_at + a_len], text[b_at..b_at + b_len])

Two overlapping-by-construction views of one buffer, both shared. Rust accepts it, C accepts it, and no uniqueness rule would refuse it. So: in every line of cancho this repository has, nothing aliases a unique reference except the fixtures written to prove it can.

That is the number the ROADMAP row wanted, and it says the rule is cheap. §3 is why cheap is not the same as available.


3. Three routes, and they are not the same difficulty

Each is a tests/accept/ fixture, accepted today, printing 2 — the second write, read back through the first reference.

Route 1 — the same place twice

both(s, s)      // tests/accept/aliasing_same_place_twice.cho

Closed by a syntactic rule: no two reference arguments of one call may share a root place, where the root is found by walking through field projections, indexes and subslices to the binding underneath. Cost, by §2: zero call sites in the corpus. It is the version everyone reaches for first, and it is worth exactly as much as §3.2 leaves it.

Route 2 — a copy into another binding

let t = s;      // tests/accept/aliasing_through_a_copy.cho
both(s, t)

Two roots, one object. Route 1’s rule does not see it, and neither does any rule that reads one expression at a time.

Closing it means a &! reference stops being val: copying one becomes a move, and every ordinary use becomes an implicit reborrow — the reference handed to a call is a fresh borrow that ends when the call does, which is what Rust does silently at every &mut argument.

The reborrow is not optional. Without it the corpus loses 1,943 reads, 154 of them inside the standard library; examples/tour.cho alone reads a &! binding in 372 distinct places. With it, the cost of the move rule is the 2 places that copy a &! into a new binding, both of them in tests/accept/unique_borrow.cho, whose comment says what it is doing:

&!r is val, so it copies — and the copies are copies of one pointer, so writes through them alias rather than racing to be the last writer.

A slice makes the same copy without a binding: alloc_slice[r](4, s) is four aliases of one object (tests/accept/aliasing_in_a_slice_of_references.cho). A struct cannot: a type declaration takes no region parameters, so a field can only name a region that needs none — &static, read-only data that aliases no arena. That asymmetry is the reason route 2 is bounded at all.

Route 3 — laundered through a return

fn head[&a](s: &!a [int]) -> [] &!a [int] { return s[0..1]; }
both(head(s), s)          // tests/accept/aliasing_through_a_return.cho

This one has no syntactic fix. The caller sees two expressions of the same type and no reason to think they touch the same bytes, because nothing in head’s signature says the result borrows the argument.

Regions do not say it. -> [] &!a [int] says the result lives in the same arena as the parameter — and so does every other reference into that arena, including ones with nothing to do with s. A rule that treated same-region as same-object would refuse two unrelated slices of one arena, which is most of what arenas are for. Region identity is not provenance.

Knowing that head’s result borrows head’s argument, and carrying that through the call, is the fact Rust’s lifetimes encode and its borrow checker propagates.


4. What each closure would cost

4.1 Route 1 alone: free, and worth nothing

Zero programs refused, and &! still does not mean unique, because routes 2 and 3 each reach the same aliasing in one extra line. A rule that refuses the obvious spelling of a thing it permits is worse than no rule: it tells a reader — and AGENTS.md tells an agent — that aliasing unique references is refused, which would then be false.

4.2 Routes 1 and 2: cheap, and still not unique

Two fixture sites, plus implicit reborrow, which is real compiler work: 1,943 read sites have to become reborrows that end at the right point, and “the right point” is a scope question the checker currently never asks about references. Route 3 survives, so &! still does not mean unique and no noalias follows.

4.3 Route 3: the non-goal

Two ways to close it:

  • Ban the shape. Refuse any function returning a &! in a parameter’s region. The corpus has exactly one such function — std.buffer.room, added because fs_read writes into a buffer the caller must hand it as room before it knows the length (porting.md §9) — and exactly one call site, examples/sort. So the ban costs the standard library a function that a port needed and nothing replaces, in order to rule out a pattern that the same port proved is the honest way to write it.
  • Track provenance. The signature says which argument the result borrows from, and the checker propagates it. That is lifetimes, and propagating them is a borrow checker.

README.md’s own design commitments, two sections above the open question:

AreaCommitmentWhy
MemoryLinear/affine types + regions/arenas — not an NLL borrow checkerLocal, cheap, total to check

and its non-goals: “No trait-system maximalism, no GATs, no specialisation, no borrow checker.”

The question was answered before it was asked. What the measurement adds is which fact is the expensive one, so that the answer is a reason rather than a preference.


5. The other end: the backend cannot take the answer

Suppose the checker proved it anyway. The fact has one consumer — the code generator — and cancho generates through Cranelift 0.121.2, which does not have the concept.

  • ir::AbiParam is { value_type, purpose, extension }. purpose is Normal | StructArgument | StructReturn | VMContext. There is no aliasing attribute on a parameter.
  • The only aliasing vocabulary in the IR is MemFlags’ AliasRegion::{Heap, Table, Vmctx} — three fixed disjoint regions for WebAssembly’s memory model, not a per-pointer restrict.
  • noalias does not appear anywhere in cranelift-codegen’s ir.

So the performance claim the README retracted cannot be recovered by fixing the checker alone. It needs the checker and a backend that has the attribute — LLVM, which the same table already names for release builds and gpu.md §1 places outside the next slices.

Two independent blockers is the finding. Either one alone would make this a “not yet”; both make it a different project.


6. What would change the answer

In the order that would make it worth reopening:

what it would takewhat it would then buy
An LLVM backendThe thing gpu.md concluded is the only way to GPU speed, and the same conclusion arrives here from a different directionA consumer for the fact. Until it exists, proving uniqueness is provably worthless to the optimiser
Parallel lanesThreads, or the GPU of gpu.md §4The correctness argument, which today is nil: on one thread, two aliasing writes are defined and ordered. Two lanes writing through aliasing references is the race a checker should catch, and that is when this stops being about speed
Provenance in signaturesLifetimes, in some form the non-goals can live withRoute 3, and with it the word “unique”

Update: threads exist, and the second condition is met. spawn (#128) lets two threads hold copies of one &!; the checker accepted it and the writes raced. Measured in atomics.md §2 (benches/atomics/race.cho: two threads, one counter, updates lost on every run on both backends). The correctness argument is no longer nil. §6.1 closes it for spawn, and only for spawn: routes 2 and 3 are still open everywhere else.

6.1 What spawn does about it

Making &! linear everywhere (route 2’s move rule plus implicit reborrow) is the general fix, and §4.2 says what it costs: 1,943 read sites become reborrows that end at a scope the checker never asks about. A thread needs much less. It needs the one fact route 2 lacked at one call: this reference has been given away and is not back yet.

spawn with a &! payload now lends the reference to the thread, and join ends the loan (linear.rs: Lease, Alias, Bind, Join; lower/conc.rs). Concretely:

  • every &! binding has a root – itself, or, when it was initialised or assigned from names that are &! references (let a = r, let h = head(r)), the roots of those. That is the provenance route 2 and the one-call form of route 3 need, kept as a set of slots rather than as a borrow checker;
  • spawn(p, f) records a lease on p’s roots. Spawning a second payload with a root already leased, or reading any &! with a leased root (a copy, a field write through it, a call passing it) is borrow-conflict until the handle’s join. The spawning thread is a second writer too;
  • the lease ends at join(h) where h is the binding that took the handle, or at join(spawn(..)). A branch must agree on whether it is held, and a loop must leave it as it found it;
  • a handle passed on any other way (to a function that joins it, into a tuple) keeps its lease for the rest of the borrow. That refuses a program that would have been fine rather than admitting one that races. (tests/reject/spawn_unique_loop_lease_survives.cho, spawn_unique_branches_disagree.cho.)

What it does not close, stated so it is not read as more:

  • a &! parameter handed in twice (f(s, s), route 1) – two parameters are two roots here. f can spawn both;
  • a &! fetched out of a slice of references – alloc_slice[r](4, s)’s four aliases of one object (§3, route 2’s slice case), spawn(many[0], f) and spawn(many[1], f). The payload reads many, a slice, not a &!, so no root is found and no lease is taken. A call that is passed the reference (head(r)) is followed, because the &! is read as a name;
  • disjoint writes through two &! slices of one buffer (thread-payloads.md T-P3). Not refused or admitted by this; a slice payload is still one leaf too wide;
  • the object under borrow shared is &, which nothing writes through, and is unaffected.

Cost, measured: across the repository cargo test --workspace and the 82-program corpus of §2, the only programs this refused were benches/atomics/race.cho and benches/atomics/sb.cho, the two that exist to demonstrate the hole. They no longer compile and are kept, with their measurements, as the record. No tests/accept/ fixture, example or package spawned a &! twice.

Edition and hash. It is a soundness tightening, which editions.md’s opening says should stay a refusal and not become an edition: a program it refuses was already racing, so there is no old edition worth preserving. The refusal is raised by the linearity replay from events lowering already walks; the IR it emits is the same, so no content hash changes (identity.rs is unaffected, and the reject fixtures are the only new files).

The first two are conditions, not work items. If both arrive, the third is a milestone with a design document of its own, and this one is its §1.

Until then &! means what slicing.md §4 said it means — a lock on a binding — and the four fixtures in §3 keep that honest: each is accepted today, so the day one of them is refused, it is a red build and a deliberate decision rather than a drift.


7. What this does not say

  • Not that aliasing is safe. It is defined — two writes through one pointer, in program order, last one wins — which is what defined-behaviour.md promises and all it promises. A program that aliases by accident is still wrong; nothing here catches it.
  • Not that the rule is hard. Routes 1 and 2 are a few hundred lines. The measurement says they are cheap and insufficient, which is a worse combination than expensive, because it is the one that invites shipping half.
  • Not that Rust is ahead on the axis the README claimed. It is, and README.md says so. This document is about what it would take to catch up, not about whether the gap is real.

Collections

Status: settled and built, and the answer is about shape rather than about generics.

The README’s “still missing” line read: a writer abstraction, and Option/Result over resources — neither blocked by the language any more, both wanting a library design. Half of that was right. Writing the library found that the linked collection holds resources and the array one cannot, for a reason no API design gets around, and that saying so out loud needed a bound the language did not have.


1. What was already true

mode-polymorphism.md §1 established that a generic container works at a resource type and a copyable one in the same program, and has since M2. That holds for every collection here:

enum Option[T]     { None, Some(T) }
enum Result[T, E]  { Ok(T), Err(E) }
enum List[T]       { Empty, Cons(T, Box[List[T]]) }

Option[Ticket] is a resource the checker will not let a program forget. Option[int] is copyable. Neither declaration writes a mode keyword, because the mode is computed from the argument — and computing it is what makes one declaration serve both.

Result[Ticket, int] is the case worth naming: two parameters at two different modes in one type. The type is a resource either way, because the mode is a fact about the type and not about which variant a particular value happens to be in. A program holding a Result[Ticket, int] that turned out to be Err still has to consume it — and consuming it is a match, which costs nothing.

So three of the four modules here needed no language change at all. They needed someone to write them and check the claim.


2. The array cannot hold a resource

Vec[T] is the one that does not work, and the reason is not the generics. It is that a Vec keeps its elements in a boxed slice, and a boxed slice holds val data only. Two independent reasons, either of which alone would be enough:

The fill is copied into every element. box_slice(h, n, fill) is one allocation holding n copies of fill, and a linear value cannot be copied at all. There is no uninitialised alternative to reach for: defined-behaviour.md has no uninitialised memory in it.

Freeing the run runs nothing. unbox_slice is one free. It does not walk the elements, because there is nothing to walk them with — this language has no destructors, and heap.md says so as a design decision rather than an omission. An obligation sitting in element 3 would be dropped rather than discharged, which is precisely the affine hole §4 of linearity-and-effects.md refuses everywhere else.

The second reason is the one that closes the question. The first looks like an API problem — hand empty a list of elements instead of a fill, and it goes away. The second does not: however a Vec[Ticket] were built, ending it would have to end every ticket, and nothing in the language can be asked to do that.

2.1 And the list can

A list is a chain of boxes, and unbox hands back what the box held. So taking a list apart produces its elements, one at a time, and a program that wants to end them has each one in its hand as it goes. The walk that reads the list is the walk that frees it, which tests/accept/linked_list.cho has said since M2 — what is new is only that the list is now generic, so it says it for List[Ticket] too.

One allocation per element is the price, and it is a real one. What it buys is the only container a program can put a resource in.

Over valOver res
Option[T], Result[T, E]yesyes
List[T]yesyes
Vec[T]yesno, and not for want of a feature

3. So the bound goes on the declaration

std.vec has to say this, and saying it was the one thing the language could not do:

res struct Vec[T: val] { held: Box[[T]], used: int, fill: T }

The vector owns an allocation, so it is res. Its elements are copyable, so T is val. Two modes in one declaration, about two different things — and a val/res keyword cannot express it, because the keyword speaks about the aggregate while this is about a parameter.

Before this, a bound on a type declaration was refused outright, on the grounds that val struct Wrap[T] already means T: val. That reasoning is correct and it is not general:

  • A val aggregate is val at every instantiation, which can only hold if every argument is. So saying val is the bound, and writing it again is a second way to say one thing. Still refused.
  • A res aggregate promises nothing whatever about its parameters.
  • An undeclared one has its mode computed from them, so a bound is a restriction on what may instantiate it rather than a restatement.

In the last two the bound says something new, so it is written. This is mode-polymorphism.md §6’s second open row, answered — and answered the other way from how that row guessed, which had only the val case in view.

There is no [T: res], on a type any more than on a function: an unbounded parameter is already checked as res, so a res bound would change nothing (mode-polymorphism.md §3.2).

3.1 It reaches the hash

A bound narrows what may instantiate the declaration, exactly as narrowing a field’s type would, so two declarations differing only in a bound accept different programs and must be two types. Not through mode_tag, which reads an absent mode as val: that is right for a declaration and wrong here, since unbounded is the stronger check.

3.2 And it is kept at the use site

Vec[Ticket] is refused where the caller wrote Vec[Ticket], naming the bound. That placement is the whole argument for having the bound at all. Without it the program would still be refused — a boxed slice holds val data only — but the refusal would land inside std/vec.cho, pointing at a box_slice call the caller neither wrote nor can change. An error inside a library is the worst possible place for one.


4. A generic function can move a resource, and can never end one

This is the rule the whole library is shaped by, and it falls out of having no way to speak about a T except by moving it.

list.push moves a T into a node. list.pop consumes the list, frees exactly the one node it took apart, and hands the element back:

pub fn pop[T, &h](heap: &!h Heap, list: List[T]) -> [heap] Option[(T, List[T])]

It discharges nothing. After it returns, the caller owes the T. A pop that dropped the element instead would have to be [T: val], and then there would be no way to get a resource out of a list at all.

So list.drop — which ends every element — is [T: val], and over a resource the caller writes the drain itself. That is not a gap in the library so much as the language observing that only the owner of a Ticket knows what ending one means. examples/queue.cho is that loop, eight lines long, and the job it forgets to finish is a compile error rather than a leak.

4.1 The drain is recursive, and that is forced

A while loop that pops until empty leaves a List[T] behind, and the checker cannot see that it is empty — emptiness is a fact about the value, not about the type. So the residual still carries the obligation, and consuming it needs the same drain, which is where it came in.

Recursion has no residual: the base case is Empty, and a match consumes that outright. Every drain in this repository is written that way, and it is worth knowing it is a requirement rather than a taste.

4.2 The bug this turned up

Keeping a declaration’s bound means reading an argument’s mode, and it read it against nothing — so a rigid T came out res however the enclosing function had bounded it:

val struct Wrap[T] { held: T }

fn rewrap[T: val](w: Wrap[T]) -> [] T { ... }
    error: `Wrap` is declared `val`, so its type arguments are `val`
           too, and `T` is `res`

T is bounded val on the line above. A [T: val] function could not name a val aggregate at T at all, which made the bound unusable in exactly the position it exists for. The mode of an argument is now read against the bounds of the declaration the type is written inside.


5. What the module system still owed

Pattern::Variant carried no qualifier. An enum could be named through an import, held, and passed around — and never taken apart, because a match could only name an enum its own module declared.

That is fatal for a library of enums. std.option would have been a type a program could hold and never open. So a pattern takes the same qualifier every other reference does:

match list.pop(heap, queue) {
    option.Option::None => { ... }
    option.Option::Some(pair) => { ... }
}

A module still reaches no hash: what gets encoded is the referenced declaration’s hash, which the qualifier is only used to look up (modules.md §2). The same match written flat and written through an import is one body, and there is a test.

5.1 The asymmetry that was still open — now closed

match on a reference borrows: a reference gives references (reading-references.md §2). Field access on one used to copy, which is why it was refused for a res field — so a struct with a res field could not be read through a reference at all, while the equivalent enum could.

Two library modules paid for that, and both are fixed:

pub fn get[T: val](v: Vec[T], i: int) -> [] (T, Vec[T])    // before
pub fn get[T: val, &v](v: &v Vec[T], i: int) -> [] T       // after

reading-references.md §2.0 now hands back a borrow of a res field, so v.held is a &v Box[[T]] and a getter is a getter. std.buffer’s write lost the same workaround — it takes a &b Buffer and the buffer is not spent by being printed.

The fill this module keeps is not one of those workarounds and stayed: reserve needs a value to fill a bigger run with, and keeping a spare T is free precisely because T is val.


6. The modules

ModuleHolds a resource?What the mode decides
std.optionyesunwrap_or is [T: val] — it drops one of two values
std.resultyes, in either positionunwrap_or is [T: val, E: val]; two bounds, each earned by a different line
std.listyespush/pop/length are unbounded; drop is [T: val]
std.vecnothe whole declaration is [T: val]

is_some, is_ok and length take a reference, which is what lets them work over a resource: reading an Option[Ticket] that way costs the caller nothing, where taking it by value would hand over a ticket the caller still owes.


7. Open

QuestionWhy it waits
Reading a res field through a referenceDone — §5.1. It is a borrow now, and both modules that worked around it are simpler for it
A writer abstractionStill missing, and still unaddressed by this slice. It wants either closures or a dispatch story, and the effect row of a writer that could be a file or the console is the interesting part
map / and_then on OptionNo closures, so there is nothing to pass
A hash mapWants Vec, so it inherits §2 — a map holding resources is the same wall

8. The suite

FixtureRule§
generic_array_over_a_resource.choThe array is refused at the definition, since unbounded is checked as res2
val_declaration_rebounds_its_parameter.choA val aggregate already bounds its parameters3
res_bound_on_a_type.choThere is no [T: res] on a type either3
type_bound_violated.choThe bound is kept where the type argument is supplied3.2
AcceptingShows
tests/accept/collections.choOption, Result and List over a resource; Vec at a copyable element; and §4.2’s [T: val] function naming a val aggregate
examples/queue.choJobs that own memory, held in a list, ended exactly once each — and the tally beside them in a Vec[int]
TestClaim
a_bound_on_a_type_parameter_reaches_its_hash3.1, and that the bound is per parameter
a_res_aggregate_may_bound_its_parameters3, both halves
a_bounded_parameter_may_stand_where_a_val_aggregate_wants_one4.2, the bug
a_match_names_an_enum_through_its_qualifier5, and that the qualifier is checked rather than decorative
qualifying_a_pattern_reaches_no_hash5, and modules.md §2 still holding

Testing a program written here, not the compiler that builds it

Status: built. §2’s primitive (trap(), and std.test’s assert/assert_eq/assert_ne on it) is on both backends, and §3’s runner is cancho test. A program — an agent’s or a person’s — had no way to state “this must be true” at all before this, not even that. What is not built is listed in §5: no per-test timeout, no message on a failed assertion, nothing generic over T.

1. What was missing, and how it was found

An agent-usability survey this session — not a hunt for a specific bug, a direct question about what the toolchain still lacked for real use — found that cargo test (crates/cancho/tests/conformance/) is purely internal to developing the compiler itself, and that nothing comparable exists for a program written in cancho. The real path to testing one’s own function, before this document, was: write another .cho file, compare values with if/==, and return a distinct int as the process exit status. No assertion, no failure message, no way to tell “this test failed” from “this program computed 1” apart from reading the number back and remembering what it meant.

That gap outranked several stdlib holes found the same session (std.vec missing pop/remove/insert, no regex, no readdir) because it is not a missing convenience — it is the thing that would let an agent (or anyone) trust code it just wrote, here, rather than eyeballing it.

2. trap(), and assert built on it

This repository’s own answer to “an assumption failed” has been a trap since M0: no message, no exception, SIGILL on both targets, exactly like an overflowing + or an out-of-range index (docs/defined-behaviour.md §1). Every existing trap is the compiler’s own arithmetic or bounds check firing on its own. Nothing let a program reach that outcome deliberately — there was no primitive to build assert out of.

trap() is that primitive: no arguments, [] int like byte_of’s own fixed signature (the int is never actually produced, since the call never returns, but the type checker needs a result type to check the call site the ordinary way), no capability, because deciding to trap is not an effect on the world. Both backends already carried the one instruction this needs — Cranelift’s trapnz/trap, the LLVM backend’s own trap_if (ud2 on x86-64, udf #0xc11f on aarch64, the same instruction docs/llvm-backend.md §3.2/§3.3 already measured for every checked operation) — so this is the first builtin that reaches it unconditionally rather than behind a check the compiler emits on its own, and adding it was mechanical on both: one trap(TrapCode::unwrap_user(1)) plus a fresh dead block in cancho-codegen, one trap_if("true") in cancho-codegen-llvm.

std.test.assert(condition: bool) -> [] int is a library function built on it, not a second builtin:

pub fn assert(condition: bool) -> [] int {
    if condition {
        return 0;
    }
    return trap();
}

A library function rather than a builtin for the same reason std.buffer’s doubling policy is a library and not a builtin (docs/boxed-slices.md §4): deciding when to trap is the caller’s policy — trap() is the primitive, assert is one way to use it, and nothing stops a program from writing its own. assert_eq/assert_ne are assert(a == b)/assert(a != b) spelled out, over int, the same way docs/collections.md’s own unwrap_or spells out a two-line match: not a new primitive, a name for the line every caller would otherwise write for itself.

tests/accept/assert.cho is the pass side, checked against its own //~ STDOUT///~ EXIT the ordinary way. The fail side cannot be: a trap is not a value a program’s return can produce, so traps.rs::assert_fails_the_same_way_every_other_trap_does checks it the way every other trap fixture here is checked — built, run, and confirmed killed by a signal (run.status.code() == None), not exited.

3. The runner: cancho test

This language has no macros and no reflection (README.md’s own “what’s deliberately not here”), so nothing can enumerate a program’s own declarations from inside it. cancho test some_file.cho does it from outside: it parses each named file, takes every fn test_*, writes a main that runs whichever one argv[1] names, builds once, and runs the executable once per test, because a trap kills the process it happens in and a runner has to survive one test’s failure to report the next (crates/cancho/src/test_cli.rs).

$ cancho test --std ok.cho
running 4 tests
test test_add ... ok
test test_returns_nonzero ... FAILED
test test_traps ... FAILED
test test_with_caps ... ok

failures:

---- test_returns_nonzero ----
returned 7, and a test answers 0 to pass

---- test_traps ----
trapped: killed by signal 4 (SIGILL, the trap every checked operation ends with)

test result: FAILED. 2 passed; 2 failed

The shape of a test. fn test_x[regions](caps) -> [row] int: no type parameters, returns int, and every parameter is a unique reference to Heap or to Io, at most one of each. It answers 0 to pass; anything else, or a signal, is a failure. A fn test_* of any other shape is refused with exit 2 rather than skipped, because a test that silently did not run reads as a pass. A test in a module other than the root must be pub, since the runner reaches it from outside.

Capabilities (the row this closes in §5). A test that needs a heap or the console declares it, exactly as main would: the synthesized main splits World, releases ffi/fs/args, borrows heap and io uniquely, passes each test the ones it names, and releases them after. The row is exact, as everywhere here ([heap] on a test that never touches the heap is a refusal), so a test that asks for Io has to use it.

No main in a test file. The runner supplies its own, so a file that declares a root-module main is refused with exit 2.

Exit codes. 0 every test passed; 4 the program built and at least one test failed (a new code, test only, so a caller can tell a red test from 1, “the program was refused”, a broken build); 2 no test_* functions were found — a run that found none is not a pass — or one has the wrong shape; 3 the environment failed. A status that is a multiple of 256 would read back as 0 as a process exit, so the synthesized main answers 1 for it instead.

4. What this does not propose

Not a message on a failed assertion. Every trap in this language is message-less by design (§2’s whole argument), and assert inherits that rather than becoming the one trap that carries text — a caller that wants to know which assertion failed reaches for check --output json’s located diagnostics at compile time, or reads the source at the line a debugger stops on at run time, the same way any other trap here is diagnosed.

Not assert_eq/assert_ne over anything but int. A generic version wants == over an arbitrary T, which this language does not have for every type (docs/collections.md never built one either) — open, not answered here, the same way docs/collections.md §7 leaves map/and_then open for want of closures.

5. Open

QuestionWhy it waits
A per-test timeoutA test that loops forever hangs the run. One process per test makes a kill easy to add, but nothing has asked for it yet
A message on a failed assertionWould make assert the one trap with text, against every other trap’s own design (§4)
assert_eq/assert_ne over a generic TWants == over an arbitrary type, which nothing here has built yet
Running tests in parallelOne process per test makes it possible; output would need buffering per test, which the runner already does
Filtering (cancho test f.cho -- name)Not needed until a file has enough tests to want one

satisfy: a contract a candidate must meet

Status: designed, then the smallest slice built (#406). What exists and is composed, unchanged: the checker (cancho check, every refusal as data), the test runner (cancho test, docs/testing.md §3), and cancho-id’s per-declaration identity. What is new here is the composition being a command with a verdict, and one addition to it: the contract pins the SigId the caller depends on, so satisfaction is against a specific identity, not a name.


1. What a contract is

A contract is a .cho file — the only language the checker already reads, so a contract is checkable the day it is written and needs no new format, parser or schema:

// contract: max
// sig: 8f13...   (optional: the pinned SigId, `cancho ids` of the declaration)

fn test_max_picks_the_larger() -> [] int {
    if max(3, 7) != 7 { return 1; }
    ...
}

Three parts, two of them free:

  1. The signature — implied by the contract’s own tests: they call the candidate by name, so a candidate with the wrong arity or parameter types is refused by the ordinary checker, with the rule tag and position check --output json already answers (unknown-name, arity mismatches, type mismatches). A candidate that does not check against the contract’s calls does not get as far as running.
  2. The examples — test_* functions with concrete inputs, the shape cancho test already runs. Satisfaction includes them passing.
  3. The properties — the same test_* functions, written as witnesses over ranges rather than points (max(i, 100-i) against max(100-i, i) for a hundred values). A property that is not decidable at compile time is witnessed, which is what a test can honestly say; a property that is decidable (min(a,a) == a) is witnessed too, and the difference is the writer’s to state, not the tool’s to guess.

2. What satisfy does

cancho satisfy <contract.cho> <candidate.cho> [--std]:

  1. Checks the two files as one program — the contract’s tests against the candidate’s declarations. A refusal here is the ordinary refusal vocabulary, located, as data with --output json.
  2. Runs the contract’s tests against the candidate, exactly as cancho test does, one process per test.
  3. Answers the verdict as data: satisfied (with the recomputed SigId of each contract-named declaration, so the caller can see what it now depends on), or the refusals / failures that stand between the candidate and the contract.

Exit codes: 0 satisfied, 1 refused (checker), 4 failed (tests) — the same codes check and test already answer, because satisfy is those two commands composed, with the verdict named.

3. What the slice deliberately does not do

  • No generation. The candidate is written by an agent or a person; the compiler is the gate, never the author. agent-errors.md §2’s finding holds here too: suggested_transform needs op ids, and this slice does not add them.
  • No new trust machinery. The verdict is check plus test, composed. An attestation of satisfaction is the vcs gate’s job (docs/vcs.md), reached through vcs publish of the candidate once it satisfies — nothing here duplicates it.
  • No property language. A property is a test function. When the language can express a decidable property as data (a term, not a program), that is a new slice with its own design; today’s honest answer is the witness.

A contract that never calls the candidate is refused (exit 2, like an empty one): the check is syntactic, a call name( to something the candidate declares in the contract’s own source, comments and strings set aside. It is a floor, not a proof of strength: a contract that calls the candidate once and asserts nothing about the result is still satisfied, and so is one that calls it only on a branch no test takes. Strength is the contract author’s, which is the same limit a test suite has.

4. The committed artifact

tests/satisfy/ holds the first real contract and its candidate — std.math’s max, contract with point examples and a commutativity witness, candidate the ordinary if — and CI runs cancho satisfy tests/satisfy/max.contract.cho tests/satisfy/max.candidate.cho --std so the loop’s first end-to-end run is a checked property of the repository, per the issue’s own verification ask.

cancho fmt: one layout, with the comments put back

Status: built. cancho fmt <file|dir>... [--check] (crates/cancho-syntax/src/format.rs, crates/cancho/src/fmt_cli.rs). Applied to every tracked program, and held there by a test (§5).

1. What was missing

cancho print renders one parsed file in canonical form, and says in its own header that it is not a formatter, because it cannot be: comments are discarded by the lexer, so that formatting can never change a content hash (docs/canonical-ast.md §3), and a printer built on the AST can only delete them. So an agent writing cancho had a canonical layout it could not use on a real file, and nothing to hold a codebase to one layout either — cargo fmt --check exists for the compiler’s own Rust, and there was no counterpart for the programs written in this language.

2. How it works

The AST has no comments, no blank lines and no literal spellings, so a formatter cannot work from the AST alone. This one does not try to extend it (that would let layout reach a hash); it uses the text as well:

  1. print the parsed file. That is the layout.

  2. Tokenise the source and the printed text and align the two token streams. They differ in a short, known list of ways, and the aligner knows each one and nothing else:

    • the printer drops redundant parentheses ((a) + 1 → a + 1);
    • it may add or drop a trailing comma;
    • it spells a literal one way. 'a', 0x10, 1_000 and 1.50 print as 97, 16, 1000 and 1.5, so the source’s spelling is put back — a formatter that rewrote 'a' to 97 would be deleting the one readable thing in the line;
    • else { if .. } prints as else if .. (§3);
    • pub before extern is read and discarded by the parser (ExternDecl has no visibility to keep), so it does not print;
    • every import is written before the first declaration.

    Any other mismatch is not a layout difference, and the formatter stops: the file is reported and left alone.

  3. Put each comment where it was, by the token next to it: at the end of the output line that token ended up on, if it was written at the end of a line there; otherwise on its own line above the line of the next token. A comment in the middle of an expression has no stand-alone place, and moves above its statement. Keep one blank line where the source had one or more — never right after a { or right before a }.

  4. Check the result (§4), and return it only if every check passes.

3. What it decides

  • else if. print used to write an else block holding one if as else { if .. }, one level deeper per link: the parser reads else if as exactly that block, so the two are one AST and one hash, and the ladder was the shape nobody writes. print now writes the chain, so fmt and print agree. This changes print’s output for any program with an else if; no identity moves.
  • One statement per line. if c { x = 1; } on one line becomes three. The printer has one layout and this is it.
  • Blank lines. One is kept where the source had one or more; one separates items regardless, which print already did.
  • Trailing whitespace and CRLF go; \n endings are written.
  • A comment’s text is never edited, apart from trailing whitespace.

4. Why it can be trusted

A formatter that is sometimes wrong is worse than none, so format returns text only if the text passes three checks, run on every call:

  1. It parses, and print of it equals print of the original: no token changed meaning.
  2. It holds the same comments, in the same order.
  3. It is a fixed point: formatting it again changes nothing.

A file that fails one is not half-formatted; fmt reports it (path: not formatted: <why>, exit 1) and leaves it as it was. What each check is for was found, not assumed: building the aligner against the repository’s own sources turned up four things the first version could not reproduce — else if (§3, which was a printer problem, not an aligner one), pub extern fn, a dropped } that looked exactly like a printed one (the drops are decided before the matches), and std/ed25519.cho’s import in the middle of the file, which is now at the top.

Measured on the repository: of the 138 programs under std/, examples/, packages/ and tests/accept/, all 138 format, 74 would change, and formatting all 74 leaves cancho ids unchanged for every one of the 80 tests/accept fixtures (a SigId and a BodyId per function — cancho ids before and after, byte for byte), and a second fmt --check over the result finds nothing to do. formatting::every_tracked_program_formats is that sweep as a test, and formatting_never_panics_on_a_damaged_program deletes each line of three real files in turn and formats what is left.

5. What this does not do

  • Applied, and kept applied. 93 files under std/, examples/, packages/, tests/accept/ and benches/ changed (1,281 lines added, 817 removed, nearly all of it one-line if c { x; } made three), and formatting::the_repository_is_formatted fails on any of them that stops being canonical. tests/reject/ is left alone: most of it does not parse, on purpose, and its line numbers are what the fixtures assert. The full suite passes unchanged, which is also the check that no identity moved: the conformance tests hash these files.
  • No line wrapping. The printer writes one statement per line however long. Wrapping a long call is a layout decision with its own documentation to write, not a side effect of this.
  • A comment inside an expression moves (§2.3). It is kept and ordered, not left where it was.
  • Imports are not sorted, as print does not (print.rs’s module_header: nothing hashes an import).
  • --check and a refusal share exit 1. The first means “would change”, the second “cannot be formatted”; the message says which. A caller that needs them apart reads the prose.

6. Open

QuestionWhy it waits
Wrapping long linesNo measured case yet where one matters
A distinct exit code for --checkNothing has asked to tell “would change” from “cannot format”

std.json: parsing and writing JSON with nothing the caller did not hand it

Status: built. std/json.cho; tests tests/lex/json_*_test.cho (29, run by cancho test), conformance/json.rs; drivers under tests/programs/. Strict RFC 8259; no tree, no allocation of its own. §5 has the numbers, and §6 has the two compiler bugs it found.

1. Why

The question that started this was whether a FastAPI-shaped package is buildable here, and the first thing it needs is the thing every request carries. There was no JSON in the language at all: the VCS op log is JSON on the Rust side, --output json is JSON out of the CLI, and examples/serve writes {"ok":true} as a string literal.

2. The design: a tape, not a tree

A value tree is a heap, a free list and a drop order for every document, and a request handler wants none of them. std.json parses into a tape the caller provides: one flat [int], three ints a node, that points back into the source bytes instead of copying them.

let tape = alloc_slice[a](json.tape_len(body), 0);     // or a heap slice
let nodes = json.parse(body, tape);
let age = json.to_int(body, tape, json.get(body, tape, 0, "age"));
  • A node is named by its index; -1 is “no node”, and every accessor answers its zero value for it, so get(get(get(..))) needs no check between steps.
  • A string is not decoded until asked for: the node holds the byte range inside the quotes and a flag saying whether it has an escape. A string with none (string_plain) is its bytes in the source, borrowed with no copy (string_view). A number is not converted until asked for: an object is walked for the key and the one value, and the other forty are never turned into anything.
  • An object’s children are its pairs in order; a key at node k has its value at k + 1 and the next key at skip(k + 1). at and get are linear in the position, which is what a flat list costs and what a request body of a few dozen fields makes irrelevant.

This is simdjson’s shape, and it is the shape here for a different reason: it is the one that needs no ownership. Everything lives in a slice the caller already knows how to size (tape_len is always enough: a node is at least a byte) and free.

Writing goes the other way, through a Writer that owns a std.buffer, puts the commas, colons and quotes in itself, and is moved through every call like a Buffer is, because growing it replaces the allocation under it.

3. What it decides

StrictRFC 8259 and nothing else: no comments, trailing commas, leading zeros, +1, .5, 1., NaN, 'single'; no unescaped control character in a string; no lone surrogate (\ud800)
UTF-8 is validatedEvery byte ≥ 0x80 in a string is checked with std.utf8 (overlongs, surrogates, > U+10FFFF refused). A parser that accepts what another refuses is where request-smuggling bugs live
A parse error is a valueparse answers the node count, or 0 - (position * 16 + code); error_code, error_position, error_message take it apart. Nine codes
parse_with is parse with the scratch state in the caller’s handsparse_with(src, tape, st) answers exactly what parse(src, tape) answers and writes the same tape, for every input (§3.1), but takes the two ints of state as a slice instead of making a region to hold them
Nesting is limited to 128Not recursed into until the stack runs out: the stack is the one resource a parser cannot be handed
A full tape is an errorCode 8, not an overrun
Integers saturateto_int clamps at the ends of int (a 20-digit number is not an overflow trap in a server); fits_int says whether it was exact. A float node truncates toward zero, saturating
Floats are correctly roundedto_float is the nearest float to the decimal text, a tie to the even one (§4). A number past the range is ±infinity and one below it ±0.0: JSON has no such values but the number it spells does
Duplicate keys: the first winsThe document’s order, not an arbitrary one
Keys compare as decoded text"café" is the key "café"
The writer cannot write bad JSONA bare value in an object, a key outside one, two keys in a row, a close that does not match, nesting past 60, finishing with something open: a trap. They are the caller’s bugs, and the alternative is a service that sends {"a":}
Invalid UTF-8 going out becomes U+FFFDThe output is always a valid document, whatever was handed in
A float that is not finite is nullWhat most encoders do, and what keeps the document valid
Floats keep their kind3.0 is written 3.0, so it reads back as a float; positional from 1e-6 to 1e21, scientific outside, shortest digits that round-trip

3.1 parse_with: no region per call

parse begins region a { let st = alloc_slice[a](2, 0); ... }: two ints of state in an arena made and freed on every call. A caller that parses one large document never notices. A caller that parses a million small ones (JSON lines, one request body after another) pays for the arena a million times, and on macOS that is nearly all of what a call costs. cancho-table’s reader design (docs/readers.md §6) measured it and asked for this.

let st = alloc_slice[a](2, 0);                 // or a heap slice; once
let tape = alloc_slice[a](12288, 0);           // once
let nodes = json.parse_with(line, tape, st);   // per document, no region

st is at least two ints. Their contents on entry do not matter (parse_with sets them), so one st serves any number of documents and nothing of one document is visible to the next. A shorter st is the caller’s bug and traps at the first store, as any index out of range does. parse is unchanged, byte for byte, so no identity that mentions it moved (§3.3). The name is parse_with (open to a better one; readers.md §6 used it for its spike, and it sorts beside parse).

Measured (benches/json_lines.py: 1,000,000 JSON lines of 72 bytes, {"id":..,"status":.., "bytes":..,"path":..,"note":..}, the task per line parse, get of status and bytes, two to_int, a count and a sum; cost per line = (best of 5 runs of 11 rounds − best of 5 runs of 1 round) / 10 rounds / lines, so reading the file drops out; every mode’s answer is compared with the rows’ before it is timed). parse is one tape reused for every line; region is a tape allocated in a region per line, which is what a first version writes.

ns per line, wall clockparseregionparse_withparse / parse_with
macOS arm64, 1 thread (re-measured after the rebase onto edition 9; load 10 rising to 20; best of 5)5671,035946.1x
macOS, 16 threads (same run)1,6233,2199.7167x
macOS, 2 / 4 / 8 threads, parse (from the earlier run, load 15 to 22)1,241 / 1,403 / 1,492
macOS, 2 / 4 / 8 threads, parse_with (from the earlier run)55 / 30 / 13.8
macOS before this change (origin/main’s compiler, same program without parse_with; same session, load 20)571 (1 thread), 1,568 (16)1,139 (1), 3,222 (16)not there
Linux x86-64 (gram, cores 0 to 5, load 5; re-measured; best of 5), 1 thread2372802331.02x
Linux, 6 threads (same run)15619380.51.94x
Linux, 2 / 4 threads (from the earlier run: parse 180 / 165, region 315 / 192, parse_with 164 / 142)

Per line is wall time over all lines, so a perfectly parallel mode divides by the thread count. parse on the Mac does the opposite: 1.6 µs per line at 16 threads against 0.57 µs at 1, 2.6x slower with 16 threads than with one (2.9x in the re-measured run): a region is a malloc and a free, and this allocator does not scale with threads here (not diagnosed further); parse_with goes from 94 to 9.7 ns, 9.6x. On Linux the arena is cheap (about 30 ns), so the gain at one thread is small (1.02x in the re-measured run, 1.18x in the earlier one), which is what readers.md predicted (283 against 255). The number worth having is the Mac’s: it is where a user’s laptop is, and where “a library function that gets slower when you add threads” costs a day to find.

3.2 The differential

parse_with is the same grammar as parse and a second copy of its body (a call from parse would have been shorter and would have moved parse’s identity, §3.3), so the claim that they agree is tested rather than argued. tests/programs/json_with_diff.cho runs both on every document, into a tape of the right size and into one too short (where the full code and the partial tape come from), with the state slice still holding the previous document’s garbage, and compares the answers and every int of both tapes. conformance/json.rs feeds it 58,733 documents (11,444 accepted, 47,289 refused): the seeds, every prefix of every seed, every short seed with each of 39 pool bytes in each position, 40,000 randomly damaged seeds and the edge documents of the JSON test suites written out (depth 127, 128, 129, 1000 and 100,000, lone surrogates, overlong and truncated UTF-8, a BOM, 01, 1., NaN, comments, trailing bytes). Zero differences. A second test classifies the edge documents with serde_json so that “they agree” cannot mean “both wrong the same way”. scripts/json_with_suite.py runs nst/JSONTestSuite (318 files; not vendored): 95 y_ all accepted, 188 n_ all refused, 10 i_ accepted and 25 refused, parse and parse_with agreeing on all 318, answers and tapes. Breaking parse_with three ways (not resetting the node count, not checking trailing bytes, an off-by-one error position) fails the differential each time (differ 3, differ 37, differ 0 is the document index that caught it).

3.3 Identity

Adding parse_with adds a body and a sig to std.json and changes none: cancho ids --std over a program that imports every std module lists 1,805 identities on origin/main (6a8bc1ed; 1,677 when this was first measured, before more std modules arrived) and the same 1,805 plus those two on this branch (comm finds nothing only on origin/main). Over the programs in tests/ and examples/ that cancho ids can read, 95 list identical identities with and without this change; with --std all 211 differ by exactly those two lines and nothing else. (The earlier run reported 165.)

4. Why it can be trusted

  • Numbers: 13,000 of them, bit for bit. conformance/json.rs reads a corpus with Rust’s str::parse::<f64> (correctly rounded) as the reference: random doubles over every exponent in their shortest form, 17-digit decimals, the exact decimal midpoint between two adjacent floats and one digit either side of it (the case a floating-point shortcut gets wrong, built from m * 5^s / 10^s in 128-bit integers), integers on both sides of 2^53 and of int’s ends, subnormals, the overflow edge, and exponents of a million digits. Zero mismatches.

    Corrected: that corpus built its halfway cells only among the normal floats (m of 54 bits, shifts under 26), so “and subnormals” meant random subnormals in their shortest form – which no tie can be – and the claim above said more than it tested. A downstream table reader found that to_float read the exact halfway point between two of the larger subnormals (between about 1e-308 and 2^-1022: the 768 significant digits of (2k+1) * 2^-1075) to the wrong neighbour about half the time: the digit cap was 767, so the final 5 was dropped as a “sticky” digit, the tie became a value above the middle, and it went up whatever the parity. Measured: 33,541 of the 1,000,000 cells of float_text.rs (3.4%; 440 of 1,700 in the first reproduction, and 113 of 400 exact midpoints of 52-bit subnormals) read wrong, none in any other shape. The cap is 768 now. conformance/float_text.rs reads 1,000,000 cells against Rust’s parse – the halfway point, a hair above and a hair below it for every subnormal below 64 units, for the largest subnormals and smallest normals, for 30,000 anywhere, and up to DBL_MAX plus half an ulp (a tie that rounds to infinity) – and scripts/float_differential.py runs the same driver against Python’s float() and against exact rational arithmetic (fractions), 1,000,000 cells, 0 differences. The same run checks to_int against an i128 parse clamped to i64.

  • Accept and refuse what serde_json does, over damaged input. Ten valid documents and 1,500 mutations of them (one to two bytes replaced, deleted, inserted, swapped or truncated from a pool of structural and non-ASCII bytes): 291 accepted by both, 1,218 refused by both, no disagreement. Every accepted one is written back out by std.json’s own writer and read by serde_json to the same value. One policy difference, found by it and declared in §3: 1.5e22100 is a number serde_json refuses as out of range and this accepts as an infinity.

  • 29 library tests, in the language, on both backends (tests/lex/): every error code with its position, UTF-8 corner cases built byte by byte in a buffer, escape decoding including a surrogate pair, saturation at both ends of int, writer output exact to the byte, and 1,500 floats written and read back to the same bits.

  • Misuse traps are checked by building eight misusing programs and confirming each dies by signal.

4.1 The part that was hard: reading a float

A decimal with 15 or fewer digits and a power of ten within 10^±22 is two exact floats and one correctly rounded operation (Clinger’s fast path). Everything else – and a random double printed in 17 digits is the common case – needs exact arithmetic, because m * 10^e in floating point is off by an ulp often enough to break a round trip. slow_decimal does it with std.bignum: the digits (up to 768, the most a halfway point can have, plus a sticky digit for the rest) and the power of ten become bignums, scaled so the quotient has 56–57 bits, divided bit by bit, and rounded on those bits with the remainder as the sticky bit. Limbs are sized to the number (a 17-digit one needs a dozen where a worst-case one needs hundreds), which is what took it from 18 µs to under 1 µs.

5. Measured

tests/programs/json_bench.cho and json_write_bench.cho; timing from outside as the difference between one round and twenty-one, so the byte-at-a-time stdin read is left out. A 4-core sandbox; Python 3.11’s C json, which builds every Python object where the tape builds none – the comparison is real for a handler that touches a few fields and unfair to the tape for one that needs every value, so both columns are given.

Reading

documentsizetape onlytape + every float convertedPython json.loads
30k API objects (strings, ints, bools, 2-decimal floats, nested, 17-digit lat/lon)7.2 MB20 ms, 355 MB/s93 ms, 77 MB/s119 ms, 60 MB/s
300k floats, 17 digits5.9 MB13 ms, 461 MB/s277 ms, 21 MB/s85 ms, 70 MB/s
300k floats, 3 decimals2.8 MB10 ms, 287 MB/s26 ms, 107 MB/s37 ms, 75 MB/s
500k integers5.7 MB26 ms, 217 MB/s25 ms, 229 MB/s40 ms, 142 MB/s
100k strings with escapes5.3 MB9 ms, 601 MB/s6 ms, 820 MB/s25 ms, 209 MB/s

Writing 200,000 small objects (id, name, a score of the form n/8, a bool, a two-element array), 15.7 MB, byte-for-byte the same bytes as json.dumps(separators=(",", ":")):

timethroughput
std.json Writer106 ms148 MB/s
Python json.dumps266 ms59 MB/s

The honest reading. Scanning is fast and conversion is not uniformly: 17-digit floats are the weak case (about 0.9 µs each, 3.3× slower than Python’s float()), and writing a 17-digit float is slower still (§7). A handler reading a few fields and writing a short reply is at the top of both tables; one that converts 300,000 random doubles is not.

6. Found along the way

Writing a module that is the third to declare a pub fn drop found two compiler bugs, both with their own regression test (conformance/modules.rs, both backends), and modules.md §3.1.1 records them:

  • Functions: every function was emitted as lexs_<name>. The type checker accepted two modules each declaring drop; the assembler refused with invalid redefinition of function 'lexs_drop'. Symbols are module.name now.
  • Statics (silent, and worse): a static was looked up among every module’s and the first taken, so two modules each with static table read one table. a.first() * 10 + b.first() answered 55 where the tables held 5 and 7. Looked up in its own module now.

And one change outside this module’s files that it needed: std.fmt’s float_into prints a number with a short decimal form (3.5, 19.99, 0.000125) without the exact algorithm (a candidate m / 10^k is checked by one exact division; 10^-k wider than a float’s spacing makes the candidate unique, so it is the shortest and the nearest). That took float writing from 6 µs to under 0.5 µs where it applies, and the existing oracle test (against Rust’s {:e}) now includes 2,000 short decimals and their one-ulp neighbours, which must fall back correctly.

6.1 Splicing a document in: put_fragment

The writer had no way to take an already-written value – a schema another library generated, a stored row, a cached answer – so a service assembling an OpenAPI document or a list response from stored JSON built it by hand with a Buffer and commas, correct only by construction. put_fragment(heap, w, fragment) writes the fragment as the next value, with the writer’s own commas and colons. The fragment is checked: parsed with this module’s strict parser, it must be exactly one value (whitespace around it is fine); a second value, a trailing comma, or nothing traps, for the reason finish traps on an open container – splicing a non-value makes the whole document invalid from a distance. The cost is one pass over the fragment and a tape freed before it returns. (put_raw is the writer’s private helper for null, true and the rest, and is not that: it trusts its text.)

7. What it does not do

Eisel–Lemire / RyuThe fast float paths cover short decimals; a 17-digit double is ~0.9 µs to read and ~6 µs to write. Both want 128-bit multiplies and a 650-entry table of powers of five – generated, as std.math’s 2/π table was, not copied
A mutable documentThe tape is read-only. Build a different document with the Writer
StreamingA whole document in memory. A chunked reader would be a second parser
Integers past 64 bitsThey saturate in to_int and are to_float-able; there is no big integer
Pretty printing{"a":1}, never indented
Comments, JSON5, trailing commasRefused, by design (§3)
Schema validation, typed decodingNo reflection, no macros: a model is a hand-written function over the tape. Generating those from a declaration is a tool, not a library
Newline-delimited JSONSplit on \n with std.bytes and parse each with parse_with (§3.1), one tape and one state for the whole file

8. Open

QuestionWhy it waits
A hash map, for objects too wide for a linear getstd.map exists now (map.md) with byte-string keys; indexing a parsed object into one is a few lines a caller can write, and nothing here does it for them
Float reading and writing at Python speed§7’s first row
An HTTP layer that uses itBuilt: http.md (parser, router) and server.md (examples/api, which reads and writes JSON bodies with this module)

std.map: a hash map from byte strings to copyable values

Status: built. std/map.cho; tests tests/lex/map_test.cho (5, run by cancho test on both backends); drivers tests/programs/map_bench.cho and map_lookup_bench.cho. Insert, overwrite, lookup, remove, and iteration in insertion order. §5 has the numbers, §7 the compiler bug a benchmark found.

1. Why

json.md §8 listed it: an object with forty members is walked linearly by json.get, which is what a flat tape costs and is fine until it is not. The same shape comes up everywhere a program keys on text it was handed – an HTTP route table, a header set, a symbol table, a cache – and until now a program wrote a Vec of pairs and scanned it.

2. The decision: a key is bytes

A map generic over its key needs a hash and an equality for that key. This language has no traits, so the choice was between passing both as function values on every call (function-values.md), and fixing the key type. Everything keyed on today is bytes: member names, headers, routes, file names, identifiers. So the key is &[byte], and the value is any val type, the same bound std.vec has and for the same two reasons (collections.md §2).

The map copies the key into storage of its own on put, so the slice a caller passes need not outlive the call. key_at hands back a view into that storage, valid until the map is next written to.

A map keyed on integers is not this module. It would be a different hash (an integer needs no byte loop) and no copy; nothing has asked for it, and map.put(h, m, key_bytes_of(n), v) works in the meantime.

3. The structure

slots   [0 | -1 | entry+1 ...]   power-of-two length, at most half full
meta    [start, length, hash, live] per entry
vals    [V] per entry
keys    one Buffer, every key's bytes back to back

Open addressing, linear probing, and the entries themselves in the order they were first put. Three consequences are the interface:

  • Iteration is deterministic. 0..entries(m) skipping !is_live is the insertion order, whatever the hash did, so a program that prints a map prints the same bytes on every run and every machine. A key that is overwritten keeps its place; a key removed and put again goes to the end.
  • Removal marks, it does not move. remove makes the entry dead and its slot a tombstone (-1). Entry numbers held by a caller stay valid until the next put that finds the entry arrays full, which compacts: live entries are copied in order into arrays sized for twice their number and the dead ones’ key bytes are dropped.
  • The table is never more than half full, tombstones included. It is sized from the entry capacity and every tombstone is an entry not yet compacted away, so a probe always reaches an empty slot and the lookup loop needs no bound.

put and remove have the shapes std.vec gave push and pop: put takes the map by value and returns it because growth replaces the storage; remove takes it by unique reference because nothing is replaced.

4. The hash

FNV-1a over the bytes, seeded, then murmur3’s fmix64 finalizer. FNV-1a is one xor and one multiply a byte and is not strong; the finalizer is what makes the low bits – the ones that pick a slot – depend on every byte, so sequential keys (key-0, key-1, …) do not cluster. The 64-bit offset basis has its top bit set and does not parse as a literal (sha512.md), so it is built from halves; wrapping_mul is the multiply and shr33 masks the arithmetic shift into a logical one.

It is not a defence against a hostile key set. An attacker who knows the seed – or the hash, with a seed of zero – can choose keys that all land in one probe chain and make a lookup linear. If keys arrive from outside, pass a per-process seed from a source of randomness. That raises the cost of the attack from “send keys” to “learn the seed”, which is what a seeded non-cryptographic hash can do and what Python and Rust’s defaults also stop at (theirs are stronger hashes; the shape of the defence is the same). A keyed hash such as SipHash is the upgrade if a caller needs more, and it is a change to hash alone.

5. Evidence

Correctness

CheckResult
put / overwrite / get / missing / haspass, both backends
remove, remove of an absent key, put again after removepass
the empty keypass
insertion order across growth and a compaction that drops two removed entriespass
30,000 random puts, removes and gets over 300 keys against a plain arrayevery get, every remove answer, and the final size agree
mutation: remove no longer decrements the count3 of the 5 tests fail

The last row is how the random test was checked to be able to fail at all. The starting capacity is 4, so the run grows the table many times and compacts it with tombstones in the probe chains.

Speed. One shared, noisy machine; each figure is the spread of three runs, taken as a difference between a short and a long run so the build of the table cancels.

Lookup, 12-byte keyscanchoRust HashMap<Vec<u8>, _>Python dict
100,000 keys (in cache)51-72 ns52-81 ns~190 ns
2,000,000 keys (past cache)171-196 ns208-251 ns~455 ns

The honest reading is the same ballpark as Rust’s standard map, not faster. Rust’s default hasher is SipHash, built for the hostile-key case §4 declines, so a Rust map with a cheap hasher would be quicker; and its keys are separate allocations where these are one contiguous buffer, which is a layout difference as much as a table one. The Python column is one run.

Put 1M keys, get 1M, remove 500k, get 1M (map_bench.cho), building every key as a string as it goes: cancho 1.2-1.4 s, Rust 1.6-2.2 s, Python 2.4 s. That figure is mostly key construction and allocation, which all three do.

6. What it does not do

Integer or other keys§2. A different hash and no copy; nothing has asked
Values that are resourcesV: val, the std.vec bound. A map holding a res value is the std.list shape, and no collection of that shape exists yet
ShrinkingThe arrays shrink only on a compaction, which happens only on a put that fills them. A map that grew to a million and was then emptied keeps its storage until the next growth
A hostile-key defence§4
Iteration while writingkey_at is a view into storage a put may replace; the checker enforces it, because the view borrows the map

7. Found along the way: the LLVM backend grew the stack inside loops

The first benchmark died with SIGSEGV at 30,000 keys. Cranelift ran it correctly; so did the LLVM backend at 20,000.

gdb showed lexs_run’s frame pointer near the top of the stack and its stack pointer exactly 8 MiB lower. The disassembly had lea -0x70(%rax),%rdi; mov %rdi,%rsp inside the loop: a dynamic alloca. The backend emitted an alloca where it was used for the buffer every borrow writes its referent into (a Map is nine words, so 0x70 bytes) and for the one-byte temporary every && and || merges through. An alloca outside the entry block takes stack each time it runs and returns it only when the function does, and mem2reg promotes only entry-block ones. About 30,000 iterations was the 8 MiB.

Every alloca outside the slot prologue is now collected and spliced into the entry block (FuncEmitter::hoist); the cell is the same one each time the code runs, which is all any of these uses wanted. A region inside a loop is covered too: its base and bump cells were the same pattern.

Why nothing caught it. The conformance suite’s loops are short, and where they were long the reference never reached a function the optimiser could not see through, so the alloca was deleted as dead. The regression test (a_loop_body_does_not_grow_the_stack_on_either_backend) took three attempts for exactly that reason: the first two passed on the broken compiler. The version that fails there passes the borrowed value to a non-tail recursive function with a depth known only at run time, so the reference escapes, and runs 20 million iterations of a borrow and a && / ||. It was run against the unfixed backend and fails with SIGSEGV there.

That is the second bug in a row that a library found and a compiler test did not (json.md §6), and the pattern is the same: the compiler was tested on programs written to test the compiler.

8. Open

QuestionWhy it waits
A router over thisThe next piece: a path is bytes, the :id segments are a parse, the table is this map plus a segment list
Integer keys§2
A keyed hash for hostile input§4
Wiring it into std.jsonjson.get is a linear scan and is right for the objects it sees; an index_object that builds a map is a caller’s few lines, and nothing yet has a document wide enough to justify owning it

std.http and std.route: reading a request and choosing a handler

Status: built. std/http.cho (request parser, percent-decoding, query lookup, response head) and std/route.cho (method + path to a route id); tests tests/lex/http_test.cho (8) and route_test.cho (2), run by cancho test; conformance/http.rs; drivers under tests/programs/. No sockets, no allocation of their own beyond the router’s tables. §6 is the evidence, §7 the numbers.

1. Why

json.md was the first piece of a FastAPI-shaped package; this is the second and third. A request arrives as bytes and has to become a method, a path, some headers and a length (std.http), and then a decision about which code runs (std.route). packages/http-request could read a request and stream its body to standard output, which is what the two programs that needed it did, and was no help to a program that wants to look at one.

It is a parser and a router, not a server: nothing here touches a socket, so it works the same on bytes from net.sockets, from a file, or from a test. The server loop, body reading and an end-to-end benchmark against a real framework are the next piece (§8).

2. std.http: the table

parse(src, table) fills a table of integers the caller provides – offsets into src, nothing copied, the shape std.json’s tape has – and answers the offset where the body starts, or a negative error. slots(n) is how big the table must be for n headers; more headers than that is a refusal, not a truncation.

let table = box_slice(heap, http.slots(32), 0);
let n = http.parse(request, contents(w));
if n >= 0 { let id = route.find(router, http.method(request, t), http.path(request, t), p); }

src may hold more than the head (the start of a body, or the next request of a pipelined connection): parse stops at the blank line and never looks past it, and its answer is where to resume.

An error is a value, and says where: 0 - (position * 16 + code), decoded by error_code, error_position and error_message, the encoding std.json uses. One code is not a refusal: 1 means the head has not all arrived, is_incomplete says so, and a server reads more and calls parse again. A head longer than max_head() (64 KiB) with no blank line is code 10, not 1, so a peer cannot make a server buffer without bound.

3. What is refused, and why

The posture is std.json’s: strict, and a refusal says where. Most of what HTTP/1.1 parsers disagree about is how a message ends, and disagreement there is request smuggling: a proxy and a server that read the same bytes as two different requests. So the ambiguous ones are refused.

CodeRefusedWhy
1head not completenot an error: read more
2request line malformed; anything after the version but CRLFone shape, one parse
3method not a tokenRFC 9110 §5.6.2
4target empty, with a #, or with a byte outside visible ASCIIa fragment is never sent to a server; raw high bytes must be percent-encoded
5version other than HTTP/1.0 and HTTP/1.1nothing here speaks 2 or 0.9
6header with no colon, an empty name, whitespace before the colon, a bare line feed or NUL in a valuethe classic parser-differential inputs
7more headers than the table holdsa limit, said aloud
8Content-Length that is not 1-15 digits, or repeated with different values+5, 5, 5 and 0x5 all mean different things to different parsers
9obsolete line folding (a header line starting with space or tab)RFC 9112 §5.2 lets a server refuse it, and it is how a header is smuggled past a filter
10head over 64 KiB with no blank linebounded buffering
11Transfer-Encoding other than chunked, or chunked together with Content-Lengththe textbook CL.TE / TE.CL smuggling pair; RFC 9112 §6.3 says to refuse
12HTTP/1.1 with no Host, or two§7.2

Line endings are CRLF only; a bare line feed is refused (code 6). A request with a repeated Content-Length that agrees is accepted, because RFC 9112 §6.3 allows collapsing it.

One thing this does not do: a malformed head is refused only once the blank line has arrived, so garbage with no terminator gets code 1 until the limit or the connection’s own timeout, not an immediate 400. A server needs a read timeout regardless, and parsing line by line as bytes arrive would refuse sooner; nothing needs it yet.

After the table, the accessors: method, target, path (before any ?, still percent-encoded), query, version, header_count, header_name/header_value, find_header/header (case-insensitive; write the name lowercase), content_length (-1 for none or chunked), is_chunked, body_start, and keeps_alive (HTTP/1.1 unless it said close, HTTP/1.0 only if it said keep-alive; tokens in a comma-separated Connection value are found).

percent_decode(text, out, plus_is_space) decodes into a buffer you provide and answers the length, or -1 for a malformed or truncated escape or no room – never “pass it through”. query_value(query, key) answers the raw (start, end) of the first key=value, or (-1, -1); a key with no = has an empty value.

respond_head(heap, out, status, content_type, length, keep_alive) appends the status line, Content-Type, Content-Length, Connection and the blank line. It traps on a content_type containing a carriage return, line feed or NUL, and on a status outside 100-999 or a negative length: header injection is a program bug, and the place to find it is the first response, not a client’s cookie jar.

respond_no_content(heap, out, status, keep_alive, extra) is the head of a response with no body at all – 204 No Content and 304 Not Modified – which may not carry a Content-Length (RFC 9110 §8.6, §15.3.5) and have no use for the Content-Type that respond_head always writes. It writes the status line, Connection, the extra lines and the blank line. It traps on any other status (a 200 with no length on a keep-alive connection has no end a client can find) and on a bad extra, like respond_head_with (the final CRLF of extra is optional: http-server.md §13). Found by building a service whose DELETE wanted a 204: respond_head could only say Content-Length: 0 with a type, and the example answered 200 with the deleted record instead.

4. Decisions

QuestionAnswerWhy
Header table: strings or offsets?offsets, in the caller’s tablethe std.json answer: no allocation, and the other forty headers are never turned into anything
Lenient or strict?strict§3. A parser that accepts everything is the other side of a smuggling attack
Is Host required?yes for 1.1, not for 1.0RFC 9112 §3.2
Chunked bodiesrecognised and flagged, not decodedthe body is the caller’s; a decoder is a stream and belongs with the server loop
Expect: 100-continue, trailers, upgradesnot handlednothing has asked
Methodsany tokena router decides what it serves

5. std.route

A router answers one question: given GET and /users/42/posts, which of the routes a program registered is it, and which parts of the path were parameters? It answers with a route id the program chose, and the program matches on it. There is no registry of function values, so dispatch is a jump the compiler can see, and a handler no route reaches is one reachability can drop.

r = route.add(heap, r, "GET", "/health", 1);
r = route.add(heap, r, "GET", "/users/:id/posts/:post", 2);
r = route.add(heap, r, "GET", "/files/*path", 3);

:name is one non-empty segment; *name, last only, is the rest of the path, even empty. Parameters come back as (start, end) ranges of the path in pattern order, in a table of 2 * most_params(router) ints.

All the rules, because a router with a hidden rule is a router with a security bug:

  1. Static first. A pattern with no parameter is found by hash. Only if no static route has the path are the others tried.
  2. Of the parameterised, the one added first that fits wins. Not the most specific; the first. Order is the one thing a reader can see.
  3. The path is matched as sent. %-escapes are not decoded first: /%75sers does not match /users. The parameters are raw ranges; decode them with http.percent_decode. Exactly one spelling of a path reaches a route, which is what keeps a proxy’s access rule and this router from disagreeing about which route it was.
  4. No normalisation. /a/ is not /a; /a//b is not /a/b; an empty segment matches no :param; no redirect is issued.
  5. Methods are exact and case-sensitive (RFC 9110 §9.1). get is not GET.
  6. 405 is not 404. find answers -2 for a path some route has under another method, -1 for a path no route has. (It does not say which methods, so no Allow header; §8.)

A malformed pattern, an id below zero, an empty method, or a second static route for the same method and path traps at registration – the program is wrong, and a table is written once, at start.

How lookup scales. Parameterised routes are indexed by their first literal segment, so a table of a thousand /svc17/items/:id routes costs a lookup like a table of ten; only routes sharing a first segment, and routes that begin with a parameter, are compared one by one. The first version scanned every parameterised route in order, and §7 has what that cost.

5.1 http.dechunk: a chunked request body

dechunk(src, out) -> (consumed, decoded) reads the chunked body at src[0] (where parse said the body starts) into out. consumed is the bytes of src the whole body took – terminating chunk and blank line included, so a pipelined request is found right after – and decoded the bytes written; consumed is negative when there is no body to return: -1 not all here yet, -2 a size that is not 1-8 hex digits, -3 framing that is not exactly CRLF, -4 too large for out, -5 a chunk extension or trailer. dechunk_incomplete and dechunk_message read the code.

Strict on purpose: extensions and trailers are where smuggling lives, nothing here needs them, and refusing is one line where interpreting them is a page. When more bytes arrive the decode starts again from the first chunk (cost proportional to the body, bounded by out). Tested three ways: unit tests for a good body, every prefix of one (never wrongly refused – that is what lets a server call it again after each read), and each refusal; a differential test against a second decoder written independently in Rust over 600 generated bodies, each mutated four ways and cut once, plus overflow sizes – every one of the six outcomes reached; and two mutations of the decoder (nine digits allowed; a trailer treated as “wait”) each failing it.

6. Evidence

CheckResult
8 parser tests: every refusal code, with its exact position; agreeing duplicate Content-Length; chunked; the keep-alive rules for 1.0 and 1.1; pipelining stops at the blank line; table too small; a head that never ends; percent-decoding and the query; the response bytespass, both backends
2 router tests: static, parameterised, rest, 405, trailing/doubled slashes, escapes not decoded, prioritypass, both backends
Parser vs httparse: 600 generated valid requests (random methods, targets, 0-6 headers with arbitrary whitespace and values, optional length/chunked/close), each mutated four ways and cut once, 3,600 inputs1,089 accepted, every one with the same body offset, method, target, version, header count and every header name and value bytes as httparse; 1,499 incomplete (and where httparse has a whole request, it is because of a bare line feed, which this refuses); none of the rest accepted by httparse unless counted in the next row
…where this refuses what httparse accepts55 inputs, all in the categories §3 names: code 6 (21), 11 (17), 12 (9), 2 (4), 8 (4). The test fails for any other code
Never looser: this accepting what httparse refuses0
No trap on any bytes: 1,500 random byte strings (a fifth fully random, the rest from the grammar’s own alphabet, NULs and 0xff included)driver exits 0
Router vs a reference matcher written from §5 alone: 13 routes, 4,000 queriesevery answer and every parameter range agrees; 282 matched, 374 wrong method, 3,344 no route
12 misuse programs (bad pattern, negative id, empty method, duplicate static, header injection, bad status, negative length)each dies of SIGILL

Two things worth saying about how the first of those went. The parser differential passed the first time it ran, which is a reason to read the counts rather than the green: the statistics line is printed by the test and shows 1,089 accepted, not a handful. And the router corpus first produced no “no route” answers at all – a random one-segment route came out as /*rest, which matches everything – so its first version agreed with the reference on a table that could not fail on that axis. Fixed by never generating a root rest route, and the test now asserts every answer occurs more than a hundred times.

7. Measurements

One shared, noisy machine; each figure a spread over three runs, as a difference between a short and a long run. The request is a browser-sized head: 427 bytes, a path with two parameters and a query, 9 headers.

per request
httparse (Rust, tuned, SIMD where it can)0.26-0.28 µs
std.http.parse + std.route.find, 0 to 5,000 routes1.0-1.4 µs
Python http.client.parse_headers (what http.server uses)~37 µs

So about four times slower than httparse and about thirty times faster than Python’s parser. Parse and route together are one to two microseconds, which is a million requests a second a core before a socket is involved; the socket is where the time goes.

Before the router was indexed by first segment, parse and route together cost 4.0 µs a request with 100 routes and 17.5 µs with 1,000, linear in the table. After it, the cost is flat from none to 5,000. (The benchmark’s routes have distinct first segments, which is the case the index serves; a thousand routes under one prefix would still be a thousand comparisons, and §8 says so.)

Two optimisations that were tried and measured: the blank-line search went from a general substring scan to one that looks hard only at line feeds (1.35 to 1.0 µs, the whole gain); a compile-time table of character classes instead of comparison chains made no difference, and was kept only because it reads more clearly.

What is not measured here, and not claimed: a request through a real socket, a keep-alive connection, or anything against FastAPI. The first measurement of that, an accept loop, was in the conversation that proposed this work; a router-backed server is the next thing to build.

8. Not done

MissingWhy it waits
A server loop that reads, parses, routes and writesBuilt: examples/api, server.md, with the requests-a-second figures. Chunked decoding and a configurable buffer are built (§5.1); still missing: streaming a body larger than the buffer to a handler
Chunked decodingBuilt (§5.1): http.dechunk. Still not: Expect: 100-continue, trailers (refused), chunk extensions (refused)
Refusing a malformed head before its blank line arrives§3, last paragraph
Allow on a 405Built: route.allowed(heap, router, path, params, out) appends GET, POST – registration order, each once – and http.respond_head_with writes the header lines it was handed (anything but name: value lines traps, for the reason a header injection does; the last line’s CRLF may be left out, http-server.md §13, and the server’s reply* answer a 500 instead of trapping). examples/api answers Allow: on its 405s
Typed parameters (:id:int), regex constraintsHalf built, without the grammar: the handler asks for the type – route.param_nat(path, params, i) (a non-negative decimal of at most 17 digits, else -1), route.param(path, params, i) (the matched text), route.param_decoded(path, params, i, out) (percent-decoded). A router that refuses a non-number itself still adds a grammar, and nothing has asked
Many routes under one prefixlinear within the prefix (§7); a trie would fix it, and nothing has that many
Query string into a mapquery_value is a scan; a map is a few lines for a caller who wants one
JSON body binding and validation (what makes FastAPI feel like FastAPI)no reflection and no macros here: a model is a hand-written function over the std.json tape

examples/api: a JSON API server, and what it costs

Status: built, migrated (§8), and the loop extracted into a package (http-server.md). examples/api/api.cho over std.http, std.route, std.json, std.conns and the native socket builtins of native-sockets.md – no Ffi, no extern fn; conformance/api.rs (21 tests over real sockets); the load generator and the references behind §5’s figures are in benches/server/. One thread, keep-alive, pipelining, a JSON API with four routes. §§1-7 describe the first version, a poll(2) loop over Ffi("libc"); §8 says what replaced it and what that cost. Where §§1-7 name poll, send, MSG_DONTWAIT or the net.sockets package they are history, not description.

1. Why

json.md, map.md and http.md built the pieces of a FastAPI-shaped stack and each said the same thing in its last section: no socket loop, so no requests-per-second, so no honest answer to how fast would this be? This is the loop, and §5 is the answer.

It is an example program, not a std module. A server needs Ffi("libc") for read, write, poll and the rest, and std does not hold foreign declarations; the net.sockets package does, and the repository’s own test (the_network_programs_are_counted) insists that package stay the only file that declares bind, listen and accept. So the program imports it, as serve/ and collect/ do, and adds the three declarations nothing else wants: poll, signal, time.

2. The shape

poll(listener + every connection)
  listener readable      -> accept, put the connection in the next slot
  connection readable    -> read what has arrived into its buffer
                            -> answer every complete request at the front of it
                            -> move what is left (the start of the next) to the front
  connection silent too long -> close it

Connections live in a dense array: the poll records, the buffers (16 KiB each, one slab), the fill counts and the last-activity times are indexed together, and closing connection k moves the last one into slot k, bytes and all. The loop visits connections last first so that move only ever touches one already looked at.

What that buys, and conformance/api.rs checks each of them:

PropertyTest
Keep-alive: sixty requests down one connectionone_connection_serves_many_requests...
Pipelining: thirty requests in one write, answered in ordersame
A request split across segments (inside the request line, a header, at the blank line, inside the body) is answered once, wholea_request_split_across_segments...
The front of the next request after a whole one survives being moved to the front of the bufferthe_start_of_the_next_request...
Closing a connection hands its slot to the last one with that one’s half-received bytesclosing_a_connection_moves_the_last_one...
A silent client does not hold anyone up: fifty open and speechless, one half a head, forty other requests served inside three secondsa_silent_client_does_not_hold_up...
Sixty-four concurrent clients, forty requests each, every answer checked against what that client askedmany_clients_at_once...
Clients that vanish: ask and leave without reading (the write to a closed socket is SIGPIPE), leave in a head, leave in a bodyclients_that_vanish...
Idle timeout: a quiet connection is closed, a busy one never isan_idle_connection_is_closed...
A client that stops reading stalls only itself: a thousand requests for 32 KiB (32 MB of answers) and none read; thirty other requests served inside two seconds; then the stalled client reads every answer, whole and in ordera_client_that_stops_reading_stalls_only_itself
A slow reader gets every byte in the right order: a thousand answers of different sizes, send limited to 1,500 bytes so each goes in pieces, bodies a repeating alphabet so a misplaced byte showsa_slow_reader_gets_every_byte...
A client that never reads is closed after the idle time, and the server is unharmeda_client_that_never_reads_is_closed...
A closing connection closes only once its large answer has gone (and a refusal bigger than one piece still arrives)a_connection_that_is_to_close_closes_only_once...
Connection: close, and HTTP/1.0 closing by defaultconnection_close_is_honoured
Refusals close the connection: ten malformed or ambiguous requests each get their status, Connection: close, and EOFwhat_cannot_be_trusted...

Two of those tests were written because a mutation survived. Deleting the move-to-the-front passed every test I had first, because none of them put the start of a request behind a whole one in the same read; deleting the byte copy in the close path passed too, because none closed a connection while the last one held a half-sent head. Each now has a test that fails with the mutation and passes without it.

The write path went through the same exercise, and it was harder, for reasons worth keeping. Making send blocking again failed the stall tests at once (the other clients starve). But deleting the shift of unsent bytes after a partial send survived three versions of the test:

  1. The first never made a send partial at all. On Linux loopback a send is whole or refused, never partial – the kernel accepts a whole skb once any room is free – so the resume code ran only for the last hundred bytes of an answer and its shift loop ran zero times. A small receive buffer on the client made the test crawl (a window under one segment and delayed ACKs: about 100 KB/s), and a small SO_SNDBUF still gave whole sends.
  2. So the program gained a fourth argument, a send quantum: no send is handed more than that many bytes. It is a plausible fairness knob, and the only deterministic way to make partial sends certain on both operating systems.
  3. Even then the mutation survived, because the /blob body was one repeated byte and a chunk sent twice is indistinguishable from the right one. The body is now abcdefghij... repeating, and the test checks every byte’s position.

The last case, a connection that is to close after a large answer, was caught by neither the stall tests nor the refusal tests; its first test could not tell “closed promptly” from “closed by the idle timeout nine seconds later” and passed on the mutation anyway. It now runs with a thirty-second timeout, so only a prompt close passes.

And then macOS failed, which none of the above could have found. The first push went green on Linux and red on macOS, on the two tests about a client that stops reading. The reasoning that followed went wrong twice before the data settled it, and the order is worth keeping:

  1. Wrong flag? 0x40 is MSG_WAITALL on macOS. A test of the flag the server chose passed: it picked 0x80, correctly.
  2. Bigger kernel buffers, so 32 MB never stalled anything? A Rust probe of how much a loopback connection queues for a non-reading peer: 540 KB on the macOS runner. No.
  3. So the only way a client that never reads could be delivered all 32 MB was for the server to have been blocked in send and let go when the client finally read, which also explains the other client’s read timing out. A probe with nothing of ours in the path – a blocking socket, a peer that does not read, send(MSG_DONTWAIT) – blocked on macOS. The flag is not honoured there for a send.

The fix is the timeout, and it was checked the way the rest were: with the flag removed and the timeout kept, the stall tests pass on Linux (Linux behaving as macOS does); with both removed they fail with the same EAGAIN the macOS job reported. That reproduces the failure on the machine I could run on and shows the fix is what cures it.

a_send_to_a_peer_that_is_not_reading_returns_instead_of_waiting is that probe, kept: it sets the timeout and flag as the server does and fails, naming the flags, on a system where a send still waits.

3. Decisions

QuestionAnswerWhy
Threads, an event loop, or processes?One thread, poll; scale by running copiesA thread per connection needs a spawn payload of more than one leaf (threads.md), which does not exist; poll is POSIX and needs no per-OS code. reuseport as the second argument lets copies share a port and the kernel spreads the connections
poll, epoll or kqueue?pollIt is the one both CI targets have. It is linear in the connections, which at the 1,024 this allows is a few microseconds a wake-up and shows up in §5 as nothing
How does a [byte]-only foreign boundary pass struct pollfd[]?A prefix of a byte array, whose length is the record countA foreign slice is (pointer, length) and only [byte] may cross. poll(fds, nfds, timeout) wants a count of 8-byte records, so the program hands over polls[0..n + 1]: C reads nfds records from the pointer, and the allocation behind it is eight times as long as the slice says. Checked on a probe before it was built on
Non-blocking writes?A send that never waits for room: MSG_DONTWAIT where the OS honours it, a one-millisecond SO_SNDTIMEO where it does not. Not fcntl(O_NONBLOCK)fcntl(F_SETFL) is variadic in C, and variadic arguments are not passed like fixed ones on Apple arm64, so declaring it as an ordinary function would be wrong on one of the two targets; setsockopt and send have fixed signatures. macOS ignores MSG_DONTWAIT on a send to a blocking socket (§2 has how that was found), so on macOS it is the timeout that does the work: a send into a full buffer returns after at most a millisecond with what it managed or EAGAIN, which the output buffer already handles. The flag is 0x40 on Linux and 0x80 on macOS – where 0x40 is MSG_WAITALL, so the program asks which it is running on (SO_REUSEADDR at level 1, option 2 succeeds only on Linux)
What happens to what the kernel did not take?It waits in the connection’s own 64 KiB output buffer, and the connection is read no moreThe record asks poll for POLLOUT instead of POLLIN, so a client that does not take its answers cannot make the server buffer without bound: its next requests wait in the kernel. A connection that cannot hold even one more answer is closed, and one that makes no progress for the idle time (a byte read, a byte sent) is closed too
Several answers queued behind a partial send?Never: drain stops at the first answer that did not go wholeAt most one answer is ever waiting, so order cannot be wrong and the buffer needs to hold one answer, which /blob’s 32 KiB maximum bounds
After a refusal, or Connection: closeClose once the output has gone, not at the first partial sendClosing early would cut the answer short; closing never would hold the slot for ever
SIGPIPEsignal(13, SIG_IGN) at start13 and 1 are the same on both targets, and the alternative is a client that can kill the server by hanging up
SO_REUSEADDR, SO_REUSEPORTBoth operating systems’ numbers are set; the wrong pair is refused harmlesslyLinux: level 1, options 2 and 15. macOS: level 0xffff, options 4 and 0x200. There is no way to ask which OS this is, and ignoring the result is correct
Where is a request’s body?In the same buffer as its head, so head and body together must fit 16 KiBA request that cannot is refused at once: 413 for a body, 431 for a head. Not waited on
After a malformed requestAnswer 400 and closeThere is no telling where the next request starts, which is the whole of the smuggling problem
The access logNoneA write a request is the most expensive thing the loop could add; a program that wants one knows where

4. What it answers

GET  /health        {"ok":true}
GET  /users/:id     {"id":42,"name":"user-42"}      400 if :id is not a number
POST /add           {"a":1,"b":2}  ->  {"sum":3}     422 if not two integers; a body that is
                                                     not JSON says what is wrong with it
GET  /search?q=...  {"q":"a b","length":3}           q percent-decoded, `+` a space; length in bytes

and {"error":...} for a path no route has (404), a path with another method (405), a request the parser refuses (400, and the connection closes), a chunked body (501), a request that cannot fit (413) and a head that cannot (431).

5. Measurements

The machine is a shared four-core container, so every figure is the spread of three five-second runs and the ratios are the claim, not the absolute numbers. The load generator is a C program written for this (benches/server/kload.c; closed loop: 2 threads, 16 connections each, every connection sends one request and waits for the whole response, GET /users/42, keep-alive), pinned to two cores; each server to one.

Server, one corerequests/secondwhat it does per request
C, poll, no parsing128,000 - 137,000finds the blank line, writes a fixed answer
examples/api (cancho)117,000 - 129,000parses the head strictly, routes, builds the JSON, writes it
FastAPI on uvicorn, uvloop + httptools3,400 - 3,550the same route and body
FastAPI on uvicorn, stock2,560 - 2,680the same

So about ninety percent of what a C loop of the same design does without parsing anything, and roughly forty times FastAPI (the extremes of the two ranges are 33 and 50) on the same core and the same request. Two copies sharing a port with reuseport reached 149,000 - 161,000 (the C one 167,000 - 187,000); with two of the four cores spent on the load generator that is a measure of the generator and the kernel as much as of the server, and is reported as that and not as scaling.

After non-blocking writes (send with MSG_DONTWAIT and a send timeout, per-connection state, POLLOUT bookkeeping), measured in the same session against the blocking version it replaced, three runs each, twice: the blocking version 128,000 - 151,000, the new one 119,000 - 141,000, with the C ceiling itself spread over 123,000 - 150,000 in that session. No measurable regression within the machine’s noise; the point estimates are about four percent apart, which three five-second runs cannot resolve. The figures in the table above are from an earlier, quieter session and are kept as measured.

Memory: 1.5 MB resident idle, 1.6 MB after three seconds under load with 32 connections – the 16 MiB of connection buffers are calloced and touched only as a connection uses them.

What this does not show. It is one request shape, a small answer, on loopback: no TLS, no body larger than a few bytes, no 10,000 connections, no tail latencies (the generator reported throughput, not percentiles; §10 has them now). The Python figures are for this container’s Python 3.11 and this machine; a faster interpreter would shrink the ratio, not close it. FastAPI with two workers measured lower than with one in this setup (1,200); that is the way uvicorn spreads a handful of long-lived connections across workers, not a property of FastAPI, and no multi-worker FastAPI figure is claimed. And the earlier figure in the conversation that proposed this – 27,000 requests a second for cancho against 980 for FastAPI – was a connection per request; this one is keep-alive, which is why both numbers are so much higher and the gap is wider.

6. What it does not do

A stalled client can still cost a millisecondWhere MSG_DONTWAIT is ignored (macOS), a send into a full buffer waits out its one-millisecond timeout before returning; the connection then waits for POLLOUT, so it costs the loop one millisecond per stall, not per request. A thousand connections stalling at once would cost a second, once. On Linux send returns at once
More than one core in one processRun copies with reuseport (second argument)
A body larger than the buffer413 – but the buffer is the fifth argument now (4 KiB to 1 MiB; fewer connections at a bigger one, so the input buffers together stay under 256 MiB), and a chunked body is decoded (§9). Streaming a body to a handler as it arrives is still not done: it would need a handler that takes a stream, and nothing here needs one
TLSexamples/tls_client is the client half; nothing serves it
Refusing a malformed head before its blank line arriveshttp.md §3: garbage with no terminator waits for the idle timeout, nine seconds by default
More than 1,024 connectionsA constant. Past it new connections are closed on accept
HTTP/2, Expect: 100-continue, upgradesnothing has asked
What makes FastAPI feel like FastAPIrequest models, validation, generated OpenAPI. This language has no reflection and no macros: a model is a hand-written function over the std.json tape, as add is

7. Found along the way

Nothing in the compiler this time, which is worth stating given the last two pieces each found a bug: after two signature slips the compiler caught (a json.is_int call with one argument too many, a local named like a function), the server answered every route correctly on its first run. What the tests found was two places the tests were thin (§2), and one wrong expectation of mine ("a b cé" is seven bytes, not six).

8. Over native sockets: what changed, and the number that fell

native-sockets.md is the account of the builtins; this is what they did to this program.

What went. Four extern fns of its own and eight imported from packages/net-sockets – twelve libc symbols behind Ffi("libc"), which made the authority report say ffi("libc") and nothing else. signal(SIGPIPE) (conn_write cannot raise it), the SO_SNDTIMEO workaround and the per-OS MSG_DONTWAIT flag with the is_linux probe (conn_write does not wait on a non-blocking connection, on either kernel), the SO_REUSE* guesses (a flag on tcp_listen), the hand-built sockaddr_in, and the poll-record byte array and its set_record/move_record (a Poller and tokens). net.lock and the package fetch are gone from the build. cancho authority now reports net_in – naming no port, because the port is an argument – conn_accept, conn_read, conn_write, poll, clock, heap, args and the console, and the_server_holds_no_foreign_authority pins it.

What changed in the loop. Connections are a std.conns.Table, found by slot, and the Poller’s token is the slot plus one. Closing one frees its slot instead of moving the last connection into its place (so the swap-remove, and the test named for it, now check only that closing one never disturbs another). The idle timeout is a once-a-second sweep over the slots with a monotonic clock, not a check made as each connection is visited.

The number. Same machine, same load (2 threads x 16 connections, closed loop, GET /users/42), server on one core, three five-second runs:

poll + Ffi (before)epoll + handles (after)
examples/api134,600 - 137,70072,700 - 74,400
C, no parsing145,000 - 156,000 (poll)74,000 - 84,000 (epoll)

A 47% fall, and it is the kernel, not the program. The C reference with the same loop and no parsing falls the same way when its poll is replaced by epoll – about 150,000 to about 80,000 – so the cancho server holds about nine tenths of the C ceiling for either, as it did before. What was ruled out, in order: the extra builtin calls and the ticket round trip (conns.read and conns.write are about one percent of the instructions each, under callgrind); extra syscalls (strace -c: one receive and one send per request and a wait every 20-odd, for both); the clock (19 ns a call, no syscall); O_NONBLOCK (the same with blocking connections); and server CPU (identical – about 390 ticks in the window for both, so each request costs the kernel twice as much). What is left is epoll against poll in this environment: a Firecracker VM, four cores, kernel 6.18, where a persistent wait-queue registration costs every arriving packet a callback that poll – registering only while it waits – never pays. That last step is an inference from the C comparison, not something observed inside the kernel.

What this corrects. native-sockets.md §4 said epoll was “also the scalable answer … a performance change as well as a safety one”. Measured here it is the reverse: poll is faster by about 1.8x at 32 connections and by about 1.8x at 300 (C: 186,000 and 165,000 against 96,000 and 105,000; cancho before and after: 173,000 and 159,000 against 86,000 and 88,000). The crossover where epoll’s O(ready) beats poll’s O(registered) was not found at or below 300 connections; 800 could not be measured (the load generator reported short reads against every server, C included). So the claim is withdrawn until it is measured on hardware that is not a VM. The Poller is the same abstraction either way: a poll(2)-backed one would need a registration table in memory the runtime owns, a larger change than this was, and it is recorded in native-sockets.md §10.4 as the open question it now is.

Still true. Against FastAPI (2,560 - 3,550 in §5) the migrated server is still about twenty-five times faster on the same core and request, down from forty, and at 73,000 it holds about nine tenths of what a C epoll loop does without parsing anything.

9. Gaps closed since

Allow on a 405 (route.allowed, http.respond_head_with) and typed route parameters (route.param_nat, param, param_decoded) – both in http.md §8 – and examples/api uses them: GET /add answers 405 with Allow: POST, and /users/:id and /blob/:n read their number with param_nat instead of a private parser.

10. Tail latency

§5 said “no tail latencies (the generator reports throughput, not percentiles)”. benches/server/kload.c now takes a sixth argument, lat, and prints percentiles in microseconds: from just before a request is written to the whole response having been read, one sample per request, every sample kept and sorted (no sketch). It is a closed loop with K requests in flight a thread, so a sample is a request’s service time plus its wait behind the others in its round – what a client of a busy server sees, not what an idle one does, and the reason these numbers grow with the connection count below. Same machine and placement as §5 (server on one core, load on two others), 32 connections, four seconds, one run each:

Server, one corereq/sp50p90p99p99.9max
C, poll, no parsing149,000137 us220 us349 us1.1 ms3.3 ms
cancho, poll + Ffi (before §8)139,000147 us229 us418 us1.7 ms33 ms
C, epoll, no parsing84,000228 us374 us591 us2.0 ms5.1 ms
cancho, Poller + handles (now)71,000285 us449 us647 us1.8 ms7.8 ms
FastAPI on uvicorn, stock2,10012.7 ms18.3 ms53 ms62 ms64 ms

What it says. cancho tracks the C loop of the same kind at every percentile – its p99 is within 10% of C’s epoll and its p99.9 is below it – so the loop has no pathological tail of its own; the shape is the kernel’s, which §8 found. The one thing worth noticing is the max of 33 ms in the poll version: one sample in 555,000, a single stall (a scheduler hiccup in a shared VM is the likeliest cause; it did not recur in the later runs and is reported rather than explained away). Against FastAPI the gap is about 45 times at the median and about 80 times at p99, wider than the throughput gap because Python’s tail is long – garbage collection and the event loop’s own scheduling – where this program allocates from a heap it controls.

With 400 connections (the generator could not open more than 256 a thread until this change; it now allocates its connection table) the same server does 84,000 a second at p50 2.9 ms, p99 7.5 ms, p99.9 9.9 ms, max 10.9 ms. That is queueing, not slowness: 400 requests in flight at 84,000 a second is 4.8 ms each by Little’s law, and the p50 is 2.9.

Caveats, and they are the §5 ones plus this: one run each, so a ratio is the claim and a digit is not; a shared VM with a noisy tail of its own; loopback, so no network; FastAPI measured here with the versions this container installs (0.142, uvicorn 0.54) at 2,100 requests a second, a little under §5’s 2,560 – which is the point of keeping the ratio and not the figure.

Chunked request bodies and larger bodies. std.http.dechunk decodes a chunked body into a buffer – strictly: it refuses chunk extensions and trailers (where request smuggling lives, and nothing here needs either), a bare line feed, a size that is not 1-8 hex digits, and a body that would not fit – and the server hands the handler the decoded bytes, so /add cannot tell a chunked POST from one with a length (http.md §5.1). A body that arrives in pieces is decoded again from its first chunk each time more of it does, which costs in proportion to the body and is bounded by the buffer. api’s fifth argument sets the per-connection input buffer from 4 KiB to 1 MiB: a 40 KB JSON body is refused with 413 by default and answered with api 8080 no 9 0 65536. Measured: the decoder agrees with a second, independently written decoder on 3,604 generated and mutated bodies (every outcome reached; two mutations of the decoder each fail the test), and the server’s throughput and tail are unchanged (69,000-72,000 a second, p99 about 0.65 ms).

What is still not done in this server: streaming a body to a handler as it arrives (so a 100 MB upload does not need a 100 MB buffer), Expect: 100-continue, and trailers. (packages/http-server has all three since http-server.md §12: a server that calls limits streams, answers 100 Continue when its application asks, and accepts and discards trailers and chunk extensions. examples/api does not call it.)

http.server: the server loop as a package

Status: built (§7). examples/api/api.cho carried the whole loop – accept, read, parse, frame, pipeline, back-pressure, idle sweep – with the application’s routes inside it. A second server would copy 600 lines. This extracts the loop; docs/server.md stays the account of what the loop does and costs, this says where it lives and why it has this shape. §11 (byte-fed mode, so the loop can sit behind TLS) is built too, and examples/https_hello is the HTTPS server made of it and packages/tls. So is §12 (streaming request bodies and Expect: 100-continue): a body larger than the buffer, in pieces, with the same back-pressure and two timers of its own.

1. What was asked

Take the loop out of the example so a server is its routes and a main. It is the piece cancho-web would build on, and the first package that imports std (package-system.md §4.8).

2. The obvious shape does not type-check

The first design is serve(heap, router, handler, ...): the loop calls a handler it was given. Lex has function values (function-values.md), so this looks free. It is not, and the reason is measured rather than assumed:

fn drive[&k](heap: &!k Heap, h: fn(&k [byte]) -> [] int) -> [heap] int {
    let b = box_slice(heap, 8, byte_of(0));
    borrow mut b as &!bw in { let c = contents(bw); n = h(c[0..4]); }

is refused – bw does not outlive k, so a reference valid for the first cannot be used where the second is expected. A function value’s type may mention only regions already in scope where it is written (function-values.md §4.2, “a type quantified over its own regions (rank 2) is refused”), and the request the loop would hand a handler lives in a buffer the loop borrowed itself. The handler’s parameters would need a region the caller cannot name. So a callback cannot receive a view of the loop’s own buffers, and copying every request into an owned value to get round that costs an allocation per request on the path the benchmark measures.

3. The shape that does: the loop is the application’s

The package owns the state and the work that has no business being rewritten – sockets, framing, pipelining, back-pressure, timeouts – and returns control at the one place the application has something to say:

let srv = server.open(heap, poller, listener, size, chunk, idle);
while true {
    srv = server.wait(heap, srv, clock, listener, 1000);     // I/O, once
    while (k = server.next(heap, srv)) >= 0 {                // a request is in hand
        // server.head / parsed / body: views of the loop's buffers
        // ... the application builds its answer ...
        server.respond(srv, answer);                          // send, or queue
    }
}

The views are ordinary borrows of the Server (&s), valid inside the borrow that reads them – which is what a rank-1 region system is good at, and what a callback is not. The application’s own state is its own variables, which a callback with no closures could not reach at all (function-values.md §4.1).

What stays in the package: everything drain, emit and serve_on did. What moves out: the route table and the handler, which were never the loop’s.

4. The contract

  • open takes the Poller and the listener and registers the listener; the server owns the Poller from then on and close ends it.
  • wait is the one place that blocks (at most timeout_ms). It accepts, reads, writes what was queued, sweeps idle connections, and leaves a list of connections that hold input. It takes and returns the Server by value because accepting replaces the connection table (std.conns.put grows it).
  • next answers the slot of a connection holding one complete request, parsed and (if chunked) decoded, or -1 when none is left. Refusals the loop owns – 400 for a request the parser rejects, 413/431 for one that cannot fit – are answered inside next and never reach the application.
  • respond appends the answer to the current connection: straight to the kernel if nothing is queued ahead of it, otherwise to the connection’s output buffer, in order. Once a connection has output waiting next takes no more requests from it: back-pressure is the loop’s, not the application’s.
  • The application must call next until -1 before the next wait. A request skipped would sit unanswered; wait therefore finishes any connection next left half-visited and re-queues unvisited ones rather than losing them.

5. What does not change

The behaviour. Every test in conformance/api.rs runs against the migrated examples/api unchanged, and the throughput and tail-latency figures in server.md are re-measured (§7).

6. What it does not do

Streaming bodies, Expect: 100-continue, TLS and more than one core are server.md §6’s list and remain the list. Handlers see a request whole. (TLS: §11 is the byte-fed mode that lets a TLS terminator drive the loop. Streaming a response is stream there. Streaming a request, and Expect, are §12: a server that calls limits gets them; one that does not still sees a request whole and ignores Expect.)

7. Built

packages/http-server/server.cho (module http.server, published with vcs publish --std into packages/http-server/.cancho-vcs), and examples/api/api.cho rewritten onto it: the example is its routes, its handlers and a 60-line run loop, and consumes the package through examples/api/server.lock like every other package consumer.

The tests. All 21 conformance/api.rs tests pass unchanged against the migrated server – keep-alive, pipelining, split requests, slow readers, vanishing clients, chunked and oversized bodies, refusals, idle timeouts. The contract’s one clause api does not follow (next until -1 before the next wait) has its own test over tests/programs/server_one_per_round.cho: four connections, six pipelined requests each, one next per wait. It found a bug the first time it ran: the connection next was part-way through was finished (its input compacted) but never queued again, and no input would arrive to wake it, so five of its six requests were never answered. wait now re-queues it when requests remain buffered. Without that fix the test fails; with the unvisited-connections shift broken it fails too (both checked by mutation). A second test holds the checked-in store to the checked-in source, so an edit that is not re-published is red.

Throughput. Same machine, same session, the old and new binaries alternated, server pinned to core 0, kload 2x16 connections on cores 2-3:

requests a second (three 5 s rounds)
before (loop inside api.cho)71,446 70,979 73,180 / 73,926 70,432 71,683
after (http.server)81,516 78,185 76,806 / 76,566 76,640 76,755

It is not slower; it measured about 7% faster. Why was not investigated, and the two series overlap in one round, so the claim is “no regression”, not “a speed-up”.

What it cost the application. The borrow nesting moved. Reading a request is a borrow srv as &sr around the handler, sending is a borrow mut srv as &!sw around respond, and the answer lives in a buffer of the application’s own between them. The application’s variables (the router, the parameter slots, the output buffer) are plain locals, which is what a callback could not have given it.

Left over. wait takes and returns the Server by value, because std.conns.put consumes the table to grow it. A put that works in place on a table made with its final capacity would make wait take &!s Server like the rest; it is a change to std.conns and nothing here asked for it. The package publishes every declaration in its file, helpers included (vcs publish cannot yet publish only the pub ones), so the lock pins the interface and the rest is reachable but not depended on.

8. Reply helpers added after a real service used it

cancho-web’s examples/users (a CRUD API over this package and cancho-schema) needed three things the package did not have, each now here:

  • reply_as(heap, out, status, content_type, body, keep, extra). reply hard-coded application/json, so an RFC 9457 application/problem+json answer had to be assembled by hand. reply/reply_with are now reply_as with that type, and nothing that called them changes.
  • reply_empty(heap, out, status, keep, extra), a 204/304 with no Content-Length and no Content-Type (std.http.respond_no_content), so a DELETE can answer 204.
  • json.put_fragment (in std.json, not here) for assembling a response from stored JSON.

conformance/http_server.rs::replies_of_any_type_and_bodiless_ones_keep_the_connection_framed sends four pipelined requests down one connection – JSON, 204, text/plain with an extra header, JSON – and checks the exact bytes: a wrong frame on the bodiless one would shift every answer after it. The store was republished (a changed body is refused by incremental publish, so it is regenerated), and examples/api/server.lock re-pins it with the two new names.

9. What a page endpoint found in std.buffer

cancho-web’s GET /users?limit=20 answered 40,000 requests a second against 99,900 for a hand-written C server (cancho-web/docs/benchmarks.md), the widest gap of its four workloads. Two causes, one in the application and one here, measured one after the other on the same machine in the same session (page of 20 users, 1.8 KB, server on core 0, kload on cores 2-3, three 5 s rounds each):

requests a second
each stored user re-parsed by json.put_fragment39,404 40,073 40,051
the page spliced as bytes (the users are the program’s own canonical output)59,830 64,873 63,353
… and buffer.append storing in one pass70,931 70,144 70,944

The first is the application’s, not this repository’s: it was validating text it had itself rendered. The second is std.buffer.append, which was push once per byte, and push checks the capacity and rebuilds the Buffer every time. Appending n bytes now makes room once and stores n times (tests/lex/buffer_test.cho checks it at every capacity boundary and fails under two deliberate mutations). Cost per extra user in a page was about 0.36 microseconds, roughly 4 ns a byte, before the change, which is what two byte-at-a-time copies of a response (into the page, then into the reply) would cost; that reading was not profiled.

Left: both copies remain byte loops. bulk-io.md §4 names the primitive that would fix it for the whole library (a slice-to-slice copy) and leaves open whether that is the compiler’s or the backend’s to do; this change does not decide it.

10. Holding a request, and handles of the application’s own

A request whose answer is not ready – it waits on a database – used to have one choice: block in next’s caller until the answer came, and stop every other connection. Two additions let the loop go on, and neither makes the server know what it is waiting on (cancho-pg/docs/nonblocking.md is the first user).

hold and answer. hold(srv) takes the request next handed over and answers a ticket instead of leaving it to respond; the loop goes on to the next request. answer(srv, ticket, bytes) is respond for that request, at any later time. What the server does meanwhile:

  • A held connection is not read from once its buffer is full (the rest of what the client sent stays in the kernel), and no request behind the held one is handed out until it is answered: the answers must leave in the order the requests came, so the next request on that connection waits. Requests already buffered behind it are served after the answer without waiting for new input.
  • It is not closed for being idle: the answer is the application’s to give, and so is giving up (an application that wants a deadline answers with 504).
  • A ticket is a slot and a generation of that slot (ticket % 2048, ticket / 2048; the generation increases every time a connection takes the slot). answer answers -1 and writes nothing for a ticket that was answered already, or whose connection is gone and whose slot may now be someone else’s. The -1 is the whole protection against writing an answer to the wrong client, so it is checked, not trusted.

poller, first_token, foreign_count, foreign. poller(srv) is the server’s own poller. The application registers its own handles in it (std.conns.watch with a token from first_token(srv) up: 0 is the listener, 1 to limit the connections), so that one wait serves them and the connections together. What wait reports for those tokens is not acted on; it is listed by foreign_count/foreign as (token, readiness) pairs, valid until the next wait, for the application to read from the handle and answer tickets. The list is 64 pairs; a pair beyond that is dropped, which is safe only for handles watched level-triggered (the poller reports them again next time), which std.conns.watch is.

tests/programs/server_hold.cho is the application used in conformance/http_server.rs: it watches a connection to the test and answers one held request for every byte the test writes.

testwhat it fixes
a_held_request_does_not_stop_the_loop_and_what_follows_it_waits_its_turnother clients are answered while one is held; a request pipelined behind it, and one written later, wait and come out in order after it
held_requests_are_released_one_each_and_oldest_firstthree held requests; each release answers one
a_held_connection_with_a_full_buffer_waits_without_reading_and_loses_nothing2,500 requests (about 80 KB) behind a held one, 5 buffers’ worth: all answered, in order
a_held_request_is_not_the_idle_timeouts_to_closeheld for 12 s, the idle timeout being 9
a_ticket_answers_once_and_never_reaches_a_connection_that_took_the_slotthe same ticket twice; and, after the connection is gone and another holds a request in its slot, the old ticket does not answer the new one

Nine single-edit mutations of the new code, each published to a scratch store and run against all of the tests above; every one is caught:

mutationfirst test to fail
answer does not check the generationa_ticket_answers_once...
answer does not check that the request is helda_ticket_answers_once...
the generation is not increased when a slot is taken againa_ticket_answers_once...
a held connection with a full buffer is still reada_held_connection_with_a_full_buffer...
a held request is produced again when more input arrivesa_held_request_does_not_stop_the_loop...
the idle sweep closes held connectionsa_held_request_is_not_the_idle_timeouts_to_close
foreign events are not countedfive tests
the ready queue is not compacted when fullanswering_more_than_the_table_has_rooms...
no requeue of requests buffered behind an answered onetwo tests

The first attempt left three of the nine standing (the held-twice one, the full-buffer one and the idle one); the last three tests, and the “nothing was held twice” check in the first, were written for them. The queue-compaction mutation survived a further round until a test ran the server with buffers of 64 MiB (room for four connections, so a ready queue of four).

One flake was found and fixed on the way: a release byte written before the server had registered the request it was meant for was read and lost, so the request was never released. The test application now keeps a byte as a credit until there is a held request to spend it on.

11. Serving it over TLS: the byte-fed mode

Status: built (this section was a proposal when examples/tls_echo merged, PR #346; building it corrected the proposal in the places marked Corrected). packages/tls has a server (tls-server.md), and examples/https_hello puts it, and this package, in one program: HTTP/1.1 over TLS 1.3 with keep-alive, pipelining, a 64 MiB streamed body and a client that stops reading, checked against curl, openssl s_client and Python’s ssl (§11.6), at a measured cost (§11.7).

11.1 What stopped it

A TLS terminator’s plaintext does not come from a socket: it comes out of tls.recv, and the answer goes into tls.send. This package could not be the plaintext side, for a precise reason: it never saw a connection’s bytes except through a socket it held itself.

  • wait owned the Listener’s readiness and accept_all called tcp_accept and conns.put: every connection the server knew was a Conn in its own conns.Table.
  • Input reached a connection’s buffer in one place, step, by conns.read. Output left in three: emit (the fast path), step’s write of what was queued, and the refusals produce sends through emit. shut closed the Conn; settle re-registered it with the poller.

The one way that works with no change is a loopback relay (the TLS side connecting to this server’s own listener on 127.0.0.1 for each client): it doubles the sockets, copies every byte once more each way, gives the HTTP side 127.0.0.1 for every client and adds net_out to the program’s row. It was not built, and it is not what the broker or the gateway should copy.

11.2 The API as built

A server opened with open_bytes has no listener and no sockets: the application owns them and moves the bytes. Everything that is not about where bytes come from is the socket server’s code, unchanged (next, head, parsed, body, respond, hold, answer, close, connections, the reply*/failure* helpers).

server.open_bytes(heap, size, out, idle, max) -> Opening      // Opening::Ok(Server) | Opening::Failed(errno)
k = server.attach(srv, now_ms)                                  // a connection: its slot, -1 when full
n = server.room(srv, k)                                         // how many bytes input takes now; 0 is back-pressure
n = server.input(srv, k, bytes, now_ms)                         // bytes from the peer; n taken (n <= room), -1 for a dead slot
    server.end_input(srv, k)                                    // the peer will send no more (half-close, close_notify)
    server.ready(srv, now_ms)                                   // `wait` without the poller: requeue, sweep idle; how many are queued
k = server.next(heap, srv) ...                                  // unchanged, with head / parsed / body / respond / hold / answer
n = server.output(srv, k, out, now_ms)                          // what the server would have written: up to len(out) bytes
n = server.pending(srv, k); n = server.space(srv, k)            // bytes of answer waiting; bytes of answer room left
b = server.closing(srv, k); server.detach(srv, k)               // the server is done with k; free the slot
n = server.stream(srv, ticket, bytes)                           // part of a held request's answer; n taken (n <= space)
open_bytessize is each connection’s input buffer (a request, head and body, must fit in it); out the answer each may have waiting (at least 512); idle seconds without progress before it ends; max connections at most (at least 1). Memory is max * (size + out) and 16 words of state a connection, all allocated here; nothing grows. Failed(22) for a size, out or max out of range, else the errno of the one kernel object it makes (below)
attachtakes a free slot, or answers -1. now_ms starts its idle clock
inputcopies at most room bytes into the connection’s buffer and queues it for next. It never takes more than room, so the application keeps the rest and asks again: this is the back-pressure. Dead or ended slots answer -1
readywhat wait does either side of its poller_wait: connections next did not reach, and the one it was part-way through, are queued again; every second, connections with no progress for more than idle seconds end
outputmoves up to len(out) bytes of the waiting answer to the caller (the bytes for tls.send) and counts it as progress. When the last byte of an answer that ends the connection goes, the connection ends
closingthe connection ended: Connection: close was answered, the peer ended and everything is answered, it went idle, an answer did not fit, or what it sent was refused (400, 413, 431). What is left is the application’s: close_notify, close the socket, detach
detachfrees the slot, ended or not (the socket failed, the application gave up). A request held for it is forgotten; its ticket answers -1 for ever, even when the slot is taken again (the generation of §10)
streamwith hold: the request stays held, nothing behind it is given out, and the idle timeout spares it while nothing is waiting to be sent. answer(ticket, "") ends it. This is how an answer larger than out is sent

Corrected, against the proposal:

  • open_bytes(heap, size, chunk, idle) is open_bytes(heap, size, out, idle, max). chunk limits a socket write and means nothing without one. out, the bound on buffered output, was the constant 64 KiB; a gateway wants it, and the number of connections, as parameters (the TLS engine holds about 40 KiB a connection beside them). The socket server’s open is unchanged and uses the same fields (osize is 65,536 for it).
  • It returns Opening, and it is not “no Poller”. Core has a Poller field the socket path registers handles in, and a res field cannot be absent without making every use of it a match. A byte-fed server therefore holds one kernel poller object (one file descriptor on Linux) that it never registers anything in; close closes it. That object is the only thing that can fail, hence the enum. wait on a byte-fed server returns it unchanged without blocking; poller, first_token and foreign are meaningless on one.
  • The clock is an argument, not a Clock. ready(heap, srv, clock) would have had the package read the clock, and attach and input (which stamp progress) would have had to read it too. The caller reads its clock once a turn and gives the milliseconds to every call that moves a byte or looks at time (§11.4).
  • room, end_input, pending, space and stream are additions. input “how many fit; 0 is back-pressure” cannot be used without knowing before tls.recv how much to ask for (the plaintext is in the engine; it has no way to hand some back), so there is room. A half-close needs a call (§11.4). space and pending let a caller size an answer to what the connection can take. stream is the one real addition: the proposal had no way to send an answer bigger than the output room, and the example’s /big/<n>, and the gateway’s proxied bodies, are exactly that.
  • It is about 330 lines, not a hundred (444 added, 75 removed, of which about 60 are code moved out of accept_all, serve_events and wait into functions both paths call).
  • The effect rows are the socket path’s. next is [heap, conn_write, poll] because the refusals it sends go through emit, and the language has no polymorphism over effects (AGENTS.md §8). A byte-fed program therefore reports conn_write and poll in cancho authority though no socket is written through them. It does not widen the report of a program that has sockets, which a terminator does; a program with none would want a second set of names, which nothing has asked for.
  • A server cannot be seen to be the wrong kind in the type. attach, ready, stream and the rest answer -1 (or do nothing) on a socket server, and wait does nothing on a byte-fed one; neither can trap.

11.3 How the two paths share the code

There is one parser, one framer, one pipeline and one back-pressure rule. produce (parse, frame, refuse), finish, advance, deliver_to / respond, hold / answer and the idle sweep are the functions the socket path always used, and a byte-fed server calls the same ones. What differs is where bytes cross a boundary, and each of those places is one test of core.fed:

socket serverbyte-fed server
bytes instep’s conns.read into the connection’s bufferinput copies into the same buffer
bytes outemit: conns.write to the kernel if nothing is queued, the rest into pendsemit skips the write: it all goes into pends; output drains it
shutcloses the Conn, frees the slotmarks the slot ended (closing); detach frees it
settlere-registers the Conn with the pollernothing
acceptingaccept_allattach
timewait reads the clockthe caller’s now_ms

Four pieces were taken out of the socket path’s functions so that both call them (init_slot from accept_all, enqueue from serve_events, begin_round from wait, sweep_idle from serve_events). The socket path’s behaviour is the same, and the 21 api tests and the 9 http_server tests pass unchanged. Four things did change on it, and are listed because “unchanged” is a claim the tests only partly carry:

  1. The idle sweep spares a held connection only if nothing is waiting to be sent. A socket server never holds with output waiting (produce hands out nothing while output waits), so for it the condition is the old one.
  2. A connection queued for next is queued through enqueue, which compacts a full queue first (answer already did; serve_events did not, and could in principle have written past the end).
  3. Slot word 15, which the socket path did not use, is the peer’s end (end_input); it starts at 0.
  4. The identity of every function that mentions Server changed (Core has four more fields), so examples/api/server.lock and tests/programs/server_hold.lock were re-pinned and the store republished. No signature changed; a consumer pinned to the old store keeps working on it.

scripts/publish_packages.py now publishes and checks packages/http-server too (it did x509 and tls only; the store is flat, packages/http-server/.cancho-vcs, which the locks pin). Republishing removes the old store’s files and writes the new: a changed body is refused by an incremental publish, as §8 found.

11.4 What the semantics are

Time. The package never reads a clock. attach, input, output and ready are given now_ms, any monotonic milliseconds, and a connection’s last progress is the time of the last byte in or out, in whole seconds, as for a socket. A byte-fed connection that no one feeds and no one drains is idle after idle seconds, which ready notices (at most once a second, from the now_ms it is given). The caller’s TLS handshake and close timeouts are its own; https_hello’s server idle is its own plus two seconds, so its sweep, which also drives the connection through close_notify, acts first.

Back-pressure shows up in three numbers and one rule. room(k) is the bytes of input the server will take: 0 while an answer is waiting (a client that does not take its answers is not read), and 0 once the connection is ending. pending(k) and space(k) are the answer waiting and the room for more, and pending + space = out. The rule is the socket server’s own: nothing is handed out by next for a connection with output waiting, so with a client that takes nothing the server holds one answer, and the connection’s input buffer (size), and no more. An answer that does not fit the room is refused whole (nothing is half sent) and the connection ends, as a socket’s does when the client has not read a buffer’s worth: respond answers -1 and closing is true. A caller that has more than space to say holds the request and streams it.

A half-close and an error. The peer’s end (a TCP FIN, or its close_notify) is end_input(k): what it sent is still answered, in order, and then the connection ends; a request it did not finish is dropped, as for a socket. A socket that fails, a TLS alert, a connection the application gives up on: detach(k). The application learns the server wants to end a connection from closing(k), and always ends it by detach. Both can happen with answers waiting: end_input waits for them to be taken; detach drops them.

Pipelining is the socket server’s: requests in the buffer are served in order, one at a time, because a connection with an answer waiting is not given another; the next ready or output that empties the answer queues it again. A request split across any number of inputs is answered once, when next says it is whole.

The memory a connection can make the server hold is size + out bytes and 16 words, fixed at open_bytes. Nothing is allocated per connection or per request by the package; the application allocates what it builds an answer in. In https_hello a connection also has four 16 KiB buffers and the TLS engine’s slot (§11.6).

Limits that are refusals, with their tags (the status, and failure’s message, which the client sees; none reaches the application): a request head that fills size without ending, 431 request head too large; a body that cannot fit, 413 request too large; a malformed request or an ambiguous length, 400 with std.http’s message. After any of them the connection ends once the answer is taken.

11.5 Tests and mutants

tests/packages/http_server_bytes_test.cho (19 cancho test tests, run on both backends by conformance/http_server_bytes.rs) is the byte-fed API without a socket, which is the point of the mode: a length body, a chunked body, three pipelined requests with a fourth cut short, a request fed one byte at a time in each framing (answered once, after its last byte and not before), every refusal and its status, half-closes (after a request, after nothing, in the middle of one, with an answer waiting, with a request held), a client that takes nothing (twenty pipelined requests, one answer held, input refused, pending never past out), an answer over the room (by 1 byte and by 100, and one that fits exactly), idle on the caller’s clock (progress by input and by output, the boundary second), slots (full, freed, reused, a freed one skipped over), a held request streamed in pieces and a ticket that never outlives its connection, a stalled stream ended by the idle timeout, the ready queue (its overflow, a slot freed while queued, the connection in hand detached), answers of no bytes, ready giving back a visit’s room, every bad slot and ticket, and 8 seeds of 3,000 random operations that must not trap or break a bound (pending <= out, room <= size).

scripts/http_server_bytes_mutants.py applies 90 single-edit mutations to the new code and the shared code it reaches, one at a time, to server.cho and runs those tests: 79 are killed and 11 survive, each argued equivalent in the script (ten because the line they change is redundant with another or unobservable: detach and init_slot both clear a slot’s buffered count, shut clears a flag that detach clears again; and one, stream refusing a socket server, because cancho test cannot open a socket server, which needs a Listener). The first run killed 74 of 91 and showed 11 survivors that were not equivalent: no test detached the connection next had in hand, none stopped at the second the idle limit is reached, none took output and then waited past the limit, none gave an answer one byte over the room, none ended a peer while its request was held, none answered with no bytes, and none looked at the room ready gives back; each has a test now (CI runs it).

11.6 examples/https_hello

examples/https_hello/ is tls_echo’s program with http.server where the echo was: hello.cho (the options, main), loop.cho (the loop), app.cho (the routes). tls_echo’s loop was split so that the two share it (below). Its authority report is tls_echo’s, label for label (conformance/https_hello.rs pins it: no foreign code, bounded).

socket -> tls.feed -> tls.recv -> rcv buffer -> server.input         requests
server.output -> app buffer -> tls.send -> tls.take -> socket        answers

Each stage reads only when the stage after it has emptied, so the same rule that bounds the server bounds the whole path: a client that stops reading its answers stops server.room, which stops tls.recv, which stops tls.feed, which stops reading the socket. A connection whose request is held with a full input buffer is not even watched. A turn is: wait; pump every connection the poller named; then rounds of {ready, answer up to 64 requests, give the streams what room they have, pump every connection that moved}, at most 16 rounds before returning to the poller (with no wait if work remains).

The routes: GET / (hello), GET /hello/<name>, POST /echo (a JSON body is returned inside {"method":..,"path":..,"bytes":..,"json":<body>}; anything else is a 400), GET /big/<n> (n bytes, a to z repeating, up to 1 GiB: the request is holded, the head streamed, and the body given as the connection takes it), 404 and 405 with Allow.

What moved to a shared file. examples/tls_echo/echo.cho and tls_echo.cho held the options, the per-slot state, the log, the handshake bounds (admit), accept_all, writing the engine’s bytes, end_slot, the reload and the command line (about 450 lines). A second program needed all of it, and the duplication check refuses a copy, so it is examples/tls_echo/front.cho (module tls_front) now: every function is byte for byte what it was, with pub added (and 4 more slots in stride). echo.cho keeps pump, close_with, sweep, stop_all and run, which touch the plaintext. main and serve remain in each program (they name their loop; a third program would move them: link-time selection of the module that holds run would do it).

scripts/https_hello_test.py (15 cases; CI’s tls-assurance job runs them):

casewhat it checks
curlcurl (OpenSSL build), HTTP/1.1 over TLS 1.3: one request; 100 requests on one connection (%{num_connects} adds up to 1); a JSON POST echoed; a 404 and a 405; a 100,000-byte streamed body byte for byte. Skipped, saying so, where curl is a SecureTransport build and cannot be given the test CA
opensslopenssl s_client: a request, a keep-alive one after it on the same connection, three pipelined in one write (answers in order), then Connection: close, answered and ended with close_notify
httpPython http.client: 2,000 requests on one connection, each answer checked; the JSON echo with every escape, non-ASCII and nesting; a body that is not JSON (400); a chunked request body; HTTP/1.0 answered and closed
pipelined300 requests in one write, then 300 with length and chunked bodies and a 404 every third, all in one write: 600 answers, in order, and the connection serves after
many200 connections at once (Python ssl, one thread each), 10 requests each
big/big/<n> for 16 sizes from 0 to 64 MiB (the boundaries of the 16 KiB record and the 64 KiB output room among them): every byte checked, the connection serves after each; a request pipelined behind a big one is answered in order; a length past 1 GiB, not a number, negative or empty is refused and the connection goes on
stalleda client asks for 1 GiB and reads 100 bytes: the server’s resident memory does not grow past 4 MB beside it, another client is served throughout (the slowest of its requests is reported), and the stalled one is ended after --idle; the server serves after
slowhalf a request and silence: ended after --idle, and the clients beside it are served
endeda connection ended by Connection: close and then written to every 50 ms (each write refused by the closed socket) beside a keep-alive client making 40 requests: every one is answered and no other connection is closed. The writes come from a thread of their own (below)
reload, bound, full, idle, shutdowntls_echo’s cases, over HTTP: a connection open before SIGHUP keeps its certificate and keeps being served, a later one gets the renewed one, a refused reload leaves it; --handshakes 2 held by two silent peers delays an honest one rather than refusing it; a fifth connection over --connections 4 is closed at once; an idle keep-alive connection is sent close_notify; SIGTERM with 10 open keep-alive connections sends close_notify to each and exits 0
hostile1,500 connections each sending a mangled request (flipped bytes, cuts, bare LFs, a 100 KB target, a thousand headers, JSON 5,000 deep, lengths past 2^64 or negative, chunk sizes past the end, 1,000 NULs): the server is alive after every one and serves after the last

Results. 14 of 14 on linux-aarch64 (Docker, with curl 8.5 built on OpenSSL 3.0.13), 13 of 14 on macOS (its curl is SecureTransport and the case says so); tls_echo_test.py still passes 8 of 8 on both after front.cho. conformance/https_hello.rs (64 connections through tls_many, a reload taken and one refused, a stop, the authority pin) and conformance/http_server_bytes.rs run in cargo test on both CI targets; cargo test --workspace on linux-aarch64 in Docker passes everything but the two tests that need a git checkout, as before this change.

A false report, and why ended writes from its own thread. It was reported that bytes arriving on an ended connection made the server close a different one: a Python client with D and H in one thread, D ended by Connection: close and then written to every 50 ms, saw H’s next request fail with BrokenPipeError within 0.1 s, the server alive and logging no close of H (it logged conn 2 closed tls-peer-closed only afterwards, when http.client closed H on the error). Measured on macOS, Python 3.14.5 with OpenSSL 3.6.4, the server is not involved:

  • when D’s conn 1 closed is logged its descriptor is gone (lsof), so D’s later bytes reach only the kernel, which answers them with a reset to D. The server’s descriptor for H stays ESTABLISHED, nothing arrives on H’s socket, and a turn of the loop never sees them;
  • H in a second process, beside the same D written to the same way: 10 of 10 requests answered;
  • the same two clients against a plain-Python TLS 1.3 server (ssl, one thread per connection, D closed after one read, H echoed): H’s write right after D’s refused one raises BrokenPipeError, the one after that succeeds;
  • calling OpenSSL’s ERR_clear_error (through ctypes, on the libcrypto that _ssl links) between D’s refused write and H’s makes every write on H succeed.

The cause is the client’s: OpenSSL keeps its error queue per thread, D’s refused SSL_write leaves an EPIPE on it, and CPython’s _ssl reports that entry as the failure of the next write on another socket in the same thread. There was nothing to correct in loop.cho or front.cho. The ended case keeps the report’s sequence, with D’s writes on their own thread: the unchanged server passes it 5 of 5. To check that the case can fail, end_slot was made to close the next slot too (a wrong-slot close, the bug the report suspected): the case failed (ConnectionResetError on H’s next request). That change was not kept.

11.7 Cost

python3 scripts/https_hello_test.py <https_hello> --cost <seconds> --kload kload --tload tload --plain api (CI’s tls-assurance job runs it): the closed-loop keep-alive load of docs/server.md §5 (benches/server/kload.c: 2 threads of 16 connections, one request outstanding on each, three rounds), plain against examples/api (the socket path of this same package, GET /users/42, a short JSON answer) and, through benches/server/tload.c, the same loop over TLS 1.3 against https_hello (GET /hello/42, a 99-byte answer with its head: a different route, so the application’s own work is not the same, and small beside what TLS adds). The server is pinned to one core and the load to two others. The script also reads the server’s CPU time from /proc and divides it by the requests.

Measured on two machines, both with the LLVM backend and OpenSSL 3.0.13 as the client: the x86-64 CI runner (a shared virtual machine, as tls-server.md §11.4’s 5.5 ms a handshake was), whose three rounds agree to 0.5%; and Docker Desktop’s Linux VM on an Apple M4 Max (aarch64 Linux 6.8, 6 vCPUs, other containers running), which is not a quiet machine: its rates moved by up to 1.5 times between two runs of the same command, so there the CPU a request is the figure to read:

requests a second, three roundsthe server’s CPU a request
x86-64, CI’s ubuntu-latest runner (the PR’s first green run), 5 s rounds
plain, examples/api178,451 179,056 178,4735.59 microseconds
TLS 1.3, https_hello42,614 42,313 42,08623.40 microseconds
aarch64, Docker Desktop’s Linux VM on an Apple M4 Max
plain, examples/api345,024 317,966 253,457 (10 s rounds); 406,611 260,736 262,227 (5 s rounds, an earlier run)2.68 microseconds
TLS 1.3, https_hello97,548 74,635 84,708 (10 s rounds); 84,732 133,024 133,420 (5 s rounds, an earlier run)9.77 microseconds

So over TLS this server costs 3.6 times (aarch64) to 4.2 times (x86-64) the CPU a request of the plain one, and serves as many times fewer requests a second from one core: about 42,000 a second of 99-byte answers on the CI’s x86-64 core, against 178,000 plain, and 75,000 to 130,000 against 250,000 to 400,000 on the aarch64 VM. The 7 microseconds (aarch64) or 18 (x86-64) a request that TLS adds is a record decrypted, a record encrypted, the engine’s and the loop’s copies, and the loop pumping a connection twice a request (§11.6). It was not profiled and that is the next place to look if the figure matters: the stages copy a request’s bytes several times each way (the socket into inq, tls.recv into rcv, input into the server’s buffer; an answer into pends, output into app, tls.take into pend) and server.output moves what is left of an answer to the front of its buffer on every call. A body streamed with /big/<n> ran at 83 MB a second on CI’s x86-64, 104 MB a second on the aarch64 VM and 245 MB a second on the Mac itself, outside Docker, each checked byte for byte. Not measured: more than 32 connections, handshakes (tls-server.md §11.4’s 3.0 ms on the M4 and 5.5 ms on CI’s x86-64 are unchanged).

11.8 What cancho-gateway needs next

What the gateway (an HTTP/1.1 reverse proxy that terminates HTTPS) can build on today, and what it still lacks. In the order it will hit them:

  1. Streaming a request body. Built, §12. (It was: a request is handed over whole, so one larger than size is refused with a 413, and a proxy forwarding an upload would have to buffer it.) limits opts in; the held request’s ticket then gives body_part, body_take, body_state, body_total and proceed (100 Continue), with the same room rule as answers, and refusals with rule tags. What the gateway still has to do with it is in §12.14.
  2. Chunked responses. stream sends bytes; a proxied answer of unknown length needs Transfer-Encoding: chunked framing on the way out (and de-chunking on the way in from the upstream). std.http has dechunk and no chunk writer.
  3. The peer’s address. std.conns does not give it Corrected (conn-peer.md): conns.peer(table, slot) answers it (std.addr.Peer, with text for a log line and an X-Forwarded-For entry and key for a per-address bound), and examples/tls_echo logs it and bounds by it; examples/https_hello logs it (the lines are front.cho’s). What is not done is packages/http-server’s use of it: the X-Forwarded-For header, and the access log, are the gateway’s change after this one.
  4. Upstream connections in the same loop. The gateway’s own sockets to its upstreams go in the poller it owns (a byte-fed server has none of its own; https_hello’s loop.cho is the place), with hold/answer for the request waiting on the upstream (§10) and stream for the answer’s body. packages/http-request is blocking; a non-blocking client is the missing piece, as it was for cancho-pg. (Built: http-client.md, packages/http-client and examples/http_fetch_nb; what the gateway still needs of it is §10.7 there.)
  5. Upgrade. Upgrade: websocket and CONNECT need a connection to stop being parsed once the 101 is sent and become a byte tunnel: detach frees the HTTP side’s slot, but the bytes already buffered behind the request are the gateway’s to recover (head/body views end with the request).
  6. Two timeouts. Built, §12.8: head_ms is a deadline for a head (a trickle does not extend it) and body_ms the longest the server waits for more of a body; both answer 408 with a rule tag. idle is still the keep-alive connection’s. (It was: idle was one number for a keep-alive connection waiting for its next request and a client taking a minute to send a head.)
  7. ALPN. The engine selects a protocol (--alpn); the loop could offer http/1.1 and refuse h2, which it does not speak. Nothing is done with the negotiated value yet.
  8. More than 1,024 connections (max_connections) and more than one core (a second process on SO_REUSEPORT, not tried).
  9. A byte-fed program’s authority report lists conn_write and poll (§11.2). If the gateway’s reviewers want it without them, the package needs a second set of names whose rows have no socket in them; nothing has asked yet.

12. Streaming a request body, and Expect: 100-continue

Status: built (this section was committed as a design first, with two measured numbers; building it corrected it in the places marked Corrected, collected in §12.12; the tests, mutants and measurements are §12.13). It was the first item of §11.8 and server.md §6’s: a proxy forwarding an upload cannot buffer it. examples/https_hello has the upload routes, and cancho-gateway’s item 6 (a header timeout of its own) is §12.8.

12.1 What stopped it, measured

A request is handed to the application whole or not at all. produce waits until head + body is in the connection’s size-byte input buffer, and refuses what can never fit: tests/packages/http_server_bytes_test.cho pins it (POST with Content-Length: 1000 into size 128 is 413 request too large; a chunked body that outgrows the buffer is the same 413), and on the socket path the same code runs. Three consequences for a reverse proxy, each from that one rule:

  • an upload larger than size cannot be proxied at all (and size is memory times connections: input_budget caps the sum at 256 MiB);
  • a client that sends Expect: 100-continue (measured, curl 8.7.1 against a server that reads the head and then nothing: -T or --data-binary of a 3 MiB file sends it and then waits 1.0 s before sending the body anyway; a 10 KB body sends no Expect) cannot be told to go ahead or refused before it sends the body, so a refusal costs the whole upload and the proxy cannot say “no” to a 1 GiB PUT cheaply;
  • the only timeout is idle, one number for a keep-alive connection waiting for its next request and a client taking a minute to send a head or a body (cancho-gateway docs/design.md §5 wants a header timeout of its own, a 408; §11.8 item 6).

The per-byte cost of the machine this design needs. The chunked decoder below is a state machine run once over each byte, in place. Measured on this machine (Apple M4 Max, LLVM backend, cancho build --std; a 64,000-byte buffer, 4,096 passes = 262 MB, a two-state machine that drops every CR and copies the rest, byte by byte): 134 ms, 1.96 GB/s, 0.51 ns a byte; the same loop as a plain forward copy of the buffer within itself is turned into a memmove by LLVM (2 ms for the 262 MB), so a content-length body costs no copy at all and a chunked one costs about half a nanosecond a byte. The TLS path measured 83 MB/s (§11.7), so the framing is not where an upload’s time will go, and this design does not trade clarity for speed in it.

12.2 The shape: a pull, and it is the request’s ticket that pulls

Three shapes were considered.

  1. A callback per piece (serve(.., on_head, on_body_part, on_end)). Refused for the reason of §2, unchanged: a piece is a view of a buffer the loop borrowed itself, a function value’s type cannot name that region, and the application’s own state would have to be reachable from a closure the language does not have.
  2. next answering the same request again for every piece (the request in hand with a body that grows). The request in hand is one request per next, and a proxy has thousands of uploads in flight, each waiting on its own upstream socket; “in hand” has no place to remember which. It would also give body two meanings.
  3. The held request’s ticket (§10) pulls its own body. Chosen. hold already is “this request goes on while I do other things, name it with a ticket, and the server will not give out anything behind it”. An upload is exactly that: the application takes the head from next, decides (refuses it, or holds it), and then, whenever it can use bytes (its upstream socket is writable) it asks the ticket for the next piece. The views are borrows of the Server, as every view is.

So the API is five new calls on a ticket and one on the request in hand, and the existing calls do not change:

server.limits(srv, max_body, piece, head_ms, body_ms) -> int  // opt in: 0, or -1 for a bound under 1; before this, nothing below happens and §12.4's first row holds
server.streaming(srv) -> bool                                  // the request in hand has a body that is still arriving (or does not fit)
t = server.hold(srv)                                           // unchanged. For a streaming request it also lets go of the head: copy what you need first
server.proceed(srv, t) -> int                                  // "I will read the body": answers 100 Continue if the client asked for it and it was not sent. 1 sent, 0 nothing to send, -1 the request is over
p = server.body_part(srv, t) -> &[byte]                        // the next piece: at most `piece` bytes that have arrived and not been taken; empty when none now
n = server.body_take(srv, t, n) -> int                         // the application has used n bytes of the piece it saw: n, or -1 (n past the piece, or the request is over)
s = server.body_state(srv, t) -> int                           // 0 more is coming, 1 all of it has arrived (bytes may still wait in the buffer), -1 the request is over: stop
n = server.body_total(srv, t) -> int                           // bytes of the body received so far, decoded

The end of a body is body_state == 1 with body_part empty: no separate body_end call, because “all arrived” and “all taken” are two facts and a proxy that is slow to take needs both (it has seen the end of the stream and still owes the upstream a piece). body_state == -1 is the one answer for every way a request ends under the application’s feet: the server refused it (framing, size, a timeout), the connection went (a reset, the peer’s end in the middle of the body, detach), or the ticket was answered. The application finds out by asking, in the same loop that gives streamed answers what room they have (feed_streams in examples/https_hello/app.cho), because there is no callback to be told by; §12.9 says what the server has done by then.

Why opt-in (limits). A server that never calls it is, in every byte it reads and writes, the server of §11: the 413, the ignored Expect, idle as the only timer. The api and http_server tests and every consumer pinned to the store stay as they are. A server that calls it has told the loop what its bounds are, and the loop can then do what a bound makes possible (hand over a head early, refuse by Expect, time a head). The four numbers are bounds, so they are parameters and not defaults the package chose: max_body the most body bytes a request may have (the Content-Length, or the decoded total), piece the most body_part shows at once (clipped to size), head_ms and body_ms the two timers of §12.8.

12.3 When next hands over a head early

With limits, produce parses the head as before and then looks at the body:

the requestwhat next does
no body (Content-Length 0 or absent, not chunked)as ever
Content-Length above max_bodyrefuses 413 (body.too-large) now: before any 100, before the client sends a byte of it
Content-Length and the whole body is in the buffer, and head + body fit sizeas ever: head, parsed, body, respond. streaming is false
Content-Length and any of it has not arrived (or head + body do not fit size)hands over the head at once; streaming is true
chunked, and the framing in the buffer reaches the final CRLF (§12.4)as ever: the body is decoded in place, body is the whole decoded body, streaming is false
chunked, and it does nothands over the head at once; streaming is true
an Expect other than 100-continue (on any request)refuses 417 (expect.unsupported)

What a request that fits does, exactly. Without limits: everything it did. With limits: a request whose head and entire body are in the buffer when its head is parsed is handed over whole, with the same head, parsed, body and respond and the same bytes in the answer; what is different is a request that fits size and whose body arrives in a later read, which was held back until whole and is now handed over as soon as its head is complete. That is deliberate and is the only change: it is what lets the application see Expect before the client has sent anything, and an application that opted in already handles streaming. A fed-at-once request and the same bytes fed one at a time therefore reach the application in different forms; both are tested to produce the same body bytes and the same answer.

An application that wants no streaming for small bodies reads the body from body_part anyway: body_part, body_take and body_state work for a request that streaming called false too, once it is held (a whole body is “all arrived” at once), so one loop over pieces serves both forms. (A chunked body held whole is still in the buffer where it was decoded; the legacy chunk scratch decoded is no longer used by a server with limits: the decoding is in place, §12.4.)

12.4 Framing

Content-Length. The head’s length, parsed and refused by std.http as today (400 for a malformed, repeated-and-different or with-Transfer-Encoding length). The server counts: remaining = length - bytes already in the buffer. Input is never taken past the end of the body (room is min(free space, remaining), and the socket path reads only that many), so what follows a body on the wire is never mixed into it, and a request pipelined behind a streamed body is simply the next bytes the caller offers once remaining is 0.

Transfer-Encoding: chunked. std.http.parse accepts exactly chunked and refuses every other coding and a Content-Length beside it, as today. The decoder is a byte-at-a-time state machine whose state lives in the connection’s words, so a body fed one byte at a time is decoded once, not again from the start at every byte as std.http.dechunk does. It decodes in place: decoded bytes are written over the framing that carried them (the write position never passes the read position), so the buffer holds [decoded, not yet taken][nothing else] while a body is arriving, and a chunk of any size needs no buffer of its own. States: size (1 to 8 hex digits, leading zeros count), extension, the LF of a size line, data, the CR and LF after data, then after the zero chunk a trailer section and the final CRLF.

  • Chunk extensions are accepted and ignored (RFC 9112 §7.1.1: a recipient MUST ignore ones it does not recognise), within bounds: at most 256 bytes after the ;, each a tab or a visible ASCII or high byte, never a control character or a bare CR or LF (body.chunk-extension). std.http.dechunk refuses them; the gateway’s framing.chunk-extension does too. This server does not, because the framing is its own and an extension that cannot be told apart from data is not a smuggling vector when it is parsed by the same machine that finds the end of the chunk; an application that wants them refused can refuse them in its own code (it sees none: they are ignored here). A switch for refusing them was not built; nothing has asked for one.
  • Trailers are accepted, checked for bytes that could confuse a parser, and discarded. After the zero chunk: zero or more lines, each of bytes with no control character (a tab is allowed) up to a CRLF, then a blank line. Their name: value syntax is not checked, because nothing reads them: what matters is that no bare LF, NUL or CR hides in one. At most 4,096 bytes of them, counted from the first byte of the first trailer line to the final CR (body.trailer-too-large, 431); a malformed line is body.trailer. The application never sees them: a proxy re-frames the body it forwards, so a trailer is dropped as RFC 9110 §6.5.2 allows. body_state is 1 only after the blank line.
  • Bounds. max_body on the decoded total, checked when a chunk’s size line is read (size + total > max_body refuses at once, before the chunk’s bytes), so a hostile fffffff0 is refused without waiting for it. Framing overhead per chunk is bounded by the 8 digits, 256 extension bytes and four CRLF bytes, each chunk carrying at least one body byte: at most about 270 wire bytes for each body byte, and time-bounded by body_ms like any other byte.

Every refusal, with its rule tag and status. The tag is sent in an X-Rule header beside failure’s {"error": ...} (a server without limits sends what it sent before, with no X-Rule). All close the connection after the answer.

rulestatuswhen
body.too-large413Content-Length over max_body; decoded total over max_body
body.chunk-size400a size line with no hex digit first, or more than 8 digits
body.chunk-framing400anything but CRLF where CRLF belongs: a bare LF, a size line with something after its digits, data not followed by CRLF
body.chunk-extension400an extension over 256 bytes, or with a control character
body.trailer400a control character (a tab apart) in a trailer line, a trailer line or the final line not ended by CRLF
body.trailer-too-large431more than 4,096 bytes of trailers
timeout.head408§12.8
timeout.body408§12.8
expect.unsupported417an Expect header with any member other than 100-continue
head.too-large, head.malformed431, 400what §11.4 already refuses (a head that fills size; std.http’s refusals): tagged only when limits is on
(no answer) body.truncatedthe peer ends or the connection resets mid-body: nothing to answer to; the request is over (-1)

A refusal found while reading (framing, size) is recorded by input/step and answered by the next next: input has no heap to build an answer in and its effect row stays [] (a widening would break every caller). The request is over from the instant it is recorded (body_state is -1, room is 0), the answer is queued by the next that follows, and the connection ends once it is sent.

12.5 Expect: 100-continue

  • Only HTTP/1.1, only for a request with a body that is streaming. On a 1.0 request, on a request without a body, and on a request whose body had already arrived whole, it is ignored (RFC 9110 §10.1.1: the client has sent it, or has no body to wait to send).
  • The server never sends 100 Continue by itself. The application sends it by proceed(t), after it has decided it wants the body: after its route and its limits, after its upstream accepted the connection, after a quota check. A gateway that refuses by route or size answers the head with a final status and sends no 100, and the client does not send the body.
  • proceed writes HTTP/1.1 100 Continue\r\n\r\n into the connection’s output (it fits: out is at least 512 and nothing of this request’s has been queued), once. It answers 0 afterwards, if the request had no Expect, and if the application has already started its answer (stream was called): a 100 after the first byte of a final response is an error on the wire.
  • 417 for an Expect header any of whose values is not 100-continue (case-insensitively; 100-continue, x too), by next, before the application sees the request.
  • A client that sends the body without waiting (curl waits one second, others do not wait) simply has its body buffered up to size, taken by body_part as usual, and is never sent a 100.

12.6 Back-pressure in both directions

In. The rule of §11.4, unchanged and shared: a byte is read only when there is room for it. room(k) is the free space in the connection’s buffer plus the bytes the application has taken from the front of it (they are moved out of the way, once, by the next input that needs the room), for a streaming body bounded by what the body still owes (Content-Length) — and 0 while an answer is waiting to go out, while the connection is ending, and while a refusal is recorded. A piece the application has not taken therefore stays in the buffer, the buffer fills, room is 0, the caller stops calling tls.recv (or the socket path stops being watched), and the client’s TCP window closes. A proxy passes its upstream’s pressure to the client by simply not calling body_take. The most a streaming connection holds is the same size + out of §11.4 whatever max_body is; a 1 GiB upload through a 16 KiB buffer is 16 KiB of the server’s memory, which the live test measures.

Out. Unchanged too, and it applies while a body is still arriving: an answer waiting to be sent stops room, so a client that sends a body and does not read the response it is already getting (an early refusal, 100 Continue, a streamed echo) stops being read, and a stalled sender and a stalled reader are both timed (§12.8). body_take re-arms the socket server’s read interest (settle) when it makes room; a byte-fed caller finds the room by asking room.

12.7 Pipelining after a streamed body

A request behind a streamed body is held back for two reasons that are already the rule: nothing is handed out for a connection while a request is held (§10), and room never takes bytes past the end of a Content-Length body. For a chunked body, the machine stops at the final CRLF and the bytes after it are the next request’s, moved down to follow the body in the buffer. When the streamed request is answered, whatever of its body the application did not take is dropped (the body has fully arrived, or the connection is closing, §12.9) and the buffered next request is given out by the next next, without waiting for new input.

12.8 The timeouts

Both are in milliseconds, on the caller’s clock (the one given to attach, input, output, ready; wait reads its own). Both answer 408 (timeout.head, timeout.body) with the connection closing after, unless the application has already begun its answer, in which case the connection is closed with nothing more sent. They are in addition to idle, which is unchanged: a keep-alive connection with no request in progress ends by it.

  • head_ms: a deadline for the head, not an idle limit. It starts when next first looks at a request whose head is incomplete, and ends when the head is complete; a client that sends a byte every head_ms - 1 milliseconds does not extend it (that is the slow-loris shape: many connections, each held open by a trickle). It does not run while the connection has an answer waiting (the server is not reading then).
  • body_ms: the longest the server waits for the client to send more of a body. It starts when the head is handed over, restarts when a body byte arrives, when the application takes a piece, and when it says proceed; it does not run while the buffer is full and the application has not taken what is in it, nor while a client that sent Expect: 100-continue has not been told to go ahead, nor while an answer waits to be sent (the client is not reading): waiting for the application is the application’s to time (a gateway has its upstream timeouts), not the client’s fault. A client that sends one byte every body_ms - 1 milliseconds is not stopped by this alone; each such byte is progress by definition. The bound on that case is the application’s total deadline for the request, which hold always allowed (§10: “an application that wants a deadline answers with 504”), and max_body bounds the bytes.
  • The scan for expired timers runs at most every 50 ms, over the connection table. ready/wait do it; expiry only records the refusal (as in §12.4); next sends it.

12.9 Answering before the body has been read

The application may respond/answer a streaming request at any time: a refusal to the head, an 413 after counting bytes, an upstream that answered early. What the server does with the connection afterwards:

  • The body had all arrived (body_state 1): the untaken bytes are dropped and the connection carries on, keep-alive as the request asked.
  • The body had not started (an Expect request, no 100 sent, no body byte received or taken): the client was never told to send it, so the connection carries on keep-alive. If the client sends the body anyway its bytes are the next “request” and are refused as one (400), which closes; no worse than any client that pipelines garbage.
  • Otherwise the connection closes once the answer has gone (as if it had said Connection: close, whatever the application’s head said, because the server cannot rewrite an answer it was handed). The alternative, reading and discarding the rest, is a decision about someone else’s bandwidth that the application can take by taking the pieces and dropping them before it answers; the server does not drain by itself because a 1 GiB PUT refused at 100 MB would otherwise cost the server 900 MB of reading to keep one connection. The client may see a reset before it has read the answer if it is still sending when the connection closes: that is a property of closing a TCP connection with unread data, and the lingering close that avoids it belongs to the program that owns the socket.
  • While the answer is streamed (hold + stream) and the body is still coming, both flow (§12.6, “Out”); if the body then fails, the answer cannot be replaced by a refusal, so the connection is closed.

A refusal the server makes mid-body (§12.4, §12.8) while the application holds the request: the request is over (body_state -1, answer/stream -1), the refusal is queued unless the application had started its answer, and the connection ends. The application’s ticket is not reusable (§10’s generation).

12.10 The memory bound per connection

size bytes of input buffer, out bytes of answer, and 32 words of state (stride goes from 16 to 32): max * (size + out) and 256 bytes a connection, fixed at open/open_bytes, as §11.4. A streaming body adds nothing: it is the input buffer. The decoded scratch of the legacy chunk path is not used by a server with limits and is allocated as before (a socket server or one without limits still needs it).

12.11 One implementation for both paths

The socket path and the byte-fed path differ in where bytes cross a boundary (§11.3) and that is all they will differ in:

classify a head, Expect, refuseproduce, one function
count a body, run the chunk machineingest, called by step (socket) and input (fed) after they have put bytes in the buffer; one function
how much may be readreadable, called by step, settle and room
the timerssweep_timers, called by sweep_idle’s two callers (wait’s serve_events, ready)
hold, answer, deliver_to, end of request, body_*, proceedone function each, on the connection’s state

step (socket) reads at most readable bytes and calls ingest on what it got; input (fed) takes at most readable and calls ingest. The duplication test would refuse a copy of either.

12.12 What building found, and what it corrected

Corrected marks a sentence above that was changed in place. Each of these was found by a test, and the test is named.

  • enqueue could write past the ready queue (a bug in §11’s shared code, not in the new). answer ended a held connection’s turn with finish, which cleared its “queued” flag even when input had added an entry for it while it was held, so the next input queued it a second time; a caller that feeds and answers without calling next between (the new tests do; a socket server’s loop never) filled a queue of max entries with copies of one connection and the next store trapped. answer now keeps the flag. (test_a_length_body_larger_than_the_buffer_arrives_byte_for_byte, 3 uploads on a server with max 2.)
  • A request with no body, with limits, kept the previous request’s head length (classify returned before setting state[7..10]), so the connection’s next request began at the wrong byte. (test_requests_pipelined_behind_a_streamed_body_are_served_in_order.)
  • A socket server closed a connection whose buffer was “full” of bytes the application had already taken (step’s st[p] >= size check ran before the room body_take gave back counted). It asks readable now, and reclaim moves what is left to the front only when a read needs the room. Corrected: §12.6 said body_take frees the room; it makes it available, and the move happens at the next read. (uploads_arrive_byte_for_byte_over_sockets..., the first run.)
  • finish moved a connection’s buffer to the front without moving a streaming request’s body start with it, when the application left a streaming request in hand at the end of a round. (test_a_streaming_request_left_in_hand_when_the_round_ends_keeps_its_body.)
  • The body timer ran while a client that had sent Expect: 100-continue was waiting for the application’s decision, and did not restart when the application said proceed. It does not run then, and restarts. Corrected: §12.8 now says so. (test_a_stalled_body_times_out_but_a_slow_application_does_not.)
  • A refusal recorded by a timer or by end_input was sent by next, but examples/https_hello’s loop only moved a connection’s answer to its TLS slot when input had arrived for it, so a 408 sat in the server’s output for ever. The loop’s sweep pumps a connection that has something waiting (server.pending). (scripts/https_hello_test.py halfbody.)
  • examples/https_hello’s /echo needed its body whole, and a body that arrives in two reads is now handed over streaming (§12.3 says so; reading the existing http and pipelined cases, which send chunked bodies to /echo in several writes, showed the example would have refused them). The example holds such a request, leaves its body in the server’s buffer and answers when the body has all come, or refuses it with a 413 if the buffer fills first (answer_whole in app.cho): the recipe for an application that wants a bounded body whole. The other way, a server that waits for a body that fits, was considered and not built: it needs a “handed over late” state (the head stays valid while the body arrives) for little gain, since size is already the threshold between the two.
  • piece is not clipped to size: a larger one is the same, because the buffer holds no more. Corrected in §12.2. The clipping line was equivalent under every test.
  • The timers’ scan is at most every 50 ms, so a timer fires up to 50 ms after it is due (test_a_head_has_a_deadline_that_trickling_does_not_extend... pins it). Corrected: §12.8 said “at most every 50 ms” already; the tests are what made the consequence a sentence.
  • Not built: a switch that refuses chunk extensions; draining or lingering on an early answer; a per-route max_body (the application answers 413 itself); a counter of discarded trailer lines (word 28 is spare).
  • packages/http-server/server.cho was 1,990 lines of the 2,000 allowed when this was written (1,999 after §13: corrected), in cancho fmt’s form (formatting::the_repository_is_formatted holds it to that). The streaming code is about 640 of them. To fit, the refusal table is one string (rule), trailer lines are checked for control bytes but not for name: value syntax, and the chunk machine’s fixed-byte states share one function (want); std.http.hex_value is now pub so the server does not carry a copy (the duplication check found it). The next addition to this file needs the package split into two modules (Core and the loop in one, the streaming in another importing it): a change to how the package is published (its store is flat, and the locks of examples/api and tests/programs pin it), not to this one.
  • Found in examples/https_hello, not fixed here: a client that keeps sending bytes after the server has ended its connection makes the server reset a different, healthy connection (reproduced on main at #360: a Connection: close request, then bytes on the same socket, while another client is served). Its cause is not known; a task to find and fix it was filed. The new cases avoid triggering it (a stalled client stops sending when it is answered 408).

12.13 Tests, mutants and measurements

Without a socket (tests/packages/http_server_body_test.cho and http_server_body_flow_test.cho, with their helpers in body_support.cho; cancho test on both backends through conformance/http_server_body.rs): 22 tests.

testwhat it fixes
a length body larger than the buffersizes around the buffer (256) and the piece (64) up to 5,000 bytes, fed 1, 7, 64, 100 and 4,096 bytes at a time and pulled 1, 13 and 64 at a time: every byte, body_total, the answer, the connection serves after
a chunked body with extensions and trailers4 body sizes x 4 chunk sizes x extension/trailer combinations x 3 feed steps x 2 pull sizes (384 uploads), byte for byte
a request cut at every bytea length request, a chunked one with an extension and a trailer and a chunked one in one chunk, cut at every point of the request: the same body and one answer, whole or streamed
requests pipelined behind a streamed bodya 700-byte body (length and chunked) then GET and POST, fed 1 to 4,096 bytes at a time: bodies and answers in order
a request that fitsanswered with the same bytes with or without limits (five requests, HTTP/1.0 and Connection: close among them); a whole chunked body with an extension and a trailer is decoded (a server without limits refuses it) with the next request moved down behind it; a whole request held is read from its ticket
Expect100 sent once and only when the application proceeds; a refusal before the body sends no 100 and keeps the connection (and closes it once the client has started sending, or sent some of the body with the head); a refusal in hand without hold; 417 for four kinds of unsupported expectation; the token is case-insensitive
every fault18 chunked wires, a length over the maximum, a whole body over it, a head too large and a malformed one, each fed one byte at a time and at once, refused with its status, message and rule, the request over from the instant it is read, the ticket refused before and after the refusal is sent
the chunk machine against a corpus50 wires, accepted (with the body they decode to) or refused (with the rule), at two feed steps; the bounds on both sides of the line (extension 256 and 257 bytes, trailers 4,096 and 4,097, a body of exactly 100)
the timersthe head deadline (the boundary millisecond, a trickle, a scan within 50 ms of the last, a keep-alive connection that is not timed, the clock of the answer being taken); the body stall (the boundary, a body that has all arrived, a client told to wait, a full buffer nobody takes, an answer waiting, an answer begun)
back-pressurea piece not taken stops the reading; the room returns exactly as bytes are taken; an answer waiting stops it however much room there is
answering earlyall arrived (kept, the rest dropped, what follows served), part of a length or chunked body (closes), in hand, streamed both ways
the peer’s endmid-body (the request is over, nothing answered, what waits is dropped), an answer on its way is not, in hand
ticketstwo uploads at once, stale generations, bad tickets, detach
a streaming request left in handthe round ends with it unanswered and unheld: its body is where it was
no limitsnothing changes: the 413, Expect ignored, no X-Rule; the new calls refuse
random8 seeds: 40 requests (none, length, chunked) split and consumed at random, byte for byte; 8 seeds x 3,000 random operations with hostile fragments and bad tickets must not trap or break a bound

With sockets (tests/programs/server_upload.cho and conformance/http_server_upload.rs, in cargo test): 6 tests that build a server program from the package’s source and talk to it over loopback: uploads of a length and chunked body with 100 Continue, pipelining, keep-alive and four uploads at once; a refusal before the body, every fault by its rule, 417, an early answer; a client that stalls in a head or a body, and the others served; back-pressure (a slow application: the client’s writes block, the test asserts under 64 MiB of 256 were taken) and clients that leave; a server that visits connections late, so a fault wait found waits through turns in which the client sends more; and a connection that reads while its neighbour’s buffer holds untaken bytes.

Against real clients (scripts/https_hello_test.py, CI’s tls-assurance job; 18 of 18 on linux-aarch64 in Docker, curl 8.5 on OpenSSL 3.0.13), four new cases over TLS 1.3:

caseresult
uploadPOST /upload of 0, 1, 2, 16,383 to 65,537 bytes, 1, 16, 128 MiB and 1 GiB, each with a length and chunked: byte count and SHA-256 equal to what was sent, 2,338 MiB hashed at 142 MiB/s with both ends hashing; the server’s RSS 1,944 -> 2,060 KB over all of it; chunks of 1 to 1,000,003 bytes with extensions and trailers; an upload, a request and an upload pipelined in one write
expectthe 100 came 44 ms into an upload that was waiting for it; /upload/refuse answered 413 with no 100 and the connection served after; 417; --max-body refusing a length by its head and a chunked body while it was sent; curl -X POST -T file -H 'Expect: 100-continue' --expect100-timeout 10 uploaded 900,000 bytes in 0.06 s, and the same to /upload/refuse uploaded 0 bytes (%{size_upload}); openssl s_client uploaded 300,000
halfbodyhalf a head, half a length body, half a chunked body and a head a byte at a time: 408 with the rule at 1.05 to 1.11 s for --read-timeout 1000; 27 requests beside them, the slowest 41 ms; a steady upload that took 3 s was not timed out
mangled1,500 connections each with a mangled upload (flipped bytes, cuts, bad chunk sizes, long extensions and trailers, lengths that lie, Expect heads): the server alive after every one, RSS 1,792 -> 2,932 KB, and a good upload still hashes

macOS (aarch64, curl on SecureTransport so the curl part is skipped, and says so): the same cases, uploads to 128 MiB at 98 MiB/s. CI’s x86-64 run (the tls-assurance job on #369, GitHub’s ubuntu-latest, curl on OpenSSL 3.0.13): 18 of 18 cases; upload 2,338 MiB hashed correctly at 124 MiB/s with the server’s RSS 4,292 -> 4,420 KB; expect waited 43 ms for the upload with its 100, curl -T uploaded 900,000 bytes in 0.02 s and a refused one 0 bytes; halfbody answered 408 at 1.05 to 1.10 s with 27 requests served beside, the slowest 41 ms; mangled RSS 2,140 -> 5,244 KB over 1,500; both mutant runs (232 and the byte-fed 90) as stated; its cost step gave 4.42 microseconds of server CPU a request plain and 18.49 over TLS (a different runner from §11.7’s 5.59 and 23.40: the comparison that means something is the alternated one below).

Mutants. scripts/http_server_body_mutants.py (CI runs it): 232 single-edit mutations of the new code and of the shared lines that now call it, 215 killed and 17 argued equivalent in the script, none surviving. The first run killed 155 of 230 (12 old texts did not match, the rest survived); the third left 22 survivors, 19 of which are argued equivalent in the script and 3 needed tests (the clock that output keeps, a ticket asked after the refusal is queued and before it is sent, the message of every refusal); between them the tests added for survivors were the timer boundaries, a body that has all arrived or a client told to wait not being timed, Expect untouched or touched by bytes that came with the head, a request in hand looked at twice, and the corpus’s bare LF where a CR belongs. The mutants are compared as tokens, so cancho fmt’s layout does not matter. 9 more are of the lines only the socket path runs (python3 scripts/http_server_body_mutants.py ... --socket, by hand: each builds the server program): the first run killed 7 of 9, the two survivors needed a server that visits connections late and a slow neighbour, and 9 of 9 are killed. The byte-fed mode’s own 90 (http_server_bytes_mutants.py) still give 79 killed and 11 argued equivalent, as §11.5 says.

Cost. The socket path with no limits is the same: examples/api, kload, one core, three 5 s rounds, the CPU a request read from /proc (Docker Desktop’s Linux VM on an Apple M4 Max with other containers running, so the rates moved by 1.5x between rounds and the CPU is the figure to read): 3.01 and 3.24 microseconds before, 3.10 and 3.17 after. https_hello over TLS (tload), which now calls limits and so classifies every request: before 11.17, 9.16, 11.76 and 9.02 microseconds in four alternated runs (mean 10.3), the same example with the limits call taken out 10.52, 10.72, 10.07 and 10.32 (10.4), after 10.17, 9.85, 9.43 and 7.87 (9.3): no difference this machine can see. The memory a connection can make the server hold is as §11.4: size + out and 32 words (it was 16), whatever the body. Upload throughput is above (142 MiB/s on that VM with SHA-256 at both ends; 98 MiB/s on the Mac).

12.14 What cancho-gateway still needs

What the gateway can build on now, and what it still lacks, in the order it will hit them (§11.8 has the rest, unchanged except where marked):

  1. The proxy loop over a ticket. limits, then for a request streaming reports: hold, read the head and copy what the upstream needs before hold (it lets go of the head), proceed when the upstream has accepted (or the route’s policy has), and each turn body_part -> write to the upstream as far as it takes -> body_take what it took. An upstream that takes nothing is a client that is not read: it needs no code. body_state 1 and an empty body_part is the end; -1 means stop and drop the upstream. A chunked upstream request is framed by the gateway from the pieces (the server discards extensions and trailers). examples/https_hello/app.cho’s feed_uploads is the loop with a hash where the upstream write goes.
  2. Per-route bounds. max_body is the server’s one number. A route’s own is the application’s: answer 413 from the head (before any 100) or after counting. There is no per-route head_ms or body_ms.
  3. A request’s total deadline is still the application’s (§12.8): body_ms stops a client that goes quiet, not one that sends a byte every body_ms - 1 ms.
  4. No lingering close, no drain (§12.9): a request answered while its body is in flight closes the connection once the answer is sent; the client may see a reset before it has read it. A gateway that wants to keep the connection after refusing a body in flight takes the pieces and drops them before answering, up to its own bound.
  5. No readiness list. The application asks each ticket it holds (body_state/body_part) in the loop that already gives streams their room; with thousands of idle uploads that is thousands of calls a turn, each a few loads. A “tickets with news” list like foreign would be the change; nothing has asked.
  6. Chunked responses, the peer’s address, upstream connections in the same loop, Upgrade, ALPN, more than 1,024 connections, the second set of effect names: §11.8’s items 2, 3, 4, 5, 7, 8 and 9, unchanged. packages/http-request is being made non-blocking by another change.
  7. packages/http-server/server.cho has 1 line left (was 10 in §12.12; §13 took nine). The next thing added to it splits the package.
  8. examples/https_hello has a bug that predates this change (a client that keeps sending after its connection was ended resets another connection, §12.12) which a gateway on the same loop would inherit: filed.

13. An extra header without its CRLF, and what a refused one answers

The bug. failure_with(.., 405, .., "Allow: GET") – the header written as one writes a header, with no CRLF – died with SIGILL, and with it every connection (found building services with a model, cancho-code#58; two models wrote it that way, cancho#515). reply_with, reply_as, failure_with and reply_empty hand extra to std.http.respond_head_with / respond_no_content, which trap unless valid_extra(extra); the doc comments said the lines look like Allow: GET\r\n, and a missing terminator was a trap, not an error.

What valid_extra accepted (read before changing it): zero or more name: value lines, a name of token characters, a value of the characters a value may hold or spaces and tabs, each line ended by CRLF, and nothing else. conformance/http.rs::a_misused_router_or_response_traps_instead_of_going_on pinned five refusals: a bare LF in a line, a bare CR, a line with no colon, a blank line, and (extra-without-its-line-ending) a missing final CRLF.

The decision, two rules.

  1. The final CRLF is optional, in std.http itself: "Allow: GET" and "Allow: GET\r\n" give the same bytes, as do "A: 1\r\nB: 2" and "A: 1\r\nB: 2\r\n". A missing terminator is not a safety question – the writer supplies the one it needs – so it is the one case that moved from refused to accepted, and it moved for every caller, not only the server’s: one rule is easier to hold than “complete in the package, trap in the library”. A lone CR or LF at the end ("X: a\n", "X: a\r") is still a bare one and still refused, as are a NUL, a line without name: value and a blank line. extra-without-its-line-ending was removed from the trap list because its claim was what changed; the guard tests now include the trailing-bare-LF and trailing-bare-CR forms.
  2. What is still refused answers 500 in the server’s reply*, and the server keeps serving. reply_as (so reply_with, failure_with) and reply_empty test std.http.valid_extra (now pub) first and, on a refusal, answer 500 Internal Server Error, {"error":"internal error"} (std.http.respond_internal_error), no extra headers, on the keep-alive setting the caller gave. A header string is an application bug; it must not kill the process. std.http.respond_head_with and respond_no_content themselves keep trapping on a refused extra: they are the lower layer with a documented precondition, a direct caller who has not checked has the bug the trap is there to find, and the one caller that must not die (this package) checks first. A trap in the library and a 500 in the package are the same rule at two altitudes.

reply_empty with a status other than 204 or 304 and reply_as with a content type holding a CR, LF or NUL or a status outside 100-999 still trap: they are not header strings an application builds from a route, and this change did not widen to them (nothing has asked).

Tests. tests/lex/http_test.cho (both backends, through json.rs): the same bytes with and without the CRLF, on respond_head_with and respond_no_content; valid_extra against 16 inputs including a NUL; the fixed 500. conformance/http.rs: the trap list above, now with the trailing bare LF/CR. conformance/http_server.rs::an_extra_header_without_its_crlf_is_sent_and_a_refused_one_answers_500 runs a server with /bare (failure_with 405 Allow: GET), and a refused extra to reply_with, failure_with and reply_empty, pipelined on one connection followed by an ordinary request: exact bytes, and the process alive. Against origin/main it failed (empty read: the process died on the first request); the three mutations below each turned a gate red. The store was republished (scripts/publish_packages.py) and the locks of examples/api and tests/programs/server_hold.lock re-pinned.

Mutations (each applied alone, the gate run, then undone):

mutationred
the CRLF completion removed (extra_open always false)test_a_missing_final_crlf_on_extra_is_supplied; the server test (the 405 head runs into the body)
the 500 fallback removed from reply_as and reply_empty (store republished, locks re-pinned)the server test: the /bare answer arrives, then the process traps on /bad and the connection closes
the guard loosened to take a bare LF as a line endtest_valid_extra_refuses_everything_unsafe, a_misused_router_or_response_traps_instead_of_going_on (extra-bare-line-feed), the server test

http.client: a non-blocking HTTP/1.1 client for a program with one poller

Status: built. The design was written, and committed, before the code (first commit of this PR); §2 was measured or read in this repository on that day, §3 to §8 are the decisions, §9 the gates stated before their numbers existed, and §10 is what was built, what the build corrected in the sections above (each marked corrected), the numbers against the gates, and what the gateway still lacks. §11 is the open questions for a person, each with the answer this PR builds on.

docs/http-server.md §11.8 lists what cancho-gateway (an HTTP/1.1 reverse proxy that terminates HTTPS) still lacks, in the order it will hit them. Item 4 is this one: “Upstream connections in the same loop … packages/http-request is blocking; a non-blocking client is the missing piece, as it was for cancho-pg.” The gateway’s own docs/pool.md shows what it does without one: a client, a connection pool and a response framer written inside src/proxy.cho (1,448 lines), over plain TCP only, with its own response-head parser (src/response.cho, 336 lines), its own pool, and no upstream TLS, no upstream names (its §6: “Upstream host names … a blocking resolver”). cancho-hooks (webhook delivery) has the same pieces a second time (docs/design.md §53: keep-alive, strict framing, the retry on a reused connection) in src/attempt.cho. Two programs have written this; the repository’s bar for a package is two askers (docs/standard-library.md), and this is the third time it is asked for.


1. The question

A program with one thread and one Poller wants to make many HTTP/1.1 requests in the same loop that serves its own clients: keep several connections to each upstream open, send a request on one without waiting for the one before, stream a request body that is arriving from somewhere else, stream the response body to somewhere else, reuse a connection afterwards when that is safe, and never hold more memory than it chose at start. It must do this over plain TCP and over TLS 1.3, and it must be testable without a socket.

What the language allows constrains the shape (AGENTS.md §8): no function values, so no callbacks; no polymorphism over effects; a resource (Conn, Engine) cannot sit in a generic container, so connections stay in std.conns by ticket; an arena is one 64 KiB chunk and traps when full, so buffers are heap boxes sized when the program starts.


2. What exists, and what it is worth

piecewhat it giveswhat it does not
packages/http-request (141 lines)read_request: reads one request from a blocking socket, Content-Length found by a naive substring search, streamed to standard output. Needs Ffi("libc") through net.socketsit is a server-side helper that happens to have the name; it parses no response and sends nothing
packages/http-response (61 lines)send_all (blocking write loop) and status_of (three digits of a status line)nothing about headers, framing, reuse
examples/fetcha blocking GET, the reference for “a cancho client”, libc socketsone request at a time; it waits
std.httpa strict request parser into an integer table (parse), dechunk (a whole chunked body in one call), response writersno response parser; dechunk needs the whole body in memory, so it cannot stream
std.connsTable of connections by slot, read/write/watch/connect_status, nonblocking, nodelay; with tcp_connect_start a non-blocking connect to an IP literal (a name stalls the loop: native-sockets.md §10.6)
packages/tlsa client Engine: start, feed/take/send/recv (bytes in, bytes out, no socket inside), the chain verified against roots the caller gave, the name and the time given at start; tickets for resumptionone engine is all clients or all servers; one slot is 269,439 bytes (measured: tls_client.ints_len() is 10,245 words, 81,960 bytes, and bytes_len() 187,479 bytes; corrected in place, #386: 269,119 before ALPN, the client identity and its hedge took four words and 288 bytes), so 64 TLS connections are 17 MB
packages/http-server byte-fed modethe shape to mirror: attach/input/output/room/closing/detach, the clock an argument, every buffer fixed at open_bytes
examples/tls_nb/rtcp.chortcp.Resolver: DNS over TCP as a state machine on the caller’s poller (a lookup does not stop the loop: a 2 ms gap against 302 ms for getaddrinfo, tls-nonblocking.md §7)it is an example file (with dns.cho), not a package, and it is Net("")-only
tests/programs/tls_many.choa client of packages/tls on one poller: connect, handshake, request, read to close_notifyone request per connection
https_hello, tls_echothe loops: socket -> tls.feed -> tls.recv -> rcv -> server.input and back, each stage reading only when the next has emptiedserver side

So the engine’s bytes-in/bytes-out shape already exists on both sides of TLS and for the server’s HTTP; the client’s HTTP is what is missing, and the pieces around it (connect, DNS, TLS) are in the repository as examples and engines, not as one thing a gateway can call.

2.1 What the two programs that wrote it agree on

Read from cancho-hooks docs/design.md §53 and cancho-gateway docs/pool.md and src/response.cho (the rules, not the code):

  • One reading of the framing, or a refusal. A response head over a bound, a malformed status line, obsolete line folding, a repeated Content-Length (even with equal values), Content-Length with Transfer-Encoding, a Transfer-Encoding that is not exactly chunked: each is a refusal with a tag (the gateway’s are response.status-line, .version, .status, .header, .fold, .length, .two-lengths, .transfer-encoding, .head-too-large; this client uses the same names).
  • A connection is reusable only if the response is HTTP/1.1; there is no Connection: close; the body is framed (a length, chunked, or none: HEAD, 1xx, 204, 304), not until close; nothing is left unread on the connection; and the request’s own body was fully sent. Anything else closes it after the outcome.
  • One retry, on a reused connection only, before any response byte, on a new connection (a pooled connection can be closed by the peer at the instant a request is sent). The hooks retries any request, because its events are at-least-once; the gateway only an idempotent method with no body, because it cannot know. That difference is policy, and the client takes it as a per-request flag (§3.5).
  • Bounded in time: idle connections expire (30 s in hooks, 5 s in the gateway’s default), and a connection is retired after a number of requests or a lifetime (hooks: 1,000 requests, 5 minutes) so a change of an address is honoured and none lives for ever.
  • A connection idle in the pool is watched for readable, and a byte or an end on it closes it at once.

3. Decisions

3.1 D1: a new package, http-client; http-request is not touched

packages/http-request is a blocking, server-side reader that depends on net.sockets (and so on Ffi("libc")), is consumed by two examples (collect, agent_supervisor) and is pinned by locks; its name suggests it is the client and it is not. The blocking fetch-style API keeps working unchanged and nothing here edits it. Putting a non-blocking client in the same module would bring Ffi into a report that is otherwise bounded; a new package has no Ffi, no socket and no capability in it but the Heap its buffers are allocated from (§3.2). (Corrected: “no capability at all” as first written; open and close take the heap.)

The package has three modules, split by concern (and so that none nears 2,000 lines): http_client_slot (the names of the words of a connection’s slot and the numbers they hold), http_client_wire (what is on the wire: validating and writing a request head, parsing a response head, the incremental chunk decoder; pure functions over slices and integer tables) and http_client (the table of connections, the state machines, the pool, the timers, the events). The store layout is packages/tls’s: one store per module under packages/http-client/.cancho-vcs/, published by scripts/publish_packages.py. (Corrected: two modules in §3.1 as first written; http_client reached 1,988 lines and the slot layout moved out of it.)


3.2 D2: the shape: a table of N connections, bytes in and out, events returned

The package performs no I/O and reads no clock. It is a sans-I/O state machine in the shape http.server’s byte-fed mode has: the caller owns the sockets, the TLS engine and the poller; the package owns one slot per connection and tells the caller what to do.

var c = http_client.open(heap, slots, in_size, out_size, limits);   // everything allocated here
t = http_client.request(c, now_ms, key, "GET", "/x", "host", "", body_none(), flags);   // a ticket, or a refusal
match http_client.poll(c, now_ms) {                 // one event, or Event::None
    Event::Connect(k)  => { /* dial key(c,k); then connected(c,k,now) or connect_failed(c,k,kind,now) */ }
    Event::Head(t)     => { status(c,t) headers ... }
    Event::Body(t)     => { b = body(c,t); ...; consume(c,t,len(b),now); }
    Event::Done(t)     => { ... }
    Event::Failed(t)   => { failure(c,t) -> a code; refusal_tag(code) }
    Event::Close(k)    => { /* close the socket (and the TLS slot), then detach(c,k) */ }
    ...
}
n = http_client.take(c, k, out, now);   // request bytes for connection k's transport (tls.send, or the socket)
n = http_client.give(c, k, data, now);  // response bytes from it; as many as `room` allows, the caller keeps the rest

Why a result enum and not callbacks: the language has no function values (AGENTS.md §8), and a callback is the shape that did not type-check for the server (http-server.md §2). Why a ticket (request) and a slot (transport): a request is addressed by ticket = generation * 4096 + slot so a stale holder (a client that went away, a retried request) cannot read or write the next request that reuses the slot; the transport belongs to the slot, so take/give/eof/detach take the slot, as the server’s attach/input/output/detach do. One slot carries one request at a time (no pipelining of requests: it is the part of HTTP/1.1 that proxies and servers disagree about, and neither the gateway nor hooks uses it).

Time is an argument. Every call that moves a byte or looks at time is given now_ms (any monotonic milliseconds). The caller reads its clock once a turn, as https_hello’s loop does. A fake clock in a test is an integer.

Back-pressure is a number, the server’s rule. room(c,k) is the bytes of response the client will take (the in-buffer’s free space; 0 while the caller has not consumed the body), send_room(c,t) the bytes of request body it will take, pending(c,k) the bytes waiting for the transport. A caller reads the socket only for room, so a caller that does not consume a body stops the upstream (TCP’s window), and a caller that is slow to take request bytes stops the client’s upload, never grows a buffer.

Memory is fixed at open: slots * (in_size + out_size) bytes, slots * 48 words of state and slots * 272 words of parse table, all allocated there; nothing is allocated per request or per connection.

3.3 D3: the request side

request(c, now_ms, key, method, target, host, extra, body, flags) validates and writes the head into the connection’s output buffer: METHOD SP target SP HTTP/1.1 CRLF Host: host CRLF [extra] [Content-Length | Transfer-Encoding: chunked] [Expect] [Connection: close] CRLF.

  • The client alone writes the framing. extra is the caller’s header lines, each token ":" value CRLF; it is refused (request.header) if a name is not a token, a value holds a control byte, or the name is one the client owns: Host, Content-Length, Transfer-Encoding, Connection, Expect, Upgrade, TE, Trailer, Keep-Alive. A caller cannot make this client emit a request with two framings, which is the request-smuggling shape std.http refuses on the way in. (A proxy that forwards a client’s headers strips the hop-by-hop ones itself, cancho-gateway docs/headers.md; the client refuses what it must not send rather than hope.)
  • Method is a token and not CONNECT (request.method); target is origin-form (starts with /) or *, visible ASCII, no # (request.target); host is a non-empty reg-name, IPv4 or bracketed IPv6 up to 255 bytes (request.host).
  • The body is body_none() (no body headers), n >= 0 (Content-Length: n; 0 is sent for POST, PUT and PATCH and omitted otherwise) or body_chunked() (Transfer-Encoding: chunked, the client frames each piece). The caller gives it with send_body(c, t, bytes, now), which takes as many bytes as send_room allows and returns how many, so a client’s upload can be piped to the upstream with back-pressure: the gateway reads at most send_room from its client’s socket, and when the number is 0 stops reading it. For a length body the client refuses bytes past the length (request.body-too-long, -1); for a chunked body end_body(c, t, now) writes the last chunk. Bytes given with the head in one request call are not special: the head is the first thing in the buffer and send_body appends.
  • Expect: 100-continue (flags bit 2): the head goes out, send_room is 0 and the bytes of the body wait until a 100 Continue arrives (Event::Continue) or expect_ms passes (RFC 9110 §10.1.1: a client does not wait for ever; Continue is raised then too, continued_by_timeout says which), or a final response arrives instead (the body is never sent, §3.4).
  • The head must fit in the output buffer or the call is refused (request.too-large); the caller sizes out_size for its largest head plus one piece of body. No input reaches a trap: every length is checked before any index.

3.4 D4: the response side

Status and headers are parsed as soon as the head is complete, into the connection’s integer table (std.http’s shape: offsets into the buffer, so no copy), and Event::Head(t) is raised; status, version, header_count, header_name, header_value, header (find by name) and head (the raw bytes) read it. They are views of the client, valid until the first consume. Then:

  • Informational (1xx, except 101) is skipped, never shown: the next head is read from the same bytes. 100 raises Event::Continue once; at most 8 are accepted per response (response.too-many-informational). 101 is refused (response.upgrade): the client does not tunnel, and a 101 that a request did not ask for is a protocol error. Upgrade and CONNECT are the gateway’s item 5, a separate slice (§8).
  • Body framing follows RFC 9112 §6.3 and the rules of §2.1: none for a HEAD request and for 1xx, 204, 304; chunked (decoded: the caller sees the same bytes whatever the framing; body_kind says which it was); Content-Length; otherwise until the connection ends. Chunk-size lines are strict (hex digits only, no sign, no overlong), an extension after ; is skipped up to 128 bytes, the line ends CRLF exactly, and trailers are skipped up to 8 KiB (response.chunk, response.trailers-too-large); a length past 2^50 is refused (response.length), as std.http does.
  • The body is delivered in pieces: Event::Body(t) says bytes are waiting; body(c,t) is a view of them (decoded in place for chunked, so one copy in from the socket and none more inside the client); consume(c,t,n,now) takes n of them off. The caller owns what it does not consume (avail). The in-buffer is both the head’s room and the body’s, so a head must fit in in_size or the response is refused (response.head-too-large, 16,384 at most whatever in_size).
  • Event::Done(t) is raised after the last byte was consumed, not after it arrived, so the buffer is empty when the connection returns to the pool. A response that is complete and unconsumed holds its slot.
  • A response that arrives before the request is fully sent (a 413 to an upload, a 401): the final head is delivered as usual, request_complete(c,t) is false, the unsent request bytes are dropped (send_body answers -1: request.response-arrived), the response is read to its end, and the connection is not reused (the upstream may still expect the rest of the body, so it is closed after the response, as the gateway does).
  • Close-delimited bodies end when the transport ends cleanly (eof): that is Done, not a failure, and the connection is closed. A length or chunked body cut short by eof is client.truncated.

3.5 D5: pooling, reuse, and the one retry

Keyed by an integer the caller chooses (key, a non-negative number: an upstream’s index, an endpoint slot). The client does not know addresses; the caller’s table maps key to an address, a port, a scheme and a name, so a connection can never be given to another upstream, and the caller can change what a key means by retire(c, key) (every idle connection of the key is closed; active ones finish and are not pooled), which is cancho-hooks §53.5 as one call.

A connection that has finished a response goes idle in its slot when all of these hold, else it is closed after the outcome (Event::Close, close_reason says why): HTTP/1.1; no Connection: close token in any Connection header of the response, and none sent; framed by a length, chunked or no body (never until close); every byte of the body and nothing after it consumed; the request fully sent; no early response; fewer than max_requests requests carried; younger than lifetime_ms. HTTP/1.0 is never reused (hooks); that is stricter than the gateway, which reuses 1.0 with keep-alive, and it costs nothing a modern upstream notices.

request takes the most recently used idle connection of the key; with none, a free slot; with none, it evicts the least recently used idle connection of another key (that connection’s transport is closed by the caller on Event::Close, and the new request connects in the same slot when the caller has detached it); with none of those, it refuses client.full, which is backpressure the caller owns (queue and retry after the next Done or Failed). Per key at most idle_per_key connections stay idle; the others are closed when they finish. A request whose key has an idle connection is already sent when request returns: the head is in the output buffer and pending is its size, no Event::Connect is raised.

While idle, the caller keeps the transport watched for readable (for TLS: it keeps calling give with whatever arrives). A byte or an end on an idle connection closes it at once (close_reason unsolicited); the package never lets such a byte reach the next request’s response. tick expires idle connections after idle_ms and any connection lifetime_ms after it was made.

The retry (hooks §53.4’s rule, and the only automatic one): when a request on a reused connection fails with the transport ended (eof), reset, or a write failure before any response byte, the client closes that transport and replays the request on a new connection, once, under three conditions: the caller allowed it (flags bit 1: hooks passes it for every request, the gateway for an idempotent method with no body only, since it alone knows), the request has not used a retry yet, and the request is replayable: everything the client sent still fits in the output buffer, which it keeps until the first response byte (a streamed body that has outgrown out_size stops being replayable, and the client says so by failing instead of retrying). A retried request shows no event (attempts(c,t) is 1); a failure on a new connection is not retried; a timeout is never retried (the request may be in progress). The request can reach the upstream twice when the peer processed it and closed without answering: the same at-least-once caveat hooks states in §53.4, and the reason the flag is the caller’s.

3.6 D6: timeouts, on the caller’s clock

Five numbers in Limits, each in milliseconds, each 0 for none, each checked by tick(c, now) (which the caller calls once a turn) and each findable in advance by next_deadline(c, now) (so the caller’s poller_wait can be bounded by it: a timer that nobody wakes for is not a timer):

limitrunsends as
connect_msfrom Event::Connect to connected (the handshake of TLS included: the caller calls connected when the transport is ready for bytes)client.connect-timeout
head_msfrom the request being fully sent, with no byte in or out for this long, while no head has arrivedclient.head-timeout
body_msbetween pieces: no byte in for this long while the caller has nothing waiting unconsumedclient.body-timeout
total_msfrom request to Done, the whole exchangeclient.total-timeout
expect_msan Expect: 100-continue head with no answerEvent::Continue (not a failure)

A time the caller owns is not charged to the upstream: while the request is waiting for the caller’s next piece of body, or the caller has not consumed what arrived, neither head_ms nor body_ms runs (consume and send_body restart them). total_ms is the bound on both. idle_ms and lifetime_ms (§3.5) are the pool’s.

3.7 D7: TLS to upstreams

The client is plaintext-in, plaintext-out: it knows nothing about TLS, exactly as http.server’s byte-fed mode does not. What TLS needs is the caller’s, in the order https_hello and tls_many do it:

  1. Event::Connect(k): the caller dials the upstream’s address (tcp_connect_start, an IP literal) and, when the poller reports it writable and conns.connect_status says 0, calls tls.start(engine, k, name, now_unix_ms) on the client engine it opened with the roots it was given (tls.trust(pem): the caller’s trust store, nothing built in), pumping feed/take until tls.event(engine, k) is established.
  2. The name is verified by tls.start’s host argument, which is the name the upstream’s certificate must carry; the address and the name are independent (the caller dialled an address it had resolved or been given; the certificate is checked against the name), which is what makes pinning an address possible (tls-nonblocking.md §3.8).
  3. Only then does the caller call http_client.connected(c, k, now); from then on take plaintext goes to tls.send and tls.recv plaintext goes to give. A failed handshake is connect_failed(c, k, kind_tls(), now) (client.tls; the engine’s own refusal tag, tls.refusal_tag(tls.failure(engine, k)), is the caller’s to log).
  4. A connection that the client says to Close gets tls.finish (close_notify) and then the socket’s close and tls.drop. An idle pooled TLS connection costs its 269,439-byte engine slot, so slots bounds TLS memory directly: 16 TLS connections are 4.3 MB.

The driver is built in this PR (examples/http_fetch_nb/fetch_io.cho): poller, std.conns, the dial, the TLS pump and the client table in one loop, in the shape of tls_many’s advance/flush. It is an example module and not a package, for the reason tls_echo/front.cho is: the second program that needs it moves it. The package itself stays free of Net, Poller and packages/tls, so its authority report is the heap and nothing else, and an existing store that imports it gains no Ffi and no tls.

3.8 D8: DNS is the caller’s

The caller resolves and gives an address. Event::Connect(k) names a key, not a host, and the caller’s table says what to dial. The reasons: (1) tcp_connect_start takes an IP literal without a lookup, and a name makes it call getaddrinfo and stall the loop (measured, 302 ms for a 300 ms answer: tls-nonblocking.md §7); (2) the gateway has a fixed upstream set, so a name is resolved at start or on a slow timer, not per request; (3) rtcp.Resolver is an example, not a package, and a package cannot depend on an example file; (4) address pinning, and the check an SSRF rule wants, are only possible when the caller holds the address. A program that needs a lookup per request uses rtcp.Resolver on the same poller (it is non-blocking), from its Connect handler: it reports a failed lookup with connect_failed(c, k, kind_resolve(), now) (client.resolve) and the client treats it as a failed connect. The example’s --resolve name=ip is the caller-resolves case; rtcp is not wired in (§8).

3.9 D9: SSRF is not this package’s concern

The client dials nothing: it never sees an address. A program that forwards a client-chosen destination (an open proxy, a webhook sender) must judge the address before it answers a Connect (cancho-hooks design.md §26, examples/tls_nb/pin.cho); the gateway has a fixed, compiled-in upstream set and its authority report says net_out(""). This package cannot widen what a program reaches, because it reaches nothing but its own heap: its authority report is heap (cancho authority, §9, pinned in conformance/http_client.rs).

3.10 D10: bounds

Every number is fixed by open’s arguments and has a refusal: slots 1 to 1,024; in_size 2,048 to 16 MiB; out_size 1,024 to 16 MiB; a response head at most 16,384 bytes and 64 headers (response.head-too-large, response.header); at most 8 informational responses; a chunk-size line at most 128 bytes; trailers at most 8 KiB; a body of any size, since it is streamed; idle_per_key, max_requests and the five timers of §3.6. The only counters that can grow without bound are the per-slot generation and request counters; the generation is reduced modulo 2^40 so a ticket (generation * 4096 + slot) is never negative and cannot overflow, and a request count is compared with max_requests and reset when the connection is.

3.11 D11: every refusal has a tag

A refusal is an integer code and refusal_tag(code) is its tag, as tls.refusal_tag is. §6 lists all of them. Three families: request.* (what request/send_body refuse: the caller’s mistake, answered at the call), response.* (what the upstream sent: the gateway’s names), and client.* (what happened to the connection: connect, timeouts, ends). A failure is Event::Failed(t) and failure(c,t) is the code; Close(k) follows for the transport.


4. The API

(As built, §10.2 lists the differences from the design.)

pub res struct Client
pub struct Limits { connect_ms, head_ms, body_ms, total_ms, expect_ms, idle_ms, lifetime_ms, idle_per_key, max_requests }
pub enum Event { None, Connect(int), Continue(int), Head(int), Body(int), Done(int), Failed(int), Close(int) }

open(heap, slots, in_size, out_size, limits) -> Client          // out-of-range numbers are clamped into range
close(heap, client) -> int                    default_limits() -> Limits
request(c, now, key, method, target, host, extra, body, flags) -> int           // a ticket > 0, or 0 - code
send_room(c, t) -> int      send_body(c, t, bytes, now) -> int      end_body(c, t, now) -> int
poll(c, now) -> Event       tick(c, now) -> int                     next_deadline(c, now) -> int
connected(c, k, now)  connect_failed(c, k, kind, now)  take(c, k, out, now) -> int  pending(c, k) -> int  room(c, k) -> int
give(c, k, data, now) -> int   eof(c, k, now)   reset(c, k, now)   detach(c, k, now)
key(c, k)  ticket_of(c, k)  close_reason(c, k)  close_reason_tag(r)  ticket_slot(t)
status(c, t)  version(c, t)  body_kind(c, t)  content_length(c, t)  header_count(c, t)  head(c, t)  header_name(c, t, i)
header_value(c, t, i)  header(c, t, name)  body(c, t) -> &[byte]  avail(c, t)  consume(c, t, n, now) -> int
request_complete(c, t)  reused(c, t)  attempts(c, t)  continued_by_timeout(c, t)  failure(c, t)  refusal_tag(code)
abort(c, t, now)  retire(c, key, now)  close_idle(c, now)  idle(c)  active(c)  free_slots(c)  slots(c)
connects(c)  requests(c)  reuses(c)  retries(c)    body_none()  body_chunked()  flag_retry()  flag_expect()  flag_close()
kind_connect()  kind_tls()  kind_resolve()

A turn of a caller’s loop is: read the clock; poll until Event::None, acting on each event; for every transport that is readable and has room, read and give; for every slot with pending, take and write (watch it for writable only while pending is not 0); tick; wait for the poller up to next_deadline. The example’s fetch_io.cho is exactly that.


5. What the unit tests can do without a socket

The client is bytes in, bytes out, and a clock that is an integer; that is the point of the shape. A test is a fn test_* run by cancho test on both backends (tests/packages/http_client_test.cho, pinned by conformance/http_client.rs) that drives a client as the caller’s loop would and plays the upstream by hand:

  • every response framing (length, chunked, until close, none for HEAD/204/304/1xx), each fed split at every byte and in pieces of 1, 2, 3 and 7 bytes, and the same bytes in one piece, with the body checked byte for byte;
  • every refusal with its tag (each response.* and each request.*), including the heads that are one byte off a valid one;
  • 100 Continue and other 1xx skipped, Expect held and released (by a 100, by the timeout, and by a final response);
  • a response before the request is sent;
  • pooling: reuse of the most recent connection, a different key getting its own, Connection: close, HTTP/1.0, close-delimited, leftover bytes, eviction of another key’s idle connection, idle_per_key, retire, expiry by idle time and lifetime and by request count; the one retry, on a reused connection before any response byte, with the flag and without it, once and not twice, not after a response byte, not on a fresh connection, not when the request has outgrown the buffer, not on a timeout;
  • timeouts on a fake clock: each of the five at the millisecond they fire and the millisecond before, and that a caller’s own slowness is not charged;
  • back-pressure: room 0 while a body is unconsumed, a body bigger than in_size flowing through a small buffer, an upload bigger than out_size flowing through a small one, a tiny out_size that refuses a head;
  • a seeded random run of tens of thousands of operations (any call, in any order, with any arguments, including stale tickets and slots out of range) that must never trap and never break a bound.

TLS is tested through the driver, not on its own: the client and the repository’s own server engine (examples/https_hello, on packages/tls’s server) meet in conformance/http_fetch_nb.rs and in the live hello case, and OpenSSL (Python’s ssl) in the live tls case. (Corrected: §5 first promised a test that connects a client Engine and a server Engine back to back in memory (tests/programs/http_client_tls.cho); it was not built, because the example’s driver is what has a TLS boundary to test and it is covered against two different servers.)


6. Rule tags

request.method, request.target, request.host, request.header, request.length, request.too-large, request.key, request.body-too-long, request.response-arrived (send_body after an early final response), request.no-body, request.ticket, client.full; response.status-line, response.version, response.status, response.upgrade, response.header, response.fold, response.length, response.two-lengths, response.transfer-encoding, response.head-too-large, response.chunk, response.trailers-too-large, response.too-many-informational; client.connect, client.tls, client.resolve, client.connect-timeout, client.head-timeout, client.body-timeout, client.total-timeout, client.closed-early (the transport ended before any response byte), client.truncated (it ended inside a head or a body), client.reset (it failed), client.aborted (reserved: an abort reports nothing). Close reasons (not failures) are numbers with names too: connection-close, not-reusable, idle-expired, lifetime, retired, evicted, unsolicited, peer-closed, failed, retry, aborted, max-requests, pool-full.


7. Cost, stated as a prediction to be checked

http-server.md §11.7 measured the server side: 5.6 µs of CPU a request plain (examples/api) and 23.4 µs over TLS 1.3 on CI’s x86-64 core, 2.7 µs and 9.8 µs on the aarch64 VM. The client does the mirror work: one parse of a response head (about the same bytes as a request head), a copy in, a copy out. Prediction: the client alone, against a local server, is the same order, 5 to 15 µs a request plain, and a TLS keep-alive request is that plus the record work (about 18 µs on the x86-64 core, §11.7’s difference), so 25 to 40 µs. §9 states the gate; the numbers are in §10.5. cancho-hooks §53.1’s arithmetic (a kept TLS delivery 30 to 40 µs) is the same figure. (Corrected: the prediction was high. Measured on the aarch64 VM, the client costs 2.9 µs a request plain and 7.5 µs over TLS, and 5 to 6 and 11 on the Mac; §10.5 has the x86-64 figures from CI. The estimate added the server’s TLS surcharge to a client that does less than the server: it has no routing, no answer to build and, in fetch_io, no JSON.)


8. What is not here

  • Upgrade and CONNECT (101 refused; the gateway’s item 5). A connection that becomes a tunnel needs detach plus the bytes already buffered behind the head handed over; the client has them (avail), but the hand-over is not built.
  • rtcp wired in. DNS is the caller’s (§3.8); an example that resolves on the poller is a follow-up once a second program wants it.
  • HTTP/2, trailers delivered to the caller, request pipelining, a connection per request on a Connection: close upstream faster than the pool allows, proxies (absolute-form targets), Content-Encoding, redirects, cookies, retries of anything but §3.5’s.
  • TLS session resumption in the driver. tls.save/start_with exist; the example does a full handshake per new connection. The pool makes that rare (a handshake per connection, not per request) and §10 measures what it costs.
  • More than one core.

9. Gates, stated before the numbers

  1. Unit tests of §5 pass on both backends; every refusal tag of §6 is reached by a test that names it.
  2. Mutants of the new code (scripts/http_client_mutants.py, as http_server_bytes_mutants.py does): each killed, or argued equivalent in the script; a survivor that is not equivalent gets a test.
  3. Live, over plain TCP and TLS 1.3, against a Python server on raw sockets, so that every byte it sends is its own (keep-alive, chunked, close-delimited, slow, early responses, Expect, damaged responses), examples/https_hello (this repository’s own TLS server on http.server), and nginx where it is installed (curl’s test server was not used: nginx ends connections the way a real upstream does): scripts/http_client_test.py. Connections are counted at the server: 1,000 requests to one upstream make no more than slots connections plus the retries a test forced.
  4. No input reaches a trap: the random run, a hostile-upstream run (a million mutated responses through the parser, split at random), and cancho check of the package with the checker’s own bounds.
  5. Cost: requests a second and CPU a request through the client against a local server, plain and TLS 1.3 keep-alive, on one core, with the figures that http-server.md §11.7 uses for its comparison.
  6. Authority: the package’s own report is the heap and nothing else; the example’s report is pinned in conformance/http_fetch_nb.rs as tls_echo’s is.
  7. cargo fmt --all --check, cargo clippy --all-targets -- -D warnings, cargo test --workspace, and scripts/publish_packages.py --check; CI’s tls-assurance job runs the live tests.

10. As built

Built in this PR; the sections above are corrected in place where the build differed, each marked corrected.

10.1 What is in the repository

packages/http-client/three modules, published as stores by scripts/publish_packages.py like packages/tls: http_client_slot (285 lines: the names of the words of a slot and the numbers they hold), http_client_wire (1,105: validating and writing a request head, the response head parser, the incremental chunk decoder) and http_client (1,711: the table, the state machine, the pool, the timers, the events). http_client was 1,988 lines before the slot layout moved out of it
examples/http_fetch_nb/fetch_io.cho (the driver of §3.7: sockets, TLS engine, poller; the one place that owns them) and fetch.cho (a program that fetches N URLs at once with it, with the options the live tests need)
tests/packages/http_client_*.cho118 cancho test tests in seven files, run on both backends by conformance/http_client.rs
tests/programs/http_client_{bytes,fuzz,bench}.chothe package with no I/O (its authority pinned), a million damaged responses through it, and its cost with nothing around it
scripts/http_client_{upstream,test,mutants}.pythe servers the live tests use, the 17 live cases and the cost, and 517 mutants
crates/cancho/tests/conformance/http_{client,fetch_nb}.rsthe unit tests on both backends and the package’s authority; the example built and run against a server written in the test and against examples/https_hello, and its authority pinned
CIthe tls-assurance job builds the example and runs the live tests, the fuzz, the bench, the cost and the mutants (ci.yml)

10.2 The API as built, against the design

The calls of §4 exist with these differences (corrected):

  • poll(c, now), detach(c, k, now), consume(c, t, n, now), send_body, end_body, connected, connect_failed, eof, reset, abort, retire, tick, take and give take the caller’s clock. next_deadline(c, now) answers the milliseconds to the next timer, 0 if one is due, -1 if there is none.
  • Added: close_idle(c, now) (every idle connection is told to close: the program is ending), ticket_of(c, k), close_reason_tag, the counters connects, requests, reuses, retries, continued_by_timeout, and default_limits(). Limits is a struct literal (cancho cannot assign one field of a value), so a caller writes all nine fields.
  • request answers 0 - code; client.full is -10. A request is refused whole: nothing about the client changes on a refusal.
  • Event::Done comes after the last byte of the body was consumed (§3.4), and a response complete but unconsumed holds its slot.
  • A free slot with an event the caller has not seen is not given to a new request (free_slot and pooled need events == 0): a caller that detaches a slot before it polled the Failed for it would otherwise lose that failure. A request that takes such a slot once it is seen is not queued twice (a full queue of N events would otherwise overwrite its oldest).
  • A closing slot takes what it is given and keeps none of it (give answers the length): a caller’s loop that reads once more after it learned the transport was closing neither loops nor feeds a dead request. A free or dialling slot answers -1.

10.3 Decisions the build added

  • A body that runs to the end of a TLS connection needs close_notify. The driver reports a TLS connection whose socket ended without it as reset (client.reset), not eof: RFC 8446 §6.1 says the data may have been cut, and for a body with no length the end of the connection is the only thing that says it is whole. A body with a length or chunked is whole without it, and the connection is just not kept. scripts/http_client_upstream.py ends its TLS connections properly (unwrap) except on /closeabrupt, which the live test uses.
  • idle_per_key bounds the pool, and it is the first thing a benchmark trips over. Thirty-two lanes on one upstream with the default of 8 idle connections made 12,957 connections for 81,536 requests (the 24 that did not fit were closed at each Done and dialled again), and 24 of them failed with client.connect (the kernel ran out of ports to hand out). The cost rows pass --pool 32.
  • The driver must not spin. The first fetch_io.step treated “bytes waiting for the socket” as work to do, so an upload to a peer that reads nothing used 0.55 s of CPU in 3 s asking the kernel again and again. A pump now says whether it moved anything, a blocked socket is the poller’s to wake, and nospin in the live tests fails the old loop (0.35 s) and passes the new (0.00 s).
  • A write that fails does not lose the response. A server that answers 413 as soon as it has the head and closes with the upload unread resets the connection while the client is still writing; its answer is in the client’s receive buffer. The driver marks the write side dead, drops the request and goes on reading (earlyrst in the live tests; the loop that reset at the first failed write lost the answer in one run of three). RFC 9112 §9.6 is why a server should not do this; a client should survive one that does.
  • The driver watches an idle connection for readable and gives what arrives to give, so a server that closes a pooled connection is noticed when it happens (Close, reason peer-closed), not when the next request finds it dead. The retry of §3.5 is for the race that remains.
  • Bytes behind a complete response are noted, not kept: if they arrive in the same read they are swallowed and the connection is closed, not pooled. A server that sends a second response nobody asked for is not believed.

10.4 Tests, against the gates of §9

  1. 118 unit tests, on both backends, the number pinned in conformance/http_client.rs: every framing (length, chunked with an extension and a trailer, until close, none for HEAD/204/304, HTTP/1.0, interim responses) fed in pieces of 1, 2, 3, 5, 7 and the whole; a body of 60,000 bytes through a 2,048-byte buffer in either framing to a consumer that takes 100 bytes a turn; 55 refused responses (each fed at three piece sizes) and 18 refused chunk framings (at every piece size) with their tags; 34 refused requests; the one retry (allowed or not, on an end or a reset, with a body, past the buffer, after a response byte, on a timeout, on a fresh connection); the pool (reuse, the newest, the oldest evicted, idle_per_key, retire, expiry by idle time, life and request count, to the millisecond); every timer at the millisecond it fires and the one before, and the caller’s slowness not charged; Expect released by a 100, by the wait and by a final response; an early response; a stale ticket and every wrong handle; and 16 seeds of 4,000 random operations of every call that must not trap or break a bound. Every tag of §6 that a request or a response can reach is reached by a test that names it.
  2. Mutants: 461 of 517 killed, 56 argued equivalent, none survives (scripts/http_client_mutants.py, which breaks wire.cho and client.cho one site at a time and runs the tests, as http_server_bytes_mutants.py does). The first run killed 356, left 153 alive and had 8 whose text no longer matched. What the survivors showed was in the tests, not the package: a harness whose byte assertions carried the label 0 and so could not fail (every assert_bytes was inert; found because read_body: a body until close counts nothing survived), a pool test that ended every connection with an eof and so could not see one pooled that should not be, an open test whose failure the next part overwrote, and the places the tests walked past: the boundary of every limit, every name the client owns, a header ending in a bare CR, PUT and PATCH with a body of 0, a hex size in lower case, a chunk size past 2^50, the trailer bound to the byte, the Expect clock starting at the last byte of the head, a replayed request not complete until it is sent again, a slot freed with events waiting being queued twice, a short final head behind interim ones that arrived with the end of the last of them. Each got a test. The 56 equivalents are each argued in the script (a check that another makes redundant, a state no caller can reach, a change only in how much is scanned); the one a test cannot show is open’s 1 GiB budget, which was run once by hand.
  3. Live, over plain TCP and TLS 1.3 (scripts/http_client_test.py, 17 cases, all passing on macOS and on Linux aarch64 in Docker with nginx installed): the framings at sizes from 0 to past the input buffer, alone and 40 at once; 1,000 requests over four URLs that make four connections, counted at the server; 40 URLs through 16 slots; three upstreams through two slots (an idle connection closed for another upstream, and the slot dialling again); 50 MB byte for byte; uploads of 0 bytes to 5 MB with a length and chunked, hashed at the server; Expect: 100-continue answered, waited out and refused; a 413 read while 5 MB were being sent, and after a reset; the retry (2 connections and 3 requests with --retry, client.closed-early without); ten kinds of refused response, each with its tag; timeouts; a name the certificate does not carry and roots that are not its CA (client.tls); examples/https_hello (300 requests on 3 TLS connections, 3 handshakes, each ended with close_notify); nginx ending connections with Connection: close after 5 requests; a blocked upload that must not spin; and a server that damages every response (flipped bytes, cuts, junk, in pieces of 1 to 100,000 bytes) to 640 connections: the client lives and every lane ends.
  4. No input reaches a trap: tests/programs/http_client_fuzz.cho feeds a million damaged responses through the whole client (0.4 s on the Mac), in pieces of one to forty bytes and consuming the body 7 bytes at a time so the buffers shift constantly: 330,281 end in Done, 669,719 in Failed with a tag, none is stuck, none traps (the same counts on macOS and on Linux, which is the point of a generator with a seed).
  5. Cost is §10.5.
  6. Authority: the package with a program that makes a request and reads a response reports heap and io_write (the program printing) and nothing else, bounded, no foreign code (the_package_reaches_the_heap_and_nothing_else); the example reports args, clock, conn_read, conn_write, err_write, fs_read("/dev/urandom"), fs_read("/etc/cancho/http_fetch_nb"), heap, io_read, io_write, net_out("") and poll, bounded, no foreign code, pinned in conformance/http_fetch_nb.rs like the TLS echo’s. Since #386: the second path is the directory --client-identity reads the client certificate and key from, fixed at build time by one narrow as examples/tls_echo_fixed’s is, so the report still names every path the program can read (docs/tls-parity.md §6.2).
  7. The gate: cargo fmt --all --check and cargo clippy --all-targets -- -D warnings clean; cargo test --workspace --no-fail-fast on macOS (arm64) and on Linux (aarch64, in Docker: x86-64 is CI’s) with the two tests that need a git checkout failing in a worktree and everything else passing; scripts/publish_packages.py --check clean.

10.5 Cost

scripts/http_client_test.py --cost 5 --pin --server <examples/api> --https-hello <https_hello>: 32 lanes on 32 connections, one request out on each, closed loop, the client on one core and the server on another (taskset), three runs of about five seconds; the client’s CPU a request is its getrusage divided by the requests, the median of the three. Plain is GET /users/42 against examples/api (a 26-byte JSON answer); TLS 1.3 is GET /hello/42 against examples/https_hello (99 bytes with its head). --quiet, so nothing is printed or hashed.

requests a second, three runsthe client’s CPU a request
aarch64 Linux (Docker Desktop’s VM on an Apple M4 Max, 6 vCPUs, other containers running; cores pinned)
plain, keep-alive324,772 313,368 303,4212.91 µs
plain, a connection for each request (--close)29,158 49,934 50,58813.09 µs
TLS 1.3, keep-alive123,094 119,944 125,2137.48 µs
TLS 1.3, a handshake for each request (--close)101 101 1013,226 µs
macOS, M4 Max, unpinned, with a load average of 15 to 30 from other jobs
plain, keep-alive145,015 170,287 157,1795.68 µs
plain, a connection for each request22,266 30,336 29,09420.16 µs
TLS 1.3, keep-alive87,379 85,616 81,70510.70 µs
TLS 1.3, a handshake for each request97 97 973,069 µs
GitHub Actions x86-64 runner (tls-assurance), shared, unpinned, as run in CI on this PR
plain, keep-alive68,208 68,396 68,49214.19 µs
plain, a connection for each request10,960 10,764 10,69984.34 µs
TLS 1.3, keep-alive18,079 18,080 18,10850.95 µs
TLS 1.3, a handshake for each request94 94 9410,334 µs

The shared x86-64 runner is 3 to 7 times slower per request than the pinned aarch64 VM (the same runner measures the server at 8.69 µs plain and 34.31 µs over TLS, http-server.md §11), so the client costs about 1.6 times the server there, not “about as much”. The state machine alone is 0.17 to 0.20 s per 400,000 requests (0.4 to 0.5 µs) on that runner. CI pins the shape (--pin), not these numbers.

So a client core serves about 300,000 plain keep-alive requests a second and about 120,000 over TLS 1.3 on the aarch64 VM, against the server’s 2.7 µs and 9.8 µs a request (http-server.md §11.7, same VM): the client costs about as much as the server it talks to, which is what a mirror should. What TLS adds to a kept request is 4.6 µs (the records), and a new TLS connection costs 3.2 ms of the client’s CPU, 430 times a kept request: the pool is the feature, and idle_per_key the setting that decides whether it works (§10.3). The VM is not quiet (the same command measured 150,918 / 139,480 / 156,006 plain and 61,494 / 53,419 / 57,126 over TLS with the host busier, 3.42 and 8.15 µs): the CPU a request is the figure to read, and it moved by up to 18% where the rates moved by a factor of two.

The state machine alone (tests/programs/http_client_bench.cho: slots requests in flight on one upstream, each answered with a canned response fed in one piece, the body consumed, the next request made on the pooled connection; no socket, no TLS, 400,000 requests):

slots8642561,024 (macOS)
per request0.23 µs (93 ms)0.29 µs (114 ms)0.50 µs (201 ms)1.23 µs (491 ms)

(a 4,000-byte body instead of 26: 0.34 µs at 8 slots). So of the 2.9 µs a request costs in the loop, 0.25 µs is the client; the rest is read, write and epoll. The growth with slots is the O(slots) scans of the pool (pooled, free_slot, oldest_idle) and of tick and next_deadline, in the bench’s transport loop as well: about 1 µs at 1,024 slots.

10.6 Memory a connection costs

Fixed when the client and the driver are opened; nothing is allocated per request or per connection.

bytes a connection
the client’s slotin_size + out_size + 48 words of state + 272 words of parse table + 1 word of queue = 84,488 with the example’s 65,536 and 16,384
the driver’s buffers (fetch_io)32,768 read + 16,640 plaintext received + 16,384 request + 32,768 write + 20 words = 98,720
the TLS engine’s slot, an https upstream only269,439

So a plain connection is 183,208 bytes (179 KiB) and a TLS one 452,327 (442 KiB): 64 TLS connections are 28.9 MB, 17.2 MB of which is the engine. The live big case moves 50 MB through a client of 8 slots in 2 to 3 MB of resident memory on macOS (19 to 20 MB on Linux, which counts the program’s pages differently).

10.7 What cancho-gateway still needs

In the order it will hit them, on top of http-server.md §11.8:

  1. The server’s streamed request body (the other half of piping an upload: this client’s send_room is the back-pressure to read the client’s socket by).
  2. A chunk writer on the way out, for a response of unknown length to a client that speaks 1.1 (http-server.md §11.8 item 2). The client dechunks; the server side must frame.
  3. Upgrade and CONNECT. A 101 is refused (response.upgrade). A tunnel needs the client to hand over the slot and the bytes already buffered behind the head; avail has them, the hand-over is not built.
  4. Resolving names without stopping the loop: rtcp.Resolver becomes a package and a driver module uses it from its Connect handler; until then the gateway resolves at start and calls retire(key) when an address changes.
  5. The driver as a package (§11 question 5): fetch_io.cho is the loop of https_hello turned to the client’s side; the gateway is the second asker for it.
  6. A per-request timeout (§11 question 6) and the gateway’s total_timeout per route: one Client per timeout class today.
  7. TLS session resumption to upstreams. tls.save/start_with exist and the driver does not use them: a full handshake is 3.2 ms of the client’s CPU (§10.5), paid once per connection, not per request. A gateway with many upstreams and short-lived connections would want it.
  8. The pool’s scans are O(slots): about 1 µs a request at 1,024 slots (§10.5). A free list and a per-key list are the change, and nothing asks yet.
  9. Access logging of the upstream leg (address, reused, attempts): reused(c,t) and attempts(c,t) are there; the address is the driver’s.
  10. A write that fails mid-body with no answer surfaces as client.reset, after the response was looked for and not found; the gateway answers its client 502 for it, as it does for client.closed-early, and retries nothing it has sent a body for.

11. Open questions for a person

Each has the answer this PR proposes and builds on; none blocks.

  1. Is HTTP/1.0 never reused the right strictness? The gateway reuses 1.0 with keep-alive; hooks never does. Proposed: never (as hooks, and as §3.5), since the upstreams that matter speak 1.1.
  2. Should the client retry a non-idempotent request? Proposed: never by itself: it is the caller’s flag (§3.5). The gateway passes it for an idempotent method with no body (as pool.md §3), hooks for every request.
  3. Should 101/Upgrade be a first-class body kind now? Proposed: no, refuse it with a tag (response.upgrade) and build it with the gateway’s WebSocket slice, where its requirements (what the client does with the leftover bytes, what Close means for a tunnel) are known.
  4. Should DNS live in the package? Proposed: no (§3.8); rtcp.Resolver becomes a package when a second program needs it, and then a driver module can depend on it.
  5. Where does the I/O driver live? Proposed: examples/http_fetch_nb/fetch_io.cho for now (§3.7); it becomes a package (http-client-io, which would import tls) when the gateway adopts it, because that is the second asker and its loop shows what a shared driver must not assume.
  6. Per-request timeouts. The limits are the client’s, set at open. The gateway has them per route. Proposed: a per-request override is a later addition (a request flag word has room); until then the caller opens one client per timeout class, which costs only the buffers.
  7. Is request returning client.full acceptable, or should the client queue? Proposed: return it; a queue is a policy with its own bound, the caller already has one (cancho-gateway queues clients in its own table).
  8. A body that runs to the end of a TLS connection with no close_notify is client.reset (§10.3). Proposed: yes, as RFC 8446 §6.1 says, and the driver is where it is decided (eof or reset), so an upstream that never sends close_notify gets a flag in the driver, not a change in the package. No server met in the live tests does that except the one built to.
  9. idle_per_key is 8 unless set. Proposed: keep, and have the gateway set it per upstream from the concurrency it allows; a benchmark that does not (§10.3) measures the pool churning, not the client.

Parallelism: threads and vectorization – what exists, what was measured, and the order to do it in

Status: strategy, with its measurements. Nothing in the compiler changes with this document. What it adds: one program that pins what threads can already carry (tests/accept/spawn_struct_ref.cho, with a backend-agreement test), corrections in place to three older documents, and the experiments behind every number here (benches/parallel/). The question it answers came from cancho-web’s database service, which reached its single core’s limit (a 4-vCPU machine, one used), and from asking what a DuckDB-shaped engine would need.

Measured on one machine: Intel Xeon 2.1 GHz, 4 vCPUs (a Firecracker VM, 1 thread per core as reported), Linux 6.18, clang 18, DuckDB 1.5.6. Times are the minimum of 5-9 runs. A claim not measured says so.

1. The question, and the short answer

Can cancho have threads, and can it vectorize, or does the design forbid them?

  • Neither is forbidden. Threads exist (spawn/join, real pthreads, both backends). Vectorization exists on the default backend for any loop that cannot trap.
  • The two real limits are different in kind. Threads are limited by a narrow payload rule and by there being no way to give a second thread its own Heap (section 4). Vectorization is limited by the language’s own guarantee: a checked + is an observable side effect, so a loop that uses it cannot be reordered across lanes (section 3).
  • Both have cheap first steps. Section 5 orders them, each with a gate that can fail.

2. What exists (read from the source and the documents)

factwhere
spawn(payload, body) and join: a real pthread_create/pthread_join, both backends. The payload and the result are each one pointer-width leaf: int, bool, byte, c_ptr, a captureless function value, any non-slice reference, or an owned File/Io/Ffi/Fs/Args/Heapdocs/threads.md; crosses_to_a_thread in crates/cancho-ir/src/lower/conc.rs
a reference crosses if the thread is joined before the region it belongs to ends (the escape check). Corrected, then fixed: the escape check alone did not make &! sound across threads – &! copies, two copies of one pointer could be spawned, and two threads then wrote one value unchecked (measured: benches/atomics/race.cho). spawn now lends the &! it is given until the handle is joined, so a second spawn of a copy, or a use by the spawning thread meanwhile, is refused. Only &! that came from one borrow, parameter or copy of it is followed; see the residual cases in aliasing.md §6.1threads.md §3, atomics.md §2, aliasing.md §6.1
multi-field payloads and Rc were decided “not yet”: nothing asked, and a general struct needs a compiler-built trampolinethreads.md §5
no program-visible atomics, no channels, no checked shared mutable memory (the compiler’s own unjoined-thread counter is an atomic, threads.md §6); Rc is not implemented. Atomics and a channel are designed in atomics.mdgrep of docs/ and std/
--backend llvm is the default and compiles with clang -c -O2 -target <triple>: no -march, the architecture’s baseline (SSE2 on x86-64)crates/cancho-codegen-llvm/src/lib.rs; llvm-backend.md
Cranelift has no auto-vectorizer; LLVM does, and does not reorder a loop whose arithmetic can trapbackend-limits.md §1.4; check-cost.md; gpu.md §2
integers are int (64-bit, trapping) and byte; narrower and unsigned widths are deferredstrings.md §2; bitwise.md §1
Heap is borrowed uniquely (“an allocator has state”), and split hands out exactly oneheap.md §2

3. What was measured

3.1 Vectorization: the default backend does it, for loops that cannot trap

benches/reduce_checked.cho and reduce_wrapping.cho (a million ints, 200 rounds; 8 MB), against the same loop in C:

timeSIMD instructions in the executable
C, clang -O2 (benches/reduce.c)66-69 ms52
cancho, LLVM, wrapping64-67 ms54
cancho, LLVM, checked (+)93-95 ms0
cancho, Cranelift, wrapping148 ms0
cancho, Cranelift, checked255 ms0

With the default backend a wrapping loop is C’s loop, vectorized; a checked one is 1.4x C and scalar. The figures in gpu.md §2 (checked 4.15x C, wrapping 2.27x) are Cranelift’s, and backend-limits.md §1.4’s “it will stay scalar” is true of Cranelift; both are corrected in place by this change, because the default has moved.

That kernel is memory-bound: 8 MB per round is past the cache, and 64 ms for 1.6 GB is what the memory delivers. Where the data stays in cache (the same work over 128 KB, 12,207 rounds):

time
LLVM, wrapping29 ms
LLVM, checked76 ms (2.6x the wrapping loop)

So the trap costs 1.4x on data that has to come from memory and 2.6x on data that does not. Which of the two a columnar engine mostly sees is a property of its blocking, not of the compiler.

3.2 Keeping the guarantee and vectorizing anyway: two attempts, both failed

Both keep every overflow a trap. Both were measured, because both looked likely to work.

ideacache-residentmemory-boundwhy not
sum the low and high 32 bits separately (reduce_limbs.cho): two accumulators that cannot overflow in a block, one checked step per block79 ms (checked: 76)102 ms (checked: 95)no gain: 29 SIMD instructions, but the 64-bit shifts and masks cost what the checks did
prove a block cannot overflow, then sum it unchecked (reduce_proved_abs.cho: largest absolute value; reduce_proved.cho: max and min by comparisons)231 ms and 425 ms (checked: 95)the abs pass has a checked 0 - x, which is itself a trap; the comparison pass does vectorize (148 SIMD instructions) and is slower than scalar: SSE2 has no 64-bit compare

In C (proved.c, 128 KB, 12,207 rounds) the same idea, to see what a better target buys:

clang -O2clang -O2 -mavx2
checked, per element73 ms71 ms
wrapping25 ms14 ms
proof pass, then wrapping166 ms63 ms

AVX2 turns the proof idea from 2.3x slower than checked into 1.13x faster, and it is still 4.5x slower than the wrapping loop: the proof pass costs more than the sum it licenses. The idea is dead as a per-query pass. What makes it worth keeping is where a columnar engine already has the proof: per-block minimum and maximum, kept when the data was written (DuckDB keeps exactly this as segment statistics). Then the check is O(blocks), the sum is the vectorized wrapping loop, and the language’s guarantee costs nothing at query time (section 5, V3).

3.3 A scan against DuckDB, on one core

50 million int64 (400 MB), the same values in both (x[i] = ((i * 2654435761) >> 7) & 1023), in memory, the answers equal (25,575,001,640 and 25,537,111). cancho is one loop per query on one core; DuckDB is a query engine and its time includes planning and its pipeline:

sum(x)count where x > 500
cancho, LLVM, checked, 1 core77 ms69 ms
cancho, LLVM, wrapping, 1 core42 ms73 ms
DuckDB, 1 thread90 ms108 ms
DuckDB, 2 threads46 ms56 ms
DuckDB, 4 threads24.5 ms40 ms

On one core a hand-written loop is level with DuckDB’s single thread on these two primitives, and DuckDB’s lead is its threads (3.7x at 4 for the sum). That is one primitive, with the plan, the SQL, the storage format, compression, joins and group-by all outside it: it says the single-core inner loop is not where a cancho engine would lose, and nothing about the rest.

3.4 Threads: what already crosses, and how well they scale

Probes (each compiled and, where it passes, run on both backends; tests/accept/spawn_struct_ref.cho pins the second and the third):

payloadresult
a unique reference to a struct (&!r Job with ints)accepted, runs
a unique reference to a struct that owns a Box[[int]]accepted, runs, the thread reads and writes through it
two workers at onceaccepted only with one function value per spawn: work taken once is instantiated at the first borrow’s region and the second reference “does not outlive” it
a reference to a slicerefused (a pointer and a length: two leaves)
an owned Net(..) or Clockrefused when measured, though the allowlist’s own comment says Net has no fields; admitted since T2 (below), with tests/accept/spawn_owned_net.cho and spawn_owned_clock.cho
two threads allocatingno way to write it: there is one Heap, borrowed uniquely, and split gives out one

So a multi-field payload by reference works today; threads.md §5 put it behind a trampoline, and that was needed only for an owned struct. Scaling (gen_threads.py: P threads, each summing its own 128 KB slice 12,207 times, the wrapping loop above; the work is P times one thread’s, so perfect scaling keeps the time constant; cores 0-3):

threads124
wall time29 ms42 ms41 ms
throughput relative to one thread1.0x1.4x2.8x

Real parallelism, but short of linear, and the 2-thread figure is worse per thread than the 4-thread one, which I cannot explain from here (a 4-vCPU VM; two vCPUs may share a physical core). Not investigated further.

3.5 What the pool work already said about multiple cores

Three blocking copies of users_pg sharing a port (reuseport, so processes, not threads) read 25,180 a second against 15,638 for one (cancho-web, docs/benchmarks.md). Processes already get cancho past one core for a service that shares nothing; they cannot share memory, and each holds its own database connections.

4. Strategy: threads

Principles. Shared-nothing first. A worker owns what it touches; the soundness argument stays the existing aliasing rule and needs no new one. No Rc, no atomics until a program asks for one (AGENTS.md §7: a feature earns its way in when a program asks, and the asker here is a server that wants more than one core).

stepwhatgate (can fail)
T0no compiler change: data-parallel kernels on today’s threads, a job per &! struct (section 3.4). Put the pattern in docs/threads.md’s status, with the one-function-value-per-spawn rule and whythe thread-scaling program (gen_threads.py) stays at least 2.8x at 4 threads on both backends; a conformance test runs two workers on both
T1give a thread its own Heap: built (section 8.6): fork_heap(&!Heap) -> Heap, edition 4, row [heap]; sound while Heap is stateless (section 8.3)tests/accept/fork_heap_workers.cho on both backends (two forked heaps, two workers, a box the fork allocates freed by the parent); three rejects; 100 million allocate-and-free rounds in two threads at a flat 10.1 MB resident, on Cranelift
T2built: Net(..) and Clock admitted as payloads: both are zero-leaf like Io (leaf_free), and the change is two names in crosses_to_a_thread’s allowlist, as File’s was. A thread now dials a refused port and reads a clock, on both backendsdone: two fixtures, two backend-agreement tests; no reject fixture had pinned the refusal, so none became an obsolete pin
T3server workers: N threads each running http.server’s loop on its own SO_REUSEPORT listener, the schema and the OpenAPI document shared read-only through &. Baseline measured (copies_users.sh, the in-memory users service on a stateless workload, an invalid POST /users: parse, validation, error, no store; copy i on core i, the load generator on cores 2-3): one process 94,904 a second (median of 5, 93.5k-100.0k), two processes sharing a port 159,176 (139.9k-163.0k), 1.68x. N = 3 cannot be measured on this machine without the load generator sharing a coretwo threads at least 0.9x of two processes: 143,000 a second or more; if it is below, say so and keep processes: they are free today. Built and measured (section 9): met, 149,380 against 141,208 a second for two processes in the same session, with ranges that overlap
T4shared read-only data across threads (&T crossing to several threads at once): test it, document it. It already type-checks as one reference per thread (tests/accept/spawn_thread_ids.cho shares a borrowed capability); what is missing is a worked examplea reader pool over a shared Box[[int]] agrees with the single-thread answer
T5communication: a bounded queue between threads needs an atomic load and store with ordering, which does not exist. Design only: what the primitive is, and why it is not Rcdesigned in atomics.md (asked by a parallel CSV scan in cancho-table); not built; stages A0-A3 each have a gate fixed beforehand. Hard constraint or nobody asked: nobody asked, atomics.md §1

Non-goals. Shared mutable memory, thread-local storage, an async runtime, and anything that gives up the single-owner rule. If a use needs them, the answer is processes (section 3.5) until a measured reason says otherwise.

What T0-T3 would buy cancho-web. The database service reached 64,000 reads a second in one process on one core. On four cores and with workers, the ceiling moves to the database, which is where it belongs; whether it does is exactly what T3’s criterion measures.

5. Strategy: vectorization

The default backend already vectorizes everything that cannot trap, and that is a large class. The work is therefore not “add a vectorizer”; it is where is the guarantee paid for, and can the engine avoid paying it where it does not need to.

stepwhatgate (can fail)
V0correct the documents that described Cranelift’s numbers as the language’s (gpu.md §2, backend-limits.md §1.4) and record LLVM’s: this change does ita reader of gpu.md §2.3 is told the default backend’s figures and where the old ones came from
V1wrapping where the bits are the intent, in library kernels: hashing, checksums, bitmaps, counters that are reduced modulo something. They are explicit (wrapping_add), they vectorize today, and they are not a loophole because the intent is stateda std kernel (a byte-count or a bitmap popcount over a slice) at 0.9x or better of C on both a memory-bound and a cache-resident input, SIMD count above zero
V2a target flag: --cpu <name> (or native) for the LLVM backend. Baseline SSE2 has no 64-bit compare; AVX2 does (section 3.2: 166 ms to 63 ms for the same idea). Opt-in, because a binary built for AVX2 does not run everywherere-run section 3.2 with the flag; if the wrapping loop is not at least 1.5x faster than baseline on a cache-resident input, drop it (C says 1.8x: 25 to 14 ms)
V3statistics instead of proofs: a column engine keeps minimum and maximum per block when it writes the block; a checked sum then reads the statistics (O(blocks)), sums each block with the vectorized wrapping loop, and combines the block sums with checked +. The language’s guarantee is kept in full, and costs nothing per rowon the 50-million-row scan of section 3.3, a checked sum within 1.25x of the wrapping one on one core (77 ms checked and 42 ms wrapping today); if the statistics are not maintained cheaply enough on write, the engine is not worth building
V4narrower element types (int32, int16, uint8) for columns: a 32-bit column summed into a 64-bit accumulator cannot overflow (so it is exact, checked or not, and vectorizes), and it halves the memory a bandwidth-bound scan reads (section 3.1’s 8 MB kernel is bandwidth-bound). This is a language change, and the largest one here: strings.md §2 and bitwise.md defer unsigned and narrower widths on purposean int32 column sum at at least 1.7x the int64 one on the memory-bound input (half the bytes, the same instructions); a design document first
V5explicit SIMD typesnot started. Cranelift has v128 and LLVM has vectors; if V1-V4 leave a real kernel short of C, name it, and then design

What not to do. The two attempts in section 3.2 each looked right and cost more than the check; the next idea gets the same treatment before it is built, and the experiments are checked in so that is cheap.

6. A column engine, in that order

The question that started this section was “could cancho be something like DuckDB”. Read against section 3.3: the inner loop is not the obstacle on one core, the threads are (T0-T3), and the language’s guarantee is manageable if the engine keeps statistics (V3). What is not there, and not designed: a SQL front end, a planner, a storage format (compression, a persistent file), joins and group-by with hash tables that scale, a spill path. A first deliverable that does not need any of them: a library of typed column kernels (sum, count where, min, max, filter over int columns, with block statistics), queried through generated functions the way pgen generates them from SQL, single-threaded, with the target of section 3.3’s numbers and V3’s gate. If that holds, threads (T3) are the next multiplier; if it does not, the document says why and the engine does not get built.

7. What to do first

  1. Merge this document, the fixture and the corrections (V0, T0’s pattern).
  2. T1: how a thread gets a Heap (decided in section 8, built in 8.6). T3 is next.
  3. T2 (the allowlist) (done), T3 (section 9), and the T3 baseline (the in-memory users service as N processes), which cost an afternoon each and decide whether T3 is worth building.
  4. V2 and V3 only after a column kernel exists to measure them on.

8. T1: a thread’s own Heap – the decision

8.1 What is true today

  • A Box is a malloc, and a Heap is a type-level token. box lowers to one malloc and unbox to one free on both backends (crates/cancho-codegen/src/body/memory.rs, cancho-codegen-llvm/src/body/memory.rs); Heap has no fields and no leaves (leaf_free), and there is no budget in the runtime (budget.md: it “does not belong here”). A region’s arena is one malloc plus a bump pointer kept in the function’s own locals, so it is private to the thread that opens it. glibc’s malloc and free are thread-safe, and a block may be freed by a thread other than the one that allocated it.
  • Heap is unique anyway, “an allocator has state, and the honest type for shared mutable state is the one that says only one reference reaches it” (heap.md §2) – true of the type’s intent, not of today’s runtime – and split hands out one.
  • A struct holding a Heap crosses to a thread and the thread allocates through it (tests/accept/spawn_heap_in_struct.cho, both backends): a res struct Worker { heap: Heap, out: int } passed as &!r Worker, the worker calling box_slice(w.heap, ..) and unbox_slice(w.heap, ..). Its row is [heap] (only owning the capability discharges the label, as for Io); main’s is [conc] because main owns the heap it moved into the struct. So the carrying side works; what is missing is a second Heap to carry.

8.2 The options

ideaverdict
Aa builtin fork_heap(h: &!x Heap) -> [heap] Heap: a new owned Heap made from a unique borrow of onechosen: nothing is amplified (the parent already holds the authority, and the label is the same heap); the child is an ordinary owned capability, moved into a worker struct or a payload
Bmake Heap copyablerejected: it deletes the uniqueness the type was designed to state, for every future allocator, to fix a problem a fork solves
Csplit hands out N heapsrejected: Split is a fixed record; the number of workers is a run-time decision
Dworkers use regions onlyrejected: the library surface (http.server.open, pg.pool, schema) takes a Heap; a worker could not call any of it
Espawn gives the thread a heap implicitlyrejected: an authority the program text does not show; the capability language exists to show it

8.3 Why A is sound, and the one condition it depends on

A Box freed through any Heap is correct while Heap has no state of its own: malloc and free need no heap value to agree on which allocation they are speaking of. So a forked Heap and its parent are interchangeable for memory safety, a box a worker builds can be freed by the parent after join, and no new linearity rule is needed (the child is res, so it is moved, and used once, by the existing rules).

The condition: Heap must stay stateless. If it ever gets state – a per-heap arena, a budget, a different allocator for a no_std target – then fork_heap has to split that state, and unbox has to be tied to the allocator that made the box (a type-level obligation Box[T] does not carry today). This design says so rather than hiding it: the footprint test of the_heap_actually_frees (eight million 2 KiB boxes, one at a time) must keep passing with boxes freed by a different heap than allocated them, and a change that makes Heap stateful has to start by failing it.

What this does not claim: that forked heaps are faster (glibc already keeps per-thread arenas; not measured here), or that allocation under contention scales (T3 measures the service, not the allocator).

8.4 The gate (T1), and what building it touches

A fixture tests/accept/fork_heap_workers.cho, on both backends, and its rejects:

  • accepts: the parent forks two heaps and moves each into a worker struct; two threads allocate and free boxes in a loop (a million iterations each); one worker also builds a box that outlives its thread (stored in its struct), which the parent frees with its own heap after join; the same run’s resident size stays flat (the footprint check);
  • rejects: using a forked heap after moving it into a thread (linear-use-after-move, existing); forking from a shared reference (&Heap: the builtin’s signature wants &!); and releasing the parent heap and then forking.

Expected cost, not yet measured: the value has no leaves, so neither backend should emit code for the call; the work is the builtin’s signature and row in cancho-ir/src/builtin.rs, the lowering arm, and the fixture. [heap] stays the only label, so cancho authority reports nothing new and a lex-os grant needs no new field.

8.5 After T1

T3’s workers hold { heap, listener, poller, schema, ... } in one struct each, passed by &!, with Net and Clock crossing as owned payloads (T2, built) for the parts the struct cannot hold. Whether a library (http.server) can be driven from a thread through a struct field of its Server type is the first thing T3 has to find out, and may need its own change.

8.6 Built, and what building it found

fork_heap is Builtin::ForkHeap (cancho-ir/src/builtin.rs), checked at the call site like box (expect_heap: the argument must be a uniquely borrowed Heap), effect heap, edition 4 (the same edition as spawn, so no older file’s own fork_heap is shadowed). Its value has no leaves: both backends emit nothing for the call, one arm each next to split’s.

The gate of 8.4, as built:

  • accepts tests/accept/fork_heap_workers.cho, agreeing on both backends: the parent forks two heaps, moves one into each worker struct, the two threads allocate and free boxes in a loop, and a box the forked heap allocates is freed by the parent’s heap. Peak resident size on Cranelift is 10.1 MB at 2 million rounds and the same 10.1 MB at 100 million (50 million in each thread, 1.47 s), so nothing leaks.
  • rejects: fork_heap_from_shared_borrow (capability-misused), fork_heap_after_release and fork_heap_child_reused (linear-use-after-move, the existing rule; nothing specific to fork_heap was added).

Three corrections to what 8.4 expected:

  1. The loop is not a footprint test on LLVM. The same binary built with LLVM runs the two million rounds in 4 ms: clang -O2 removes a malloc/free pair whose result it can see through. The Cranelift build does the real traffic (1.47 s for the 100 million). The footprint claim above is therefore a Cranelift measurement; the LLVM build proves the program type-checks, links and agrees on the answer.
  2. “A box built in a thread and freed by the parent after join” is not expressible yet, and 8.4 should not have listed it. join refuses a Box result (“cannot cross back from a real thread yet”), and a res field cannot be moved out through a &! reference, so a worker has no way to hand a box back. What the fixture proves instead is the other direction of 8.3’s claim, a box made by the fork and freed by the parent. Handing a box back is a separate, small piece of work for T3 if a worker needs it (a worker’s reply is more likely to be bytes in a buffer the parent already owns).
  3. A compiler bug turned up on the way and is fixed: join(a) + join(b) failed on the default (LLVM) backend with “the compiler failed to generate code” because the scalar-kind inference had no arm for the Joined node. It now answers the thread’s result type; tests/accept/spawn_join_operands.cho pins it on both backends. Cranelift was never affected.

Not claimed: that a forked heap is faster than sharing one, which is T3’s measurement.

9. T3: two threads running http.server

9.1 What it took

examples/users_threads/users_threads.cho in cancho-web is users with its main replaced; the application, the schema and the whole server loop (run) are unchanged. Main opens two SO_REUSEPORT listeners (it holds the only Net), and each worker is a struct Lane { heap, clock, listener, status } handed to a thread by &!, which calls run(w.heap, w.clock, w.listener) and builds its own Poller, Server, store, schema and buffers inside the thread. So the first question of 8.5 has a plain answer: a library can be driven from a thread through struct fields, because run takes its capabilities as borrows, and the Server itself never needs to live in the struct (the thread makes it). No change to http.server was needed.

What the language needed was one more builtin. A thread needs a Heap (T1, fork_heap) and a Clock of its own, since server.wait takes &Clock and a Clock is res. split gives one, and a thread cannot borrow the parent’s across spawn (only one &! struct reference crosses). So fork_clock(&Clock) -> Clock, edition 5, makes another from a shared borrow of the first. Its argument for soundness is fork_heap’s: a clock reads the monotonic time and nothing else, the parent already holds the authority, the label is unchanged, and no program’s lex-os grant or cancho authority report changes. It is also where the capability discipline gives a little: authority was conserved before (one holder of each capability), and for Heap and Clock it no longer is. Net is deliberately not forkable: the listeners are made by the one thread that holds it, and workers receive listeners, not the right to make them. tests/accept/fork_clock_workers.cho (both backends) and tests/reject/fork_clock_after_release.cho pin it.

9.2 Measured

Same workload and machine as the baseline (invalid POST /users, 16 connections, 4 kload threads; server threads or processes on cores 0 and 1, the load generator on 2 and 3), the two variants interleaved in three rounds of five runs each, so the machine’s drift hits both:

runs (requests a second, sorted)median of 15
two processes, SO_REUSEPORT115,972 125,328 133,872 136,180 138,096 140,388 140,436 141,208 141,264 147,292 152,204 154,896 159,708 160,972 173,404141,208
two threads, one process112,228 125,560 129,176 130,500 132,984 133,004 137,844 149,380 149,428 160,212 160,280 161,224 162,892 164,344 165,356149,380

Both threads are busy (about 1,500 and 1,700 ticks of CPU each over a run), so the load is spread and not carried by one. The ratio of the medians is 1.06, the criterion was at least 0.9 and at least 143,000 a second, and both are met; but the ranges overlap almost completely and the machine was slower than when the baseline was taken (processes then 159,176, today 141,208), so the honest reading is threads are indistinguishable from processes here, and not worse. The first single block of five gave the threads 161,432; the pooled median is the number to quote.

9.3 What this does and does not say

  • It says a typed, linear, capability-checked server can use several cores in one process with the same code, no unsafe and no shared mutable state, at the throughput of the process-per-core arrangement it replaces.
  • It does not say threads beat processes. For a stateless service they should not: both are share-nothing. The reason to have threads is what processes cannot do, which is share a store, and that needs T4 and T5 (shared read-only data, then communication), neither built.
  • Three cores cannot be measured on this 4-core machine without the load generator sharing a core, so scaling past two is unmeasured.
  • The in-memory store (POST that succeeds) is per-thread today, so a GET may land on a worker that has not seen the POST. This example only demonstrates the stateless path; sharing the store is exactly T4/T5.

Native sockets: servers without Ffi("libc")

Status: design settled; slice 1 built (§10). Stage 1 of removing C from the path between a cancho program and the kernel. §7 is the whole roadmap to a toolchain with no C in it; this document is only the first of its four stages, and it is the one with an asker — the server in server.md and the two repositories that will be built on it (cancho-web, cancho-schema).

What it corrects before it builds. The first sketch of this stage was net_read(fd: int, buf) / net_write(fd: int, data), fixed signatures with no capability, because that is how listen and accept already work (listen.md §6). §2 shows why that is the wrong shape for a read and a write, and §3 replaces it with typed handles, the way file-handles.md did for files.


1. What the C costs, measured

examples/api — the server — declares four extern fns of its own (poll, signal, time, send) and imports eight more from packages/net-sockets (socket, setsockopt, bind, listen, accept, read, write, close). Twelve libc symbols, all behind Ffi("libc").

That is two costs, and the second is the one that matters.

It is C on the critical path. The program is cancho, the toolchain that links it is Rust and (for the LLVM backend) clang, and every syscall goes through a libc this project did not write. Removing that is §7’s whole subject.

The authority report says nothing. cancho authority on the server prints ffi("libc"), and ffi("libc") means “may call any function in libc” — unlink, execve, system. The program does not do those things, and the report cannot say so. A server whose row reads net_in("8080"), conn_read, conn_write is a server whose reviewer can tell what it can reach; one whose row reads ffi("libc") is one that has to be read line by line. This is authority.md’s reason for existing, applied to the one program class where the answer matters most — the one that takes input from strangers.

So the target is not “fewer externs”. It is a server whose authority row is exact.

2. Why fixed-signature fd builtins are the wrong shape

listen(fd: int, backlog: int) and accept(fd: int) take no capability: “the fd already proves the authority bind checked” (listen.md §6.1). That argument has a hole, and for those two builtins the hole is harmless. For a read and a write it is not.

An int proves nothing. bind returns an int, and int is forgeable: a program can write 3. accept(3) on a descriptor that is not a socket answers an error; listen(3, 5) likewise. But net_write(1, data) is write(2) to standard output without an Io, and net_read(0, buf) is a read of standard input without one. Every guarantee standard-input.md and standard-error.md spent a milestone on — “a function that does not declare io_write cannot print” — would hold for every program except the ones that happened to import a socket.

file-handles.md §4.1 already settled this question for files, and the answer there is the answer here: the handle is the capability. A File has no literal form (defs.rs’s is_capability: “the number would be someone else’s open file”), it is one leaf, it is consumed exactly once by close, and it is minted by exactly one builtin that checks an authority once. Nothing in the type system needs to change; sockets are the second user of a mechanism that already exists.

3. The design

Three new prelude types, all res, none with a literal form, all one leaf (a descriptor), all edition 5; only (a change that adds is exact, and costs old files nothing — editions.md header; File’s own name collision, §4.2 there, is why they are not in every program’s prelude).

typewhat it isconsumed by
Listenera bound, listening socketlistener_close
Connone established connection, either directionconn_close
Pollera set of handles the kernel is watching (§4)poller_close

3.1 The builtins

Each builtin’s result is a prelude enum, never a sentinel and never std.result — file-handles.md §2.1: a builtin’s signature is fixed in the compiler and std is opt-in.

builtinsignaturerow
tcp_listen(net, port, backlog, flags) -> Listeningnet_in(bound)
tcp_connect(net, host, port) -> Dialednet_out(bound)
tcp_accept(&!Listener) -> Acceptedconn_accept
conn_read(&!Conn, &![byte]) -> Receivedconn_read
conn_write(&!Conn, &[byte]) -> Sentconn_write
conn_nonblocking / listener_nonblocking(&!H) -> int[]
conn_close / listener_close(H) -> int[]
conn_nodelay(&!Conn) -> int[] (§11)
conn_raw_fd(&Ffi(..), &Conn) -> intffi(..) (§6)
enum Listening { Ok(Listener), Failed(int) }          // errno
enum Dialed    { Ok(Conn),     Failed(int) }
enum Accepted  { Ok(Conn), Again, Failed(int) }
enum Received  { Data(int), End, Again, Failed(int) }
enum Sent      { Wrote(int),    Again, Failed(int) }

Again is its own constructor, not Failed(EAGAIN): the value of EAGAIN is 11 on Linux and 35 on macOS, and a program that compares an errno against a number it had to look up per platform has a sentinel with a constructor around it (file-handles.md §3). Data(0) is not reachable, as Got(0) is not (a read into an empty buffer is Failed(EINVAL), §10); Wrote(0) is, for an empty write.

Rows follow the file rule (file-handles.md §4.1): the authority is spent where the handle is minted — tcp_listen performs net_in("8080"), tcp_connect performs net_out("api.internal:443") — and the operations on the handle carry a path-free label named after the capability and the direction. Owning a Conn discharges conn_read/conn_write; borrowing one declares them. The authority report of a program that moved from Ffi("libc") to handles over the same port must keep the port in its row; a conformance test says so, the one bulk-io.md §3.2 asked for and file-handles.md §4 wrote.

tcp_listen folds four calls into one — socket, setsockopt(SO_REUSEADDR), bind, listen — as bind already folds three. Whether a program may also ask for SO_REUSEPORT (the api server’s multi-process mode) is the one option worth an argument; it becomes a fourth int of flags rather than a second builtin.

Writes never wait. The contract of conn_write on a non-blocking Conn is “takes what the kernel has room for, answers how much, or Again”, on every platform. How that is achieved is the runtime’s business and is the reason this belongs in the compiler rather than in a library: MSG_DONTWAIT is honoured on Linux and ignored by macOS on a blocking socket (server.md §3, §7 — found by CI, not by reasoning), so a portable program written against send(2) has to know that. Against conn_write it does not.

SIGPIPE stops being the program’s problem. Writing to a peer that has gone must answer Failed(EPIPE), never kill the process. MSG_NOSIGNAL on Linux and SO_NOSIGPIPE on macOS do it per socket, with no process-wide signal disposition — so signal(SIGPIPE, SIG_IGN), an extern and a magic number in the server today, disappears. (The signals a program wants to see, SIGTERM and SIGINT, are signals.md: a capability and a handle the Poller can wait on, edition 6.)

conn_nonblocking is explicit and one-way. A Conn is blocking when it arrives, which is what examples/serve and collect want; registering a handle with a Poller does not flip it silently, which would be an action at a distance. A non-blocking listener matters for correctness, not speed: between poll saying a listener is readable and accept being called, the pending connection can reset, and a blocking accept then waits for a different client.

4. Readiness: the Poller

A server needs to wait on many handles at once. The obvious builtin is poll(&![int] fds, timeout), which is poll(2) — and which has §2’s problem exactly: it names descriptors by number. It reads nothing, but poll over [0] tells a program, without Io, whether standard input has data, and over [3..1024] tells it which descriptors the process has open. That is a small leak, and it is the kind this project has refused every time it came up.

So a Poller owns its registrations, and the program names handles by tokens it chose:

poller_new()                                 -> Polling      // Ok(Poller) | Failed
poller_add_listener(&!Poller, &Listener, token) -> int
poller_add_conn(&!Poller, &Conn, token, events) -> int      // events: 1 read, 2 write
poller_modify(&!Poller, &Conn, token, events)   -> int
poller_remove(&!Poller, &Conn)                  -> int
poller_wait(&!Poller, &![int] out, timeout_ms)  -> int      // pairs (token, events) written to `out`

Linux backs it with epoll, macOS with kqueue. That is a cost to state plainly: epoll_event is 12 packed bytes on x86-64 and 16 on arm64, and kevent is 32; the compiler writes those structs, the program never sees them, and the program-visible surface is two ints per event. It is also the scalable answer — poll(2) is O(registered handles) per wait, and the api server’s whole loop is currently a scan of its own record array — so it is a performance change as well as a safety one, and gets benchmarked as one (§8). Corrected by measurement (§10.4): in the one environment it was run in, epoll is the slower of the two.

The kernel drops a registration when the descriptor closes, so a Conn closed while registered cannot leave a stale entry that a new connection with the same number inherits. A token is the program’s own, and the rule is the program’s too: a token it has retired can still arrive in the same wait that closed it.

A Poller row is poll — path-free, argument-free, and for the reason heap and args have none: there is nothing to narrow. It observes handles the program already holds.

5. Time

The idle timeout is the one place the server asks the clock, and today it does it with time(NULL) — wall-clock seconds, which jumps when the host’s clock is corrected and would close every connection in the process at once if it jumped forward. A timeout wants a monotonic clock.

That needs a capability, or any program reads the time without saying so, and “it is not secret” has not been the project’s test for authority. Proposal: a Clock capability carried by a third Split declaration. PRELUDE_SPLIT_NET is the second: an edition-5 file’s split() would answer one with a clock field, so no file written before it is made to consume a field it never heard of — the move Net made (ir.rs’s note on PRELUDE_NET). One builtin:

clock_ms(&Clock) -> [clock] int     // monotonic milliseconds from an arbitrary origin

poller_wait’s timeout is the other half: it says how long to sleep and the clock says what time it is, and the server needs both. This is the one piece of §3–§5 that is a new capability rather than a new use of an existing mechanism, and §9 asks about it.

6. The escape hatch, and why it is safe

TLS needs a descriptor: SSL_set_fd(ssl, fd) takes an int (examples/tls_client). So conn_raw_fd hands one over — and costs an Ffi capability to call. A program holding Ffi("libc") can already call write(2) on any number; giving it the number a Conn wraps is not a widening, and a program without Ffi cannot reach the hatch. The descriptor stays owned by the Conn; the raw int is a loan and conn_close still ends it.

There is no inverse (conn_from_fd). That one would mint authority from a forgeable number, and nothing asks for it.

Corrected (tls-nonblocking.md §3.2): a program without Ffi can read the number. The sentence above (“a program without Ffi cannot reach the hatch”) is false of the number, if not of anything it could be used for: a ticket is epoch << 32 | descriptor (§10.3) and std.conns.Table.tickets is a field of a pub struct, so vec.get(table.tickets, slot) & 0xffffffff is the descriptor, with no capability (examples/tls_nb/gaps/g5_table_ticket.cho; tls.fd_of). Calling anything on it still needs Ffi, so nothing is forged, but Table’s fields should not be readable. And conn_raw_fd is still not built: the TLS spike uses memory BIOs instead (§3.2 there), partly because OpenSSL’s own socket BIO writes with write(2) and a closed peer then kills the process with SIGPIPE, which conn_write’s MSG_NOSIGNAL is built to prevent.

7. Where this sits: the four stages

stageremovesstatus
1Handles (this document)Ffi("libc") from any program that only talks to the networkdesigning
2A libc-free Linux runtimelibc from the builtins’ implementation: _start, read/write/socket/… as raw syscalls, errno as a negative return, getaddrinfo as a DNS clientnot started
3Port the frontendRust from lexing, parsing, checking, lowering — cancho-syntax (5.3k lines), -ir (12.3k), -types (0.7k), -id (1.9k)not started
4Own backend and linkerCranelift/LLVM/clang/ld; for macOS, an arm64 code-signing stepnot started

Stage 1 is not C-removal in the compiler: the builtins still call libc. What it removes is the program’s dependence on libc, which is the precondition for stage 2. Until no program names a libc symbol, replacing libc underneath is a breaking change; after it, a backend-internal one — the same ordering that let file_read change from read(2) to anything without touching sort.cho.

Two honest limits, so nobody plans past them. macOS cannot leave libSystem: Apple does not guarantee its syscall numbers, and a binary that bypasses libSystem is not one the platform supports — so “no C” means “no C on Linux” and, on macOS, “no C that this project wrote or links beyond libSystem”. And getaddrinfo, which connect.md §10 uses for name resolution, is a large body of C (/etc/hosts, nsswitch, resolver configuration): a native replacement is a real DNS client, which is why stage 2 is measured in months and not in builtins.

self-hosting.md said “not yet — no asker”, and was right when it said it. There is an asker now; that document gets a paragraph pointing here when this one is built, not before.

8. What building it must prove

  1. Mutation checks on every row. Each of tcp_accept, conn_read, conn_write, poller_wait is mutated (return the wrong constructor, drop the Again case, ignore the count) and a test must fail — the discipline that exposed uniform-body tests and never-partial sends in server.md.
  2. The forgery test. A program without Io that tries to write to descriptor 1 has no spelling for it: Conn { } is rejected, no builtin takes an int descriptor, and conn_raw_fd is rejected without an Ffi. A positive test per refusal, with rule tags.
  3. Platform parity. The Again contract is a test over a real socket pair on both Linux and macOS CI; the 1 ms SO_SNDTIMEO workaround in examples/api is the evidence the contract needs, and is deleted only once the test that replaces it passes on macOS.
  4. Both backends. Cranelift and LLVM, byte-for-byte equal output on the conformance programs. One known trap: fcntl and ioctl are variadic, and on Apple arm64 a variadic call is not the same ABI as a fixed one. LLVM expresses the difference; whether Cranelift does is unmeasured, and if it does not, non-blocking mode on macOS goes through a path that avoids them — to be decided by the measurement, not here.
  5. No regression, measured. examples/api migrated to handles runs the same benches/server loads; the ~120,000 requests a second may not fall, and the Poller’s effect at 1,024 connections is reported against poll(2) whichever way it points.
  6. The authority report test of §3: the port survives the migration.

9. Slices, and what is open

  1. Handles and the read/write path — Listener, Conn, tcp_listen, tcp_accept, conn_read, conn_write, *_nonblocking, *_close, conn_raw_fd; examples/serve and examples/collect migrated, their Ffi("libc") gone. (They need no Poller.)
  2. tcp_connect — and packages/net-connect / net-sockets reduced to what is left, which may be nothing.
  3. Poller — and examples/api migrated; the benchmark in §8.5.
  4. Clock (decided: yes).
QuestionSettled
Clock capability, or a per-registration deadline in the Poller?Clock (§5). A deadline the kernel reports as an event is less general, and the web layer will want the time for logs and Date: headers anyway; poller_wait’s timeout stays
SO_REUSEPORTA flags argument on tcp_listen (bit 1), not a second builtin
The old int-returning bind/listen/accept/connectKept, documented as superseded; they cannot be removed without breaking files that check today
Peer address on acceptNot in Accepted. A later conn_peer(&Conn, &![byte]) -> int is additive and costs nothing now; no asker until an access log exists. Built (conn-peer.md), as predicted: conn_peer, asked at call time with getpeername, 19 bytes, no effect row, with std.addr and conns.peer above it
Packages depending on std (#63)Yes, separately, after slice 1
UDP and Unix socketsNo asker. Conn is deliberately not named TcpConn — if a second transport arrives it should find the name free — but nothing here is built for it

10. What slice 1 built, and what building it corrected

Built: Listener, Conn, the five answers (Listening, Accepted, Received, Sent, and Accepted’s Again), tcp_listen, tcp_accept, conn_read, conn_write, conn_nonblocking, listener_nonblocking, conn_close, listener_close, all edition 5, on both backends. Slice 2 added tcp_connect (Dialed): connect’s bound check and getaddrinfo walk, answering a Conn. Failed(-1) is a name that did not resolve – no errno is negative – and any positive value is the kernel’s. Slice 3 added the Poller (§4, below). Slice 4 added the Clock (§10.2). Not yet built: conn_raw_fd. Nine conformance tests over real sockets run every program on both backends (conformance/sockets.rs), plus six corpus fixtures that pin the refusals (a forged, dismantled or leaked handle; an undeclared conn_read; the builtins at edition 4) and the edition-1 name freedom.

Four things the design did not know:

  1. bind has been wrong on macOS since it was written. It passes SOL_SOCKET = 1 and SO_REUSEADDR = 2 – Linux’s numbers; Darwin’s are 0xffff and 4. On macOS the setsockopt quietly did nothing and nobody saw it, because a failed setsockopt is ignored and a bind to a fresh port needs no reuse. tcp_listen takes its constants from cancho_ir::SocketOs, one table both backends read; the old bind is untouched, as §9 decided. A program that needs SO_REUSEADDR on macOS should use tcp_listen.
  2. A blocking recv of zero bytes waits for data. conn_read into an empty buffer first reached the kernel, hung, and would have reported end-of-stream had it returned. It now never reaches the kernel and answers Failed(EINVAL).
  3. accept hands a connection its listener’s O_NONBLOCK on Darwin and not on Linux. A Conn arrives blocking on both – Darwin clears the flag after accept – so a program’s first read means the same thing on either.
  4. fcntl is variadic, and Cranelift cannot say so. LLVM declares it i32 (i32, i32, ...) and the call is correct everywhere. Cranelift’s modules refuse two signatures for one symbol, so it declares one, always with a third argument; on Apple arm64 only that one has nine integer parameters – the first eight in registers, the ninth in the first stack slot, which is where va_arg reads a variadic argument. Verified by reasoning and by the macOS CI job, not on a Mac.

The non-blocking switches answer 0 or the errno (not the -1 of fcntl): a sentinel is how fs_read came to disagree with getchar.

10.1 The Poller as built

poller_new() -> Polling (Ok(Poller) | Failed(errno); it needs no capability, since a set watching nothing observes nothing), poller_add_listener(&!Poller, &Listener, token), poller_add_conn(&!Poller, &Conn, token, events), poller_modify, poller_remove(&!Poller, &Conn), poller_wait(&!Poller, &![int], timeout_ms) and poller_close. events is 1 for readable, 2 for writable. The first five answer 0 or the errno; poller_wait answers how many (token, events) pairs it wrote into the slice – at most 64 a call and at most len / 2 – or -errno, and a negative timeout waits for ever. The label is poll, discharged by owning a Poller or the Net that made what it watches.

  • Level-triggered on both kernels, which is what poll(2) was: a connection with unread data is reported again next wait. kqueue is level-triggered without EV_CLEAR.
  • Error and hang-up read as readable, so the read is what reports them. This mapping is not exercised by a test: a clean FIN already sets EPOLLIN, and the mutation that dropped EPOLLERR | EPOLLHUP survived. It needs a peer that resets, which std’s TcpStream cannot do without SO_LINGER.
  • A token can arrive twice in one wait on macOS, once per filter (kqueue reports readable and writable separately); on Linux they are one entry. A program ORs the events of a token, which is correct on both.
  • epoll_event is 12 packed bytes on x86-64 and 16 elsewhere, and kevent is 32; both are written and read by the compiler. The kqueue path – two single-filter kevent changes per registration, a timespec timeout – is verified only by macOS CI.
  • Closing a handle removes it from the set (both kernels do), so a closed Conn cannot leave a stale entry for a new connection with the same descriptor number to inherit.

10.2 The Clock as built

Clock is a leaf-free capability like Io, carried by a third Split (PRELUDE_SPLIT_CLOCK): an edition-5 split(world) answers seven fields, io, ffi, fs, heap, args, net, clock. That broke every edition-5 file that destructured six – there were none outside this document’s own tests, which is the argument for adding it now rather than later. clock_ms(&Clock) -> int is CLOCK_MONOTONIC in milliseconds (clock id 1 on Linux, 6 on Darwin) and performs the path-free label clock; owning a Clock discharges it. The test measures a 1.1 s wait on an empty Poller and asserts it reads as 1000-3000. It was 120 ms first, and the mutation that multiplied seconds by 1 instead of 1000 survived it: the sub-second part alone measures a short wait correctly, and the bug shows only when the span crosses a second boundary, which a wait over a second always does.

10.3 What migrating the server found: a resource cannot be in a table

examples/api holds up to 1,024 connections and finds each by the number the Poller returns. Conn is a resource, and no table can hold one: std.vec and std.map hold only copyable things (collections.md §2), because freeing an array runs nothing and an obligation inside it would be dropped; the one container for resources is std.list, a chain of boxes, where finding connection 700 walks 700 links. Making Conn copyable would fix that and bring back what a typed handle is for – a stale copy closing the wrong connection after the descriptor is reused.

So two builtins and a library:

  • conn_detach(Conn) -> int ends the Conn and answers a ticket; the descriptor stays open. -1 means it could not (a descriptor beyond the table), and the connection was closed rather than leaked.
  • conn_attach(int) -> Attached (Ok(Conn) | Failed(EBADF)) turns a ticket back into the Conn once.
  • std.conns is a Table of tickets over an ordinary Vec[int], with put, read, write, nonblocking, watch/rewatch, close and drop by slot number. Each is attach, the one builtin, detach: the Conn exists for exactly that long, so linearity does not stop at the table’s edge. A free slot’s cell holds the next free slot, so close needs no heap. drop closes every connection left.

Why a ticket is not authority. The runtime keeps a 32-bit counter per descriptor below 65,536 (256 KiB of bss, one global in both backends). A ticket is epoch << 32 | descriptor. conn_detach bumps the counter to an odd value and issues it; conn_attach accepts a ticket only if the descriptor is in range, the epoch is odd, and it equals the descriptor’s current counter – then bumps it to even. So conn_attach(1) – standard output as a guess – fails (the counter is 0, not odd and not 1), a ticket copied before redemption fails the second time, and a ticket whose connection has since been closed fails after the number is reused. The epoch is masked to 31 bits so a ticket stays non-negative; parity survives the wrap.

Tests: nine forged tickets refused (the standard streams, a descriptor never ticketed, negatives, i64::MAX, past the table); a ticket redeemed twice and a ticket after conn_close; a table of three clients with slot order, each read and write finding its own client, a freed slot refusing stale use and being reused, and drop closing the rest. Mutation-checked: validity without the epoch comparison, an attach that does not spend, and a detach that does not bump.

The cost is a number to be measured, not argued: every read or write through the table is two extra builtin calls and two table accesses. The api benchmark below says whether it matters.

10.4 What measuring the migration found: epoll is slower here

examples/api fell from about 136,000 to about 73,000 requests a second when its poll loop became a Poller, and a C server with no parsing falls the same way (about 150,000 to about 80,000) when only its poll is swapped for epoll. §4’s claim that epoll is the scalable choice and “a performance change as well as a safety one” is therefore not borne out: in a Firecracker VM with four cores, poll was faster at 32 and at 300 connections. The full account, with what was ruled out, is server.md §8. It does not touch the safety half of the argument – a Poller still names handles by token and never by descriptor – and it makes one open question of the performance half: whether the Linux Poller should be poll(2)-backed, which needs a registration table the runtime owns. Not decided here; the number is the reason to decide it.

10.5 clock_unix_ms: what a webhook service found missing

Section 5 chose a monotonic clock for timeouts and said why: a wall clock that jumps forward would close every connection at once. That choice left a program with no way to ask what day it is. cancho-hooks (a webhook delivery service) signs each delivery with Standard Webhooks, whose webhook-timestamp header is integer Unix seconds and which a receiver checks against its own clock, so a message stamped with a monotonic reading (here, 425,112 ms since an arbitrary origin) is refused as more than fifty years old.

clock_unix_ms(&Clock) -> [clock] int is the second builtin on the same capability: milliseconds since 1970-01-01 UTC, from CLOCK_REALTIME (id 0 on both Linux and Darwin), read the way clock_ms reads CLOCK_MONOTONIC. It reports the same clock label, so a program that stamps messages says so, and a program without a Clock cannot. It is for stamping, never for timing: it can step backwards when the host’s clock is set, and a timeout or a retry schedule built on it can fire early, late or never. The doc comment on the builtin says so; nothing in the compiler can enforce it.

Verified by the_wall_clock_reads_unix_milliseconds, which runs a program on both backends and requires the number it prints to lie between two readings of the host’s clock taken either side of the run; changing either backend to read the monotonic clock makes it fail (checked on each). Not verified: Darwin, where CLOCK_REALTIME is 0 by the platform headers and not by a run.

10.6 tcp_connect_start: what a delivery service measured

tcp_connect waits until the kernel has an answer. For a server that dials other people (cancho-hooks delivers webhooks) that is the whole service stopping for as long as one peer takes: a receiver whose accept queue is full makes the kernel drop the SYN, and the connect retries for minutes. Measured in cancho-hooks (docs/design.md section 15), POST /events was held for 10 s or more by one such endpoint, and for 255 ms a request by an endpoint that merely answered after 300 ms, because the attempt ran on the thread that serves ingest. The read side already had a remedy (a Poller and a deadline); the connect did not.

Two builtins, both edition 5:

tcp_connect_start(net, host, port) -> Dialed       // [net_out(bound)], like tcp_connect
conn_connect_status(&!Conn)        -> int          // [], 0 connected, or the errno

tcp_connect_start is tcp_connect with one change: the socket is made non-blocking before connect, and an answer of EINPROGRESS (115 on Linux, 36 on Darwin) is a success, not a failure. The Conn it answers is non-blocking and may not be connected yet: register it with a Poller for writable (events 2), and when the poller reports it, conn_connect_status reads SO_ERROR: 0 means connected, anything else is the errno the connection failed with (111, refused, for a closed port). It is the same authority as tcp_connect (net_out("host:port"), nothing foreign; checked by a conformance test), and the Dialed it answers has the same two leaves; a refusal that the kernel reports at once comes back as Failed(errno), one it reports later comes through conn_connect_status.

Call conn_connect_status only after the poller has said writable (or hung up). Before that SO_ERROR is 0 whether or not the connection is made, and the builtin has no way to say “not yet” without inventing a sentinel, which this document has argued against throughout. The protocol is the one every non-blocking connect has had since 4.2BSD, written as two typed calls.

A name is still resolved by a blocking call. getaddrinfo has no non-blocking form in libc; tcp_connect_start calls it as tcp_connect does, so a host name can still stall the caller for as long as the resolver takes, and an IP literal cannot (it resolves without a lookup). A service that needs names without stalling needs a resolver of its own, which is not built.

std.conns.connect_status(table, slot) is the same call for a connection held in a Table. Verified, on both backends: a_started_connection_works_once_it_is_writable (writable, status 0, bytes both ways); a_started_connection_to_a_closed_port_reports_the_errno (a positive errno, from the call or from the status); a_started_connection_does_not_wait_for_a_peer_that_never_answers (the test fills a listener’s accept queue until a connect no longer completes, then runs a program that starts a connection to it and must exit within seconds: a blocking connect would still be waiting); and the authority report. Six mutants (no O_NONBLOCK before connect, EINPROGRESS treated as a failure, the status always 0, each on each backend) are all killed. Not verified: Darwin. Its EINPROGRESS (36), SOL_SOCKET (0xffff) and SO_ERROR (0x1007) are from the platform headers, not from a run.

11. conn_nodelay: what a proxy found it needed

A connection pooler for PostgreSQL (cancho-pg, docs/pooler.md) forwards what one peer sends to another. Its first slices ran with no way to set a socket option, and the stall it hit is the old one: Nagle’s algorithm holds back a small write while an earlier write is unacknowledged, and the peer’s delayed acknowledgement answers it tens of milliseconds later. Measured on loopback, one outstanding request at a time, a 70,000-byte result:

throughmedian round trip
PostgreSQL directly0.48 ms
the proxy, TCP_NODELAY not set44.0 ms (p99 56 ms)
the same proxy with TCP_NODELAY set on both of its sockets (by a throwaway LD_PRELOAD shim, before this builtin)0.58 ms

A 3,000-byte result (one segment) shows no difference (0.10 against 0.06 ms): the stall needs a response that takes more than one write. A second case in the same program: PostgreSQL’s extended protocol, forwarded message by message, fell from 25,700 to 90 transactions a second at fifty clients; that one the proxy fixes itself by sending the messages that arrived together in one write, but the first case no program can fix without the option. PgBouncer sets TCP_NODELAY (and SO_KEEPALIVE) on every socket for this reason, which strace shows.

conn_nodelay(&!Conn) -> [] int turns TCP_NODELAY on: 0, or the errno, as conn_nonblocking answers. It has no effect row (it names no resource: it changes how one handle already held sends), is edition 5 on both backends (setsockopt with IPPROTO_TCP 6 and TCP_NODELAY 1, the same numbers on Linux and Darwin), and std.conns has nodelay(table, slot) beside nonblocking. It is one way: nothing turns it off, because nothing here has asked to. SO_KEEPALIVE is not here; the first asker for it will be a server that holds idle connections open for a long time.

The test (conn_nodelay_turns_the_option_on_for_an_accepted_connection) runs on both backends and, where strace is installed, reads the setsockopt the program made and its answer, since a status of 0 alone cannot tell a call that set the option from one that did nothing; a mutant that sets TCP_MAXSEG instead is killed on each backend.

12. tcp_listen_on: listening on one address (#498)

Status: built (edition 9). §12.1-§12.6 were written before the code; §12.7 records what the gates measured. A claim a gate showed false is corrected in place.

12.1 What is wrong

tcp_listen(net, port, backlog, flags) binds INADDR_ANY (crates/cancho-codegen/src/body/sockets.rs, tcp_listen: family byte, the port, and twelve zero bytes), so every listener this language can make is reachable from every interface the machine has. The capability’s bound for the inbound half is the port alone (listen.md §6.1), so cancho authority prints net_in("8080"), which names no address: the report cannot tell a server meant for a local proxy from one open to the network, and no program can say which it is. cancho-ml’s ml-serve (#498, last comment) measured the cost: a connection through the machine’s non-loopback address succeeds, so it cannot default to loopback and refuses to start without a bearer token or --no-token.

12.2 The surface

One new builtin, edition 9, and nothing else changes:

tcp_listen_on(net, host, port, backlog, flags) -> [net_in(bound)] Listening

It is tcp_listen with a second argument: host, a string literal holding an IPv4 address in dotted-quad text ("127.0.0.1", "0.0.0.0"). It answers the same Listening, consumes nothing the old call does not, and the handle it makes is the same Listener: tcp_accept, listener_nonblocking, poller_add_listener and listener_close neither know nor care which call made it.

It is a new name rather than a fifth argument on tcp_listen for the reason every addition here is (editions.md §6.4): edition 5 is closed, a five-argument tcp_listen would be new surface in a closed edition, and the checker would have to choose between two meanings of one name by counting arguments. Edition 9 is the latest, tcp_listen_on is a name a program may declare for itself, and an earlier file keeps its own (tests/reject/tcp_listen_on_is_edition_nine.cho).

The host is a literal because the row has to be exact. A host read at run time cannot appear in a report written at compile time, and the point of the issue is the report. A program that takes the address from its command line chooses between literals, which is also the program a reviewer can read:

if everywhere { tcp_listen_on(n, "0.0.0.0", port, 8, 0) } else { tcp_listen_on(n, "127.0.0.1", port, 8, 0) }

(Not an address the operator types: that is what is not done, §12.8.)

12.3 The bound names the host and the port

A Net’s bound is one string. For the inbound half it was a port, and it now has three shapes, read the way net.md §4 already reads the outbound host:port:

boundwhat a listener may betcp_listentcp_listen_on
"" (unnarrowed)any address, any portyesyes
"8080"port 8080 on every interfaceyes, as todayyes: any host, port 8080 (a subset of what the bound grants)
"127.0.0.1:8080"port 8080 on 127.0.0.1 and nowhere elserefusedhost "127.0.0.1", port 8080
"127.0.0.1:" or "127.0.0.1"any port on 127.0.0.1refusedhost "127.0.0.1"
"0.0.0.0:8080"port 8080 on every interface, said out loudrefused (use the bare port)any host, port 8080

The host half compares exactly, not as a prefix (net-bounds.md §2.3 is the argument: 127.0.0.1 as a prefix admits 127.0.0.10). 0.0.0.0 in a bound is the wildcard and covers every host, loopback included; an address in a bound covers only itself. A bound whose host half is not an IPv4 literal (localhost:80, ::1, a name) covers no tcp_listen_on host at all: there is no resolution (§12.8), so it cannot be matched, and the call is refused rather than guessed.

The row is the capability’s bound, as it is for every other net_in. A program that listens only on loopback narrows to "127.0.0.1:8080", and cancho authority prints net_in("127.0.0.1:8080"); a program that listens everywhere narrows to "8080" or "0.0.0.0:8080" and its row says that. A program that never narrows has net_in("") whichever form it calls, which is what the report already says about every unnarrowed capability (net-bounds.md §2.4: an empty bound is the root, not a bound). The host is therefore enforced where it can be checked, at the call, and reported where the program wrote it down, at the narrow. The old call’s row is what it was.

12.4 Refusals (one new rule)

Every refusal below is the new rule listen-host, at the host literal (or at the call, for the old form), with the reason in the message, and has a fixture under tests/reject/:

  1. host is not a string literal (a variable, a call, a concatenation).
  2. host is not a dotted-quad IPv4 literal: a name, "localhost", an IPv6 text, a short form such as "127.1", a leading zero ("010.0.0.1", ambiguous between octal and decimal in C’s parsers), an octet over 255, a trailing dot, whitespace, the empty string.
  3. host is not covered by the Net’s bound (§12.3).
  4. The old form, tcp_listen, on a Net whose bound has a host half: it would bind every interface, which that bound does not grant. Today such a call compiles and traps at run time on every execution (the bound parses as no port, so it reads as port -1); making it a refusal turns a program that could never work into one that says why. An unnarrowed or port-only bound is untouched.

The port is still checked at run time against the bound’s port half (HEAP_OUT_OF_BOUNDS on a mismatch, exactly as tcp_listen’s). Nothing here can reach a panic: the host is parsed by Ipv4Addr-style code over a &str with no indexing, and a value the backend does not understand is the checker/backend invariant every other node already states.

12.5 Compatibility

Every program that calls tcp_listen(net, port, backlog, flags) keeps its behaviour and its authority row. The old call is not rewritten, re-routed or re-parsed: it binds INADDR_ANY, listens on every interface, and its row is the bound of its Net, net_in("8080"). That is the existing behaviour and this change does not alter it; the default is not changed to loopback. The one observable difference is §12.4 item 4, which touches only a program that was already trapping. packages/http-server, cancho-web, cancho-gateway, cancho-hooks, cancho-mqtt and examples/ call the old form and are not edited (§12.7 lists the ones run). The new node is a separate Expr::TcpListen argument that the old lowering never produces, so the code the old call generates is byte-identical: the identity hashes (cancho ids) of the existing programs do not move (checked in §12.7).

udp_bind has the same all-interfaces behaviour and is not changed (§12.8).

12.6 Gates (each must be able to fail)

  1. Reachability, real sockets, both backends. A program listening with "127.0.0.1" accepts a connection to 127.0.0.1 and refuses a connection to the machine’s non-loopback IPv4 address (found by asking the OS which source address routes to a public address, without sending anything; the test is skipped, with a message, on a machine that has none). A program listening with "0.0.0.0" and one using the old form accept on both.
  2. Authority report. The JSON report of a loopback server carries net_in "127.0.0.1:PORT", the everywhere server’s net_in carries the bare port, and the old form’s report is unchanged.
  3. Narrowing. A Net narrowed to "127.0.0.1:PORT" refuses tcp_listen and tcp_listen_on(.., "0.0.0.0", ..) and "127.0.0.2", and accepts "127.0.0.1".
  4. Refusals of §12.4 each with the tag, and a generated set of hostile host texts (octets, signs, unicode, lengths) that must refuse or accept per an independent parser and never panic.
  5. Edition. The name is absent before edition 9.
  6. Mutants, each of which must fail a gate above: the bound check removed; loopback bound when everywhere was asked, and the reverse; the report row naming the wrong host; the host compared as a prefix; the old form made to bind loopback.
  7. The repository’s full gate (fmt, clippy -D warnings, cargo test --workspace, which includes the file budget, the rule fixtures, the fuzzer and the differential tests).

12.7 Measured

Measured on macOS (darwin-aarch64), where the machine’s other IPv4 address was its LAN address; the Linux half of CI runs the same tests.

  • Reachability (§12.6 gate 1), both backends. A tcp_listen_on(.., "127.0.0.1", ..) listener answered 127.0.0.1 and was refused (ConnectionRefused) on the LAN address; "0.0.0.0" and the old tcp_listen answered both. A bare-port bound with the loopback literal also bound loopback only. The non-loopback half was run, not skipped, here; on a machine with no such address the test prints SKIPPED and checks loopback only.
  • Tests added: 12 conformance tests (listen_host.rs, each on both backends where it opens a socket), 7 unit tests (cancho-ir/src/tests/listen.rs, including 3,000+ generated dotted quads checked against std::net::Ipv4Addr), 5 tests/reject fixtures (all under listen-host, plus the edition gate). The 16-row narrowing table (the_bound_decides_which_calls_are_allowed) is the statement of §12.3.
  • Mutants, 15, all killed (each run against the unit and conformance tests): host check always true; host compared as a prefix; wildcard covering nothing; each backend ignoring the address (everywhere when loopback is asked); each backend’s old form binding loopback; each backend’s byte order; the row dropping the host; the row naming 0.0.0.0; the old form allowed on a host bound; the port check dropped; the parser accepting a leading zero and 256. Corrected in place: the first run reported the dropped port check as surviving, because the test waited on a server that never exits and the run killed it; the test now has a deadline and fails if the program is still listening.
  • Differential / existing gates. cargo test --workspace including the rule-fixture, file-budget (files.rs), fuzz and self-hosting table tests. Adding the builtin moved three generated self-hosting tables and the WASI refused-set count (69 to 70, wasm.md); the variant is appended to Builtin::ALL so no existing position moved.

12.8 Not done

  • IPv6. The listener is AF_INET. ::1 is refused (§12.4 item 2). Symmetric in principle (sockaddr_in6, IPV6_V6ONLY), but a second socket family reaches tcp_accept, the peer address (conn_peer) and the poller, and nobody has asked.
  • Names. No DNS, no "localhost": a listener’s address must not depend on a resolver (net-bounds.md §2.3), and a name can resolve to an address that is not loopback.
  • A host chosen at run time. Only literals, for §12.2’s reason. A service that lets its operator pick any address needs a different capability shape; the choice between a fixed set of literals is expressible today.
  • Changing the default of tcp_listen to loopback. That is the compatibility question the issue left open and this answers it “no”: it would change the behaviour of every deployed server.
  • udp_bind and Net’s outbound bound (still a prefix, net-bounds.md).
  • TLS is orthogonal and untouched.

UDP: datagram sockets without Ffi("libc")

Status: built, both halves (§9 and §10); the round-trip measurement against C is not done. Issue #355. The asker is cancho-dns#18, a forwarding and caching DNS resolver. native-sockets.md §9 recorded “UDP and Unix sockets: no asker” and kept Conn free of a transport name in case one came; this is that second transport. Everything here is edition 5, on both backends, IPv4 (as tcp_listen and tcp_connect are today).


1. What is missing, and why a resolver needs all of it

cancho has no datagram socket. std/ has none, native-sockets.md builds Listener and Conn over SOCK_STREAM only, and Poller watches only those. DNS is UDP first (RFC 1035 §4.2.1); TCP is the fallback for an answer that did not fit. A resolver that cannot speak UDP is not a drop-in for a stub resolver, and its security story rests on things only UDP has: a random source port per upstream query, and a kernel that drops a datagram from the wrong peer.

Three things a program must be able to do, each with an authority the report can name:

the program wants toauthorityshape
ask one upstream a question and read its answernet_out("203.0.113.53:53")a connected datagram socket
answer clients on a portnet_in("53")a bound datagram socket
reply to this client, laternone beyond the bound socketa peer it was sent by

2. The central decision: connected and bound, never “send to anything”

The issue’s main question was what a sendto peer is checked against. The answer is that there is no sendto with a free destination, because nothing could check one: the bound in a Net is a literal (net.md §4), and a destination chosen at run time is not. So:

  • udp_connect(net, host, port) checks host and port against the Net’s bound exactly as tcp_connect does (connect.md §10.1: a prefix on the host, equality on the port), resolves, and calls connect(2) on a SOCK_DGRAM socket. Its row is net_out(bound), the same row tcp_connect has. From then on the kernel itself sends only to that peer and drops datagrams from any other source, so the report’s claim (“this program talks to that upstream and nothing else”) holds for sending and receiving.
  • udp_bind(net, port) is tcp_listen for datagrams: net_in(bound), a port, SO_REUSEADDR. It receives from anyone, as a server must, and it can send only to a peer it received from (§4). A bound socket therefore cannot be turned into a way to reach a host that has not first written to it.
  • There is no udp_send_to(&Udp, bytes, host, port). A program that wants a second destination connects a second socket and the report shows both.

This is the only shape in which cancho-dns’s headline holds: reaching the upstreams in its generated set and no others.

3. The handle and its verbs

One new res type, no literal form, one descriptor leaf, consumed by udp_close (native-sockets.md §3 and file-handles.md §4.1: the handle is the capability). It is not Conn: a datagram socket has different answers (an empty datagram is a datagram, and an oversize one is reported rather than cut), and a Conn that sometimes had message boundaries would make every conn_read caller wonder.

enum UdpOpened  { Ok(Udp), Failed(int) }                     // errno; -1 = name did not resolve (as Dialed)
enum Datagram { Got(int), Truncated(int), Again, Failed(int) }

Got(n) is a datagram of n bytes, n may be 0. Truncated(n) means the datagram was n bytes and the buffer held fewer; the buffer holds the first bytes and the rest are gone. A program that does not care reads it like Got; a parser that must not act on a cut message refuses it, with its own rule tag. Again is EAGAIN/EWOULDBLOCK on a non-blocking socket. Sent (existing) is reused: Wrote(n) is always the whole datagram or the call fails, because a datagram is never partial, and Again is a full send buffer.

builtinsignaturerow
udp_connect(net, host, port) -> UdpOpenednet_out(bound)
udp_bind(net, port, flags) -> UdpOpenednet_in(bound) (flags bit 1 is SO_REUSEPORT, as tcp_listen’s)
udp_recv(&!Udp, &![byte]) -> Datagramudp_recv
udp_send(&!Udp, &[byte]) -> Sentudp_send
udp_recv_from(&!Udp, &![byte], &![int]) -> Datagramudp_recv (§4)
udp_send_to(&!Udp, &[byte], int) -> Sentudp_send (§4)
udp_local_port(&Udp) -> int[] (§5)
udp_nonblocking(&!Udp) -> int[]
udp_close(Udp) -> int[]
poller_add_udp(&!Poller, &Udp, token, events) -> intpoll

udp_send on a bound socket, and udp_send_to on a connected one, answer Failed(EDESTADDRREQ) / Failed(EISCONN)-style errors from the kernel; the checker does not distinguish the two types (they are one type, with a mode), and a test pins the answers. poller_add_udp registers for readable: a datagram socket is writable nearly always, and a full send buffer is reported by Again.

A receive of a zero-length buffer answers Failed(EINVAL) without reaching the kernel, as conn_read does (native-sockets.md §10 item 2): a datagram of any size is then Truncated, never silently empty. MSG_TRUNC is what lets Linux report the real length; Darwin has no equivalent in recv, so there the length is the buffer size and Truncated means “at least as big as the buffer” (§7 states it as unmeasured).

4. A peer is a ticket, not an address

A server replies to the client that wrote to it, often after waiting for something else (a resolver answers a cache miss only after its upstream does). So the sender’s address must outlive the next udp_recv. It cannot be a number the program can write, or udp_send_to(u, bytes, 0x7f000001_0035) would be a free sendto.

udp_recv_from (a bound socket’s receive; udp_recv stays the connected socket’s and records nothing) writes the sender into a runtime ring of 65,536 entries (a sockaddr_in, the ticket it was issued under and the descriptor that heard it: 32 bytes each, 2 MiB of bss, one global in each backend, like conn_detach’s epochs in native-sockets.md §10.3), and writes a ticket for it into the first cell of an int slice the caller passes. (The first draft of this section had a separate udp_peer(&!Udp) that answered the last sender; an out-parameter needs no per-socket state and cannot return another datagram’s ticket, so it is gone.) A ticket is the running count of datagrams received, so the entry it names is ticket mod 65,536. udp_send_to accepts a ticket only if it is positive, the entry still holds that very ticket, and the entry was recorded by this socket’s descriptor, so:

  • a number the program invented is refused (Failed(EBADF)), as conn_attach(1) is;
  • a ticket from another socket is refused, so the reach of a bound socket stays “those who wrote to it”;
  • a ticket older than 65,536 received datagrams is refused. This is the one real limit, stated up front: a resolver holding a pending query across more than 65,536 other datagrams loses the reply, and the program gets EBADF it can count. Nothing is silently misdelivered.

The row for udp_send_to is udp_send, not net_out: the bound socket’s authority is net_in, and replying to a sender is not new reach. A test shows that a program with only net_in("53") cannot name any other destination.

5. The source port

DNS needs an unpredictable source port per upstream query (the Kaminsky defence). Linux and Darwin choose ephemeral ports for an unbound socket’s connect(2), Linux with randomisation. So the program’s port for an upstream query is the kernel’s, and the way to get a fresh one is a fresh socket: udp_connect, exchange, udp_close. That costs a socket, a connect and a close a query. udp_local_port answers the port the kernel chose, for logging and for a test that two sockets differ.

What the design does not give is a program-chosen range or its own randomisation; that would need the program to bind before connect, which needs a net_in row for a port it does not mean to listen on. That is a real cost to the authority report and nothing asks for it yet.

6. Randomness stays out

The language has no randomness source (tls-pure.md: a ChaCha20 DRBG seeded from /dev/urandom through Fs). Transaction IDs and 0x20 case randomisation reach it. That is cancho-dns’s row to carry, not this document’s, and it is stated here so the two designs do not each assume the other solved it.

7. What building it must prove

  1. Mutation checks on every row: udp_recv answering Got for a truncated datagram, dropping Again, ignoring the count; udp_send sending a prefix; a ticket check that skips the descriptor or the sequence. Each mutant is killed or explained.
  2. The forgery tests: no builtin takes a descriptor or an address; Udp { } is rejected; a udp_send_to with an invented, a stale, a wrong-socket and a negative ticket is Failed(EBADF).
  3. The authority tests: a program that uses udp_connect over a narrowed Net reports net_out with its host:port and nothing foreign; one that uses udp_bind reports net_in with its port; udp_connect to a host outside the bound traps as tcp_connect does.
  4. Real sockets, both backends: loopback echo; an empty datagram (Got(0)); an oversize datagram (Truncated); a datagram from a stranger to a connected socket is dropped (it never reaches the program); a peer that never answers (a non-blocking udp_recv is Again); readiness through a Poller.
  5. Measure: a round trip against a C recvfrom/sendto loop on the same machine, reported plainly in this document whichever way it points. No claim of parity is written before the number is.
  6. Not verified, said so: Darwin’s MSG_TRUNC equivalent, SO_NOSIGPIPE irrelevance, and kqueue for the new handle are from platform headers and CI, not from a run here.

8. Slices

  1. This document.
  2. The connected half: Udp, UdpOpened, Datagram, udp_connect, udp_send, udp_recv, udp_nonblocking, udp_close, udp_local_port, poller_add_udp; tests and mutants.
  3. The bound half: udp_bind, the peer ring, udp_recv_from, udp_send_to; the forgery tests (§10).
  4. std.conns-style table for Udp tickets, if a program asks (a resolver holding many upstream sockets will); decided by the programs, not here. A program asked (cancho-dns D3: one connected socket per query in flight, a source port per query, the only form that keeps the defence of docs/design.md §6). Built as §11.
QuestionSettled
One type or two (bound and connected)?One, with a mode; the kernel answers the wrong-mode call and a test pins it
sendto with a free destinationNo (§2): unprovable against a literal bound
Peer as an address, an index or bytesA ticket (§4), checked against the receiving socket
Source portThe kernel’s, a fresh socket per query (§5); a program-chosen port needs a net_in it does not mean
Reuse ConnNo: empty datagrams and truncation are different answers
IPv6, multicast, broadcast, recvmmsg, Unix datagram, DTLSOut, as the issue says

9. What slice 2 built, and what is not done

Built, edition 5, both backends: Udp, UdpOpened, Datagram, udp_connect, udp_send, udp_recv, udp_local_port, udp_nonblocking, udp_close and poller_add_udp. udp_connect is tcp_connect’s node (Expr::TcpConnect with a datagram flag), so the bound check, the resolver walk and the net_out(bound) row are the same code; udp_send is conn_write’s emitter and udp_nonblocking is conn_nonblocking’s. poller_add_udp takes events as poller_add_conn does (§3’s table is corrected to say so), and there is no modify or remove: closing the socket removes it.

Checked, by conformance/udp.rs on both backends: a datagram exchanged with a peer; an empty datagram as Got(0); a 100-byte datagram into 16 bytes as Truncated(100) (Linux); a datagram from a stranger never reaching a connected socket; Again on a non-blocking socket with nothing waiting, and Failed(EINVAL) for an empty buffer; two sockets with two distinct ephemeral ports; a Poller reporting readability; udp_connect to a host or a port outside the bound trapping; and the authority report (net_out with the host:port, udp_send and udp_recv, no ffi, no net_in). Five reject fixtures: a forged Udp { }, a Udp taken apart, one left open, udp_recv without its label, and the builtins at edition 4.

Mutants, all killed: truncation never reported (each backend); MSG_TRUNC dropped on Linux; the empty-buffer guard moved; a datagram connect over a stream socket (each backend); the local port always 0; the local port’s high byte read from the wrong offset (LLVM); udp_nonblocking a no-op. Two survived the first version of the tests and were fixed by strengthening them, not by excusing them: the port test only checked “positive and different”, which a port read from the wrong bytes also satisfies (it now requires an ephemeral port, above 1023), and a blocking receive hung the suite instead of failing it (the harness now kills a program after 20 seconds).

Found. Adding builtins and types moved four things that pin counts, each corrected in place: the generated examples/selfhost/tables.cho (regenerated with UPDATE_SELFHOST_TABLES=1), the WASI refused set (48 to 55, docs/wasm.md), the checker’s label count (26 to 28: udp_recv, udp_send), and the type and builtin counts in docs/self-hosting.md. The datagram builtins are refused on WASI as sockets are.

Not done in slice 2. The bound half (§10). The round-trip measurement against a C loop (§7 item 5): no number is claimed. Darwin: the nine tests above passed in CI on the darwin-aarch64 runner (the Truncated rule, getsockname and the kqueue registration included); that is the CI result, not a run on a Mac here.

10. What slice 3 built, and what is not done

Built, edition 5, both backends: udp_bind(net, port, flags), udp_recv_from and udp_send_to, and the peer ring behind them. udp_bind is tcp_listen’s node (Expr::TcpListen with a datagram flag, and a backlog of 0 the lowering supplies), so the port check against the Net’s bound, SO_REUSEADDR, SO_REUSEPORT and the net_in(bound) row are the same code; it simply does not call listen. The ring is one zeroed global, lexs_udp_peers, in each backend.

The decision that held. A bound socket has no free destination: udp_send_to takes a ticket and nothing else. A program holding only net_in("PORT") has no net_out in its report (checked), and cannot name a host that has not written to it.

Checked, on both backends: a server answering the sender it heard from; two senders answered in the opposite order of arrival, each getting its own reply (the ticket outlives the next receive); five forged tickets refused with Failed(EBADF) and nothing sent, the client then seeing exactly the one real reply (a ticket never issued, 0, negative, and the same slot one ring’s length ahead and behind); a ticket from a different bound socket refused; a ticket older than the ring refused (the program feeds itself 65,536 datagrams through a connected socket, so no client can drop any, then replies late); an empty ticket slice as Failed(EINVAL) and a receive that delivered nothing leaving the cell untouched; udp_bind outside the bound trapping; and the authority report (net_in with the port, udp_send and udp_recv, no net_out, no ffi). One more reject fixture, udp_send_to_not_declared.

Mutants, all killed on each backend that has the code: the owner check dropped; the ticket not compared; the ticket compared only modulo the ring (killed by the ticket one ring ahead); listen called on a datagram socket.

The limit, as designed. A reply held across more than 65,536 other received datagrams is refused. At 30,000 datagrams a second that is about two seconds, which is longer than a resolver waits for an upstream but not by an order of magnitude; a program that holds replies longer must answer or drop them sooner. Only udp_recv_from fills the ring, so a forwarder’s connected sockets (udp_recv) do not age its clients’ tickets.

Not done. The round-trip measurement against a C loop. A std table of Udp handles for a program that holds many upstream sockets (slice 4: built, §11). The ring is a fixed size; a larger one is a constant in socket_os.rs. Darwin: this slice’s tests (the ring, the tickets, udp_bind) passed on the darwin-aarch64 CI job of #358; that is the CI result, not a run on a Mac here.

11. Slice 4: tickets for datagram sockets

Why now. cancho-dns D3 sends every upstream query from a fresh connected socket, so that its source port is the kernel’s and unpredictable (§5), and it has hundreds of queries in flight. Udp is a resource and std.vec holds only copyable things (native-sockets.md §10.3), so there was nowhere to put them: found by reading the forwarder’s design against the language before writing it, not by a failure.

What it is. conn_detach/conn_attach again, for Udp:

  • udp_detach(Udp) -> int ends the handle, leaves the descriptor open and answers a ticket; -1 means the descriptor is past the epoch table and the socket was closed rather than leaked.
  • udp_attach(int) -> UdpOpened redeems a ticket once (Ok(Udp)), or Failed(EBADF): never issued, already redeemed, copied, forged, of the other kind, or for a descriptor since reused. UdpOpened is reused; no new type.
  • std.udps: put, send, recv, nonblocking, local_port, watch, close and drop over a std.conns Table (empty, live and slots are conns’s). A table holds tickets, not sockets, and the kind bit below makes each verb refuse the other kind’s, so one table may hold both; conns gained store, replace, ticket_at and release_slot as public so that the two modules share the slot bookkeeping instead of copying it (the repository refuses a duplicated function body).

One epoch table, a kind bit. Both kinds of ticket use the descriptor epochs of native-sockets.md §10.3, because a descriptor is one thing whatever it carries. Left like that, a Conn’s ticket would redeem as a Udp and the reverse: memory-safe, since both are descriptors, but the wrong verbs on the wrong socket. So a Udp ticket sets bit 31 of its descriptor half. conn_attach reads all 32 bits, finds a descriptor beyond the 65,536-entry table and refuses it; udp_attach requires the bit and reads the other 31. No second table, and the 31-bit epoch and the odd/even rule are unchanged.

Built and tested on both backends (tests/conformance/udp.rs): a ticket from udp_connect redeems and the socket still sends and receives; eight forged redemptions refused (zero, negative, one, i64::MAX, the real ticket plus one, plus two epochs, with the kind bit cleared, and redeemed as a Conn); a second redemption refused; a Conn’s ticket refused by udp_attach and not spent by the refusal; and a three-socket table (slots in order, each send and receive finding its own peer, a closed slot refusing stale use and being reused, drop closing the rest). The builtins are refused on WASI with the others (Gap::Sockets) and are edition 5.

Not done. Nothing here measures what a table costs per datagram (two extra builtin calls and two table accesses, as native-sockets.md §10.3 says for Conn); cancho-dns D3 is the first program that will.

12. Slice 5: who a ticket names

Why. cancho-dns needs to refuse clients outside configured networks and to rate-limit by client network (design.md section 6 there). A bound socket answers a sender through a ticket, deliberately not an address (§4), so a program could not read who had written to it. Found by reading that policy against the language before writing it. (conn_peer for an accepted TCP connection had been added meanwhile for two other programs, conn-peer.md; the datagram half is what was left.)

What it adds, edition 5, refused on WASI with the other socket builtins, no capability and no authority label: udp_peer(&Udp, ticket, &![byte]) -> int writes the sender a ticket names into the caller’s buffer in the form conn_peer uses (19 bytes: family, sixteen address bytes, the port big-endian; the family is always 4 here, as every cancho socket is IPv4), so std.addr.decode reads it and std.addr.key keys a bound on it. It makes the three checks udp_send_to makes (positive, still the one its ring entry holds, issued to this socket) and answers EBADF if one fails; a buffer under 19 bytes answers EINVAL; in neither case is anything written. 0 otherwise.

Why it grants nothing. The address is data the program received anyway. There is still no send to an address, and udp_send_to still takes only a ticket, so the authority report is unchanged: a program can decide about a client, not reach one it was not given.

Built and tested on both backends over real sockets: the sender’s address and port as the client’s own socket reports them; six refused tickets (never issued, zero, negative, a ring’s length ahead and behind, another socket’s) each EBADF; a buffer of 18 bytes EINVAL and untouched. Eight mutants killed (the owner check dropped, the buffer size unchecked, the address read from the wrong offset, a padding byte left unwritten, on each backend).

Not done. IPv6 (a datagram’s sender is IPv4 until sockets are dual-stack).

Signals: knowing you were asked to stop, with an authority that says which

Status: built, edition 6 (written before the code, corrected where building it disagreed; sections 9 and 10 are the corrections and the measurements).

1. Why

A long-running program has to learn that it was asked to stop: SIGTERM from a supervisor, SIGINT from a terminal, SIGHUP to reload. cancho had no way to observe a signal (std.test’s issue, #237 part b). cancho-hooks (a webhook delivery service) did it through Ffi("libc"), and src/ops.cho there says exactly what that cost:

  • Four libc functions (sigblock, sigsetmask, sigpending, signal), reached through Ffi("libc"), which docs/reach.md section 5 established means every authority. The service’s cancho authority report opens with UNBOUNDED, and it was bounded before the signal code was written.
  • No handler. A callback must have an empty effect row (docs/function-values.md section 5), so a handler could set nothing and write nothing. The workaround blocks the signals and polls sigpending once a turn, so a stop is noticed at the loop’s next wake-up (50 ms at most) rather than when it arrives.
  • The mask layout and the numbers are the program’s problem: 16386 is the sigblock mask for SIGINT | SIGTERM on Linux, sigpending fills a 128-byte sigset_t the program indexes by hand, and none of it is right on macOS, where SIGUSR1 is 30.

What a service that only needs to know it was asked to stop should have is a narrow capability: its row names the signals it claims, the report is bounded, and the wake-up is a handle the Poller can wait on, so a stop interrupts the wait instead of waiting out its timeout. The shape is the one docs/native-sockets.md settled for Net, Listener, Conn, Poller and Clock; this document follows it and says where it deviates (section 8).

2. The design

Four prelude types and four builtins, all edition 6.

typewhat it isconsumed by
Signals("INT,TERM")a capability, leaf-free like Io, narrowed to the signals it may claimrelease
SignalWatchthe claim itself: res, one i64 leaf, no literal formsignals_close
Watchingenum { Ok(SignalWatch), Failed(int) } (errno)match
Split (edition 6)edition 5’s seven fields and signals: Signals("")match / destructuring
builtinsignaturerow
signals_watch(&Signals("S")) -> Watchingsignals("S")
signals_pending(&!SignalWatch) -> intsignals_read
poller_add_signals(&!Poller, &SignalWatch, token) -> intpoll
signals_close(SignalWatch) -> intnone (ending a handle is not using one)

narrow(signals, "INT,TERM") is the same narrow the other capabilities have, with the same meaning: it consumes the wider capability and answers a narrower one. The root Signals("") that split hands out can become any set of claimable signals; Signals("INT,TERM") can become Signals("INT") and never Signals("HUP"). signals_watch on the unnarrowed root is refused (capability-misused): a program must say which signals it claims, and “all of them” is not an answer the authority report could print.

edition 6;

// Know that we were asked to stop. The row names the set; the report is bounded.
fn stop_requested[&w](watch: &!w SignalWatch) -> [signals_read] bool {
    return signals_pending(watch) != 0;
}

fn main(world: World) -> [] int {
    let Split { io, ffi, fs, heap, args, net, clock, signals } = split(world);
    release(io); release(ffi); release(fs); release(heap); release(args); release(net); release(clock);
    let stop = narrow(signals, "INT,TERM");
    var status = 1;
    borrow stop as &s in {
        match signals_watch(s) {
            Watching::Ok(w) => {
                var watch = w;
                borrow mut watch as &!wh in {
                    if !stop_requested(wh) { status = 0; }
                }
                signals_close(watch);
            }
            Watching::Failed(e) => { status = 2; }
        }
    }
    release(stop);
    return status;
}

2.1 The set is a type, so the row is exact

The set of signals lives in the capability’s type, as Net’s bound does (Net("8080")), and signals_watch performs the label signals("INT,TERM") with exactly that argument. cancho authority prints it, so which signals a program claims is a fact the report states, and a program that never calls narrow on its Signals claims none. Owning a Signals("S") discharges signals("S") and signals_read; owning a SignalWatch discharges signals_read; a function handed a &!SignalWatch declares [signals_read] (the path was spent where the handle was minted, docs/file-handles.md section 4.1). A World discharges signals(""), the root, as it does net_out("").

The argument is one string because a label carries one string (ffi("libc"), net_out("host:port")), so the set is a canonical comma-separated list: names without the SIG prefix, in alphabetical order, no spaces. narrow accepts any order and the type it answers is the canonical one, so two programs that name the same set have the same type and the same row.

2.2 The claimable signals, and the bitmask

Eight signals are claimable. Each has a bit in the int that signals_pending answers, fixed by cancho and the same on every target, because the signal numbers are not (SIGUSR1 is 10 on Linux and 30 on macOS):

namebitLinuxmacOSwhy it is claimable
HUP111reload, or the terminal went away
INT222Ctrl-C
QUIT433Ctrl-\
TERM81515the supervisor’s request to stop
USR1161030the program’s own
USR2321231the program’s own
ALRM641414an alarm the program set through Ffi
WINCH1282828the terminal changed size

std.signals names the bits (signals.sigterm(), signals.sigint(), …) and has signals.has(mask, bits), so a program says signals.has(mask, signals.sigterm()) and never mask & 8 (section 6).

Every other signal is refused at compile time with the rule signal-not-claimable, in narrow, with a sentence that says which of three reasons applies:

  • cannot be caught: KILL and STOP. The kernel does not let a program have them.
  • a fault, not a request: SEGV, ILL, BUS, FPE (and ABRT, TRAP, SYS). Blocking one that a fault raised does not defer it, the kernel kills the process; cancho itself ends a trap with SIGILL (docs/defined-behaviour.md section 1), so claiming it would make every checked-arithmetic failure look like something the program could handle and then kill it anyway.
  • not claimable (yet): everything else (PIPE, CHLD, CONT, TSTP, TTIN, TTOU, URG, …). PIPE is deliberately not one: conn_write already answers Failed(EPIPE) and never raises it (docs/native-sockets.md section 3). CHLD is not, because cancho has no way to start a process (#237 part a), and on macOS this design would have to ignore it to see it, which turns on automatic reaping. A new signal is one row of a table when a program asks (CONTRIBUTING.md: two askers).

A malformed set (empty, a duplicate, a space, SIGTERM for TERM, lower case) is refused under the same rule.

3. Semantics

What is delivered. signals_pending(w) answers the bits of the watched signals that arrived since the previous call, and clears them. It never waits. 0 is “nothing yet”.

Exactly once, and what coalesces. A signal that arrives is reported by exactly one call. Two different signals that arrive between two calls are both reported (that is the mask). Two arrivals of the same signal between two calls are reported as one: POSIX keeps at most one pending instance of a standard signal, so the count is gone before a program could read it. The bitmask says “at least once since”. A program that needs a count is not asking about stopping.

A second signal. There is no automatic second behaviour: a claim stays a claim, every later signal is reported by a later call, and the program decides. What the hooks service wants, “a second signal ends the process at once”, is signals_close after the first: closing the SignalWatch ends the claim and puts the signals’ disposition back to the default, so the next SIGTERM terminates the process. Two details are part of the contract:

  • A signal that arrived between the last signals_pending and the signals_close is not lost and is not swallowed: it is delivered when the claim ends, with the default action, which for TERM and INT ends the process. This holds on both kernels (on macOS by re-raising what the kqueue still held, section 5), except for a signal that was ignored on entry (next bullet). That is the second signal arriving before the first was acted on, and “kills at once” is what it was asked to do. A program that wants to discard it calls signals_pending last.
  • A signal the process inherited as ignored (nohup ignores HUP; a shell runs a background job with INT and QUIT ignored) is claimable on both kernels, and is read as any other. Afterwards it is ignored again on Linux (the claim never touched the disposition, only the mask) and at the default on macOS (the claim ignored it to watch it and cannot know what it was). The difference is observable only for a signal that was ignored before the claim. The conformance suite found this the way it would be found in use: the tests were first run as a background job, INT was ignored, and “the next INT kills at once” did not.

One claim at a time per signal. Signals are process-wide, so two SignalWatches over one signal would race to read it. A signals_watch over a signal another live SignalWatch already holds answers Failed(EBUSY) (16 on both kernels), and closing the first frees it. narrow consumes the capability, so a program holds one set and the only way to reach the refusal is to borrow it twice; a library handed &Signals("INT,TERM") that claims it while the program’s own claim is live gets EBUSY rather than a stolen signal. (Disjoint claims would coexist; nothing can make two disjoint capabilities yet, because there is no fork_signals, and nothing has asked for one.)

Threads. Signals are process-wide and a signal mask is per thread. The rule has to hold identically on both kernels, so it is the strict one:

  • a signals_watch while any spawned thread has not been joined answers Failed(EBUSY); watch first, then spawn;
  • a thread spawned afterwards inherits the mask (Linux) or the process-wide disposition (macOS), so the signal cannot be taken by a thread that did not ask for it;
  • signals_pending and signals_close may be called from any thread. On Linux signals_close unblocks the signals in the calling thread, so it should be the thread that watched (the main thread; the others keep them blocked until they end, which is invisible, since the signal is delivered to a thread that does not block it).

The runtime counts live threads (spawn adds one, join subtracts one) in a single word it already needs for the claimed set; that is the only change to spawn and join.

Readiness. poller_add_signals(poller, watch, token) registers the claim as readable, level-triggered like everything on the Poller: poller_wait reports (token, 1) while a claimed signal is waiting, and keeps reporting it until signals_pending takes it. A program that registers a SignalWatch and never calls signals_pending spins, as one that never reads a ready socket does. Closing the SignalWatch removes it from the Poller (the kernel drops a registration when the descriptor closes), so there is no poller_remove_signals.

4. The refusals, and what is not a refusal

rulewhere
an unclaimable, unknown or malformed namesignal-not-claimablenarrow
narrow to a signal the capability was not narrowed tocapability-not-narrowablenarrow
signals_watch on the unnarrowed rootcapability-misusedsignals_watch
a SignalWatch not closed, used after signals_close, or taken apartlinear-value-unconsumed, linear-use-after-move, linear-value-taken-apartthe linearity pass
an undeclared signals("INT") or signals_readeffect-not-declaredthe row
signals_* called in a file below edition 6not-a-function (a type: unknown-name)name resolution
a seven-field Split pattern at edition 6arity-mismatchthe destructuring

Failed(errno) is the runtime’s refusal: EBUSY (a live thread, or a signal another claim holds), or the kernel’s own (EMFILE when signalfd or kqueue cannot make a descriptor). A failed signals_watch has changed nothing: it does not leave signals blocked.

5. Underneath

Both backends emit the same thing from the same table (cancho_ir::signals): the table says, for each target, which native number each claimable signal has and which edition-independent bit it answers. The handle is one i64: the native mask of the signals it claims in the high 32 bits and the descriptor in the low 32, so signals_close knows what to release without a table, and poller_add_signals reads the descriptor exactly as it reads a Conn’s.

Linux (measured, section 9): pthread_sigmask(SIG_BLOCK) for the set, then signalfd(-1, set, SFD_NONBLOCK | SFD_CLOEXEC). The signals are blocked, not ignored and not handled, so they queue in the kernel and no handler runs; signals_pending is one read of up to eight signalfd_siginfo records (128 bytes each, ssi_signo first) and a conversion of the numbers to bits. The descriptor is readable while a signal is queued, so epoll waits on it unchanged. signals_close unblocks (a queued signal is then delivered with the disposition in force, which is the default unless the process inherited SIG_IGN) and closes the descriptor. The disposition is never touched, so a signal ignored on entry is ignored again afterwards.

macOS (not run, section 10): there is no signalfd. kqueue has EVFILT_SIGNAL, which reports a signal “even if it has been marked SIG_IGN”, so the claim sets each signal to SIG_IGN (signal(2)), makes a kqueue, and registers one EVFILT_SIGNAL filter per signal (EV_ADD | EV_CLEAR). signals_pending is a zero-timeout kevent into an eight-event buffer, each event’s ident being the signal. The kqueue descriptor is itself readable while it has events, so the Poller’s kqueue registers it with EVFILT_READ (poller_add_signals is the existing readable registration on that descriptor). signals_close first takes out of the kqueue whatever was not read (a zero-timeout kevent, the same drain signals_pending does), sets each signal back to SIG_DFL, closes the kqueue, and then raises each signal that was unread, so it is delivered with the default action. Without that step the signal is lost, and macOS CI found it: the kernel discards an ignored signal rather than leaving it pending (only the kqueue kept a record of it), so restoring the default found nothing to deliver and closing_with_a_signal_unread_delivers_it_at_once saw the program exit 0. On Linux the signal sits in the blocked mask and unblocking delivers it; on macOS it is re-raised, which has the same effect in the thread that closed. This path works in every thread, which Linux’s does not; the live-thread rule applies on both so that a program means the same thing on each.

Not a handler. Neither path installs a handler, so none of the three obstacles from function-values.md section 5 arises, and nothing runs in signal context: the program reads the signal as data, in its own loop, in the order it chooses.

6. std.signals

A module of the eight bits as functions and two predicates, because a literal like 8 for TERM is a number a reader must look up:

signals.sighup()  signals.sigint()  signals.sigquit()  signals.sigterm()
signals.sigusr1() signals.sigusr2() signals.sigalrm()  signals.sigwinch()
signals.stop_signals() -> int           // INT | QUIT | TERM: the signals that mean "stop"
signals.has(mask, bits) -> bool         // every bit of `bits` is in `mask`
signals.any(mask, bits) -> bool         // some bit of `bits` is in `mask`

The names carry the sig prefix because int is a type name and signals.int() does not read; they are not stop() because of the gap in section 10 (a local called stop, the natural name for the claim, hides signals.stop). It imports as import std.signals as sg; where a local is called signals, which split makes it.

7. What the hooks service changes

src/ops.cho’s four extern fns, hold_signals, pending_signal and second_signal_kills become: narrow(signals, "INT,TERM") once in main; signals_watch before the first spawn; poller_add_signals on the loop’s Poller so the stop wakes poller_wait; signals_pending once per wake-up; signals_close on the first signal for “a second signal kills at once”. The Ffi("libc") that remains in the service (if any) is whatever else it uses; this removes ffi("libc") for signals from its row, and adds signals("INT,TERM").

8. Where this deviates from the sockets precedent, and why

  • Edition 6, not edition 5. Slice 4 of native-sockets.md added clock to edition 5’s Split because no edition-5 file destructured it yet (native-sockets.md section 10.2: “there were none outside this document’s own tests, which is the argument for adding it now rather than later”). That argument has expired: today 48 .cho files in this repository (examples/api, examples/ocpp_ws, examples/tls_nb, tests/accept, tests/reject, tests/programs, …), the programs embedded in three conformance modules, and every downstream service destructure seven fields, and editions.md section 5 is explicit that a field on Split is the additive kind of change, absorbed by an edition. So Split becomes a fourth declaration of one name (PRELUDE_SPLIT_SIGNALS), an edition-5 file’s split() still answers seven fields, and edition 6; is edition 5 plus signals. No file moves.
  • A capability and a handle, not one type. The suggested signals_pending(&Signals) would make the capability carry state (a descriptor), which Net and Clock do not: they are authority with nothing behind them, and the thing that has a descriptor (Listener, Conn) is minted from them. Signals("S") is the authority, SignalWatch is the claim.
  • No mask argument to signals_watch. The set is in the capability’s type, checked where narrow is written. A runtime mask would need a runtime check against a bound, as tcp_listen’s port has (it traps), and the report would name the bound rather than what the program claims.

9. What it is checked by

crates/cancho/tests/conformance/signals.rs builds real programs on both backends and signals the real process with kill(2); the programs speak on standard error and are driven by standard input, so a signal sent before a p is queued before the poll that must see it. Twenty tests:

claimtest
each of the eight is delivered once: sent, polled, polled again (empty); the process lives for all eighteach_claimed_signal_is_delivered_exactly_once
several signals between two polls are all reported; the same one three times is one bit; all eight together are 255signals_between_polls_are_all_reported_and_a_repeat_is_one
a claim takes only what it names: claiming TERM, a USR1 still ends the processa_signal_that_was_not_claimed_still_takes_its_default_action
a poller_wait with a 20 s timeout returns on the signal: token 7, readable, then signals_pending, then not ready againa_signal_wakes_a_poller_wait_at_once
“a second signal kills at once”: read, close, signal again, and the process dies of the signalclosing_the_claim_makes_the_next_signal_kill_at_once
close with the signal unread delivers it; close after the read does not; both with the status of the processclosing_with_a_signal_unread_delivers_it_at_once, closing_after_the_read_leaves_nothing_to_deliver
a signal ignored on entry (trap '' USR2 in the parent shell) is claimable, and is ignored again on Linuxa_signal_ignored_on_entry_can_be_claimed
a thread spawned after the claim does not take the signal; a claim while one runs is EBUSY, and granted after the join; an overlapping claim is EBUSY until the first is closeda_thread_spawned_after_the_claim_does_not_take_the_signal, a_claim_is_refused_while_a_thread_runs_and_granted_after_the_join, a_signal_another_claim_holds_is_refused_until_it_is_closed
every unclaimable signal (23 names, three reasons), malformed sets, widening, the unnarrowed root, a non-Signals, linearity, rows exact both ways, owned capabilities discharge, edition 6 onlyevery_unclaimable_signal_is_refused_with_its_rule and five more
the authority report names the set, is bounded, has no ffi; “never touches” says signals when nothing is claimedthe_authority_report_names_the_set_and_is_bounded, the_text_report_says_what_is_claimed_and_what_is_not
std.signalsstd_signals_names_the_bits

Beside them: 9 unit tests for the table and for Label::covers (cancho-ir), tests/reject/signal_not_claimable.cho (the rule has its fixture, every_rule_has_a_fixture), tests/accept/signals_claim.cho, and AGENTS.md section 3.2, whose block the suite compiles.

Mutants. 37 deliberate breakages of the new code, each run against the signals tests and each killed: 13 in the checker and the table (the subset check skipped; SEGV not a fault; set cover as a text prefix, in covers and in the set test; a reversed canonical order; the row not performed; signals_close at edition 5; narrow skipping the set check; the root allowed to claim; an owned claim and an owned Signals discharging nothing; a wrong USR1 number; two signals sharing a bit) and 12 in each backend (the mask not blocked; the signalfd blocking; one record a read; no unblock on close; the claim kept after close; an overlapping claim and a running thread each ignored; the kernel’s numbers not converted to bits; the bits of a poll not accumulated; spawn and join not counted; poller_add_signals doing something else). The first run of the mutants’ targets also found the one test that depended on the environment (section 3, “ignored on entry”). Two more cover the macOS re-raise, which the Linux suite cannot run: a unit test in each backend builds a claim for x86_64/aarch64 Linux and x86_64/aarch64 Darwin (Cranelift emits Mach-O for the host architecture; clang -c takes all four) and requires the Darwin module to call sigaction, kqueue, kevent and raise and the Linux one to call none of the four’s Darwin calls but signalfd and pthread_sigmask; dropping the raise in either backend fails it. That is a check that the call is emitted, not that it works.

Strace (strace -f -e trace=signalfd4,rt_sigprocmask,rt_sigaction, either backend): rt_sigprocmask(SIG_BLOCK, [INT TERM]), signalfd4(-1, [INT TERM], 8, SFD_CLOEXEC|SFD_NONBLOCK), then read(3, ..., 1024) = -1 EAGAIN per poll, and no rt_sigaction: no disposition is touched, no handler exists.

Latency. Six runs of the wake test (three per backend): the poller_wait returned 124, 180, 234, 263, 292 and 496 microseconds after the kill, having waited 300 ms of a 20000 ms timeout. ops.cho’s polling noticed a stop at the loop’s next wake-up, up to 50 ms later.

Size. 1,404 lines added to the compiler across 24 files (the table 164, the checker 127 in lower/signals.rs and 110 in defs.rs, 385 in Cranelift, 416 in LLVM; 14 lines changed), 1,368 lines of tests (1,218 of them conformance), 62 of std, 18 of fixtures, and this document.

10. What is not verified, and what building it found

  • macOS is written, not run here. CI’s macOS arm64 job ran the suite on the first version of this branch: 19 of the 20 signal tests passed there, so the kqueue path works, and the 20th found the unread-at-close loss fixed in section 5. The re-raise that fixes it has been built for Darwin (above) and not run. The kqueue path (SIG_IGN through sigaction, EVFILT_SIGNAL with EV_ADD | EV_CLEAR, a zero-timeout kevent to read, the kqueue descriptor registered in a Poller’s kqueue with EVFILT_READ) compiles into the same functions behind the target test and is exercised by the same suite when it runs on a Mac. The three assumptions the first version made from the man page and the headers (a kqueue readable in another kqueue; the 16-byte struct sigaction; EVFILT_SIGNAL reporting an ignored signal) held in that run. Still unrun: raise delivering to the closing thread before signals_close returns (POSIX says raise returns after the handler, or the default action, has run), and an unread signal that was also ignored on entry, which on macOS is re-raised with the default action although the process had ignored it (the claim cannot know what the disposition was).
  • aarch64 Linux is not run was run, and found a fault that is not in this section’s code. SFD_NONBLOCK | SFD_CLOEXEC and the 128-byte signalfd_siginfo are the same on both Linux architectures, and the claim itself behaved. The two tests that also spawn (a_claim_is_refused_while_a_thread_runs_and_granted_after_the_join, a_thread_spawned_after_the_claim_does_not_take_the_signal) died with SIGBUS on the Cranelift backend: the thread counter in lexs_signal_state was emitted unaligned and aarch64’s exclusive load refuses it. Fixed and measured in threads.md section 6.
  • Signals and SignalWatch do not cross to a thread as a spawn payload (crosses_to_a_thread, as Conn does not). Nothing asked, and the watch-before-spawn rule makes the natural program the one that reads signals on the thread that watched.
  • A signal’s count is not available, by design (section 3): a standard signal has one pending instance.
  • Adopting it in cancho-hooks is that repository’s change (section 7); this one does not touch it.

A gap found, not caused, and not worked around: a local binding hides a qualified function of the same name. The claim’s natural name is stop; std.signals first had a stop() and sg.stop() was refused:

import std.math;
fn main(world: World) -> [] int {
    let Split { io, ffi, fs, heap, args } = split(world);
    release(io); release(ffi); release(fs); release(heap); release(args);
    let min = 3;
    return math.min(1, 2) + min;     // error: `min` is a local binding, not a function
}

Qualification should have been enough to say which min is meant. It is the same for any std function and any local, so this is std.math and not signals. The function here was renamed stop_signals to avoid it, which is a workaround for this one name and not a fix.

Corrected in this document while building it: the first draft said a disjoint claim “coexists” with another and tested for it. It cannot be written yet (section 3); the draft’s Failed(EBUSY) for a second claim is the whole of what a program can reach. The first draft also said a seven-field Split pattern is pattern-shape; it is arity-mismatch.

The peer of a connection: conn_peer, std.addr, conns.peer

Status: built on both backends (§10); WASI refuses it with the other socket builtins; macOS arm64 and Linux arm64 run, Linux x86-64 is CI’s. Two programs ask. cancho-gateway (an HTTP/1.1 reverse proxy terminating HTTPS) needs the client’s address for X-Forwarded-For and for per-address bounds; cancho-mqtt (the broker; its design section 7d reads “no per-source limit (std.conns gives no peer address)”) needs it for a per-source limit on handshakes and connections. http-server.md §11.8 item 3 and tls-server.md §11 name it, and native-sockets.md §9 recorded it as “additive and costs nothing now; no asker until an access log exists”. There are now two askers, which is the bar (standard-library.md).


1. What is missing

tcp_accept answers Accepted::Ok(Conn) and nothing about who connected. The kernel knows: accept(2) can fill a sockaddr, and getpeername(2) answers it for any connected socket. A program written against Ffi("libc") could ask; a program written against handles (everything since native-sockets.md) cannot, and conn_raw_fd is the escape hatch that §6 of that document exists to make unnecessary. What the programs do with the answer:

useneeds
a log line per connection (examples/tls_echo, an access log)text of the address and port
X-Forwarded-For: <client>text of the address, no port, no brackets
at most N connections and M handshakes a second from one sourcea key that is equal for the same source and cheap to compare
the same bound surviving the client’s own address rotationa key coarser than one IPv6 address

2. Measured before designing

Three things decided the design, each measured on this repository’s compiler and on the two kernels it targets (macOS 26 arm64 and Linux arm64 in Docker; the probes are scripts/conn_peer_probe.c and scripts/conn_peer_dual.py).

questionLinuxmacOS
getpeername on an open connectionanswersanswers
…after the peer sent FIN and closedanswersanswers
…after the peer reset (SO_LINGER 0, close)ENOTCONN (107)EINVAL (22)
cost of one getpeername call237 ns235 ns
connect + accept + two closes on loopback25.4 µs(stalled under load; not measured)
the same with one getpeername after accept21.2 µs (within noise of the line above)
IPv6 listener (::), client dials 127.0.0.1peer is ::ffff:127.0.0.1peer is ::ffff:127.0.0.1
IPV6_V6ONLY default00

And one fact about the compiler: every cancho socket is IPv4 today. tcp_listen and tcp_connect use AF_INET (connect.md §10: “no IPv6”; udp.md header), so a client on ::1 cannot reach a cancho listener at all. That does not make the IPv6 half of this design speculative: the address type must not need to change the day dual-stack listening lands, and a program that is handed a Conn by a future listener must already be able to read its peer. It does change how the IPv6 half is tested (§10).

3. The API

Three layers, each the smallest that is useful, none with an effect row.

3.1 The builtin: conn_peer(&Conn, &![byte]) -> [] int

Edition 5, both backends, refused on WASI with the other socket builtins (wasm.md; the refused count moves 58 to 59). It calls getpeername(2) on the connection’s descriptor and writes 19 bytes into the buffer:

byte 0        family: 4 or 6
bytes 1..17   the address, 16 bytes, network order; an IPv4 address in bytes 1..5, bytes 5..17 zero
bytes 17..19  the port, big-endian

It answers 0, or the errno: whatever getpeername said (ENOTCONN after a reset on Linux, EINVAL on macOS: §2), and EINVAL (22) for a buffer under 19 bytes or a socket that is not IP (AF_UNIX, nothing else exists today), before anything is written to the buffer. The family byte is 4 and 6, not AF_INET6, because that is 10 on Linux and 30 on macOS and a program must not carry a table of them (native-sockets.md §3 on EAGAIN, same reason). The kernel’s sockaddr layout differences (a u16 family on Linux, a length byte then a u8 on Darwin) stop in the backend.

The connection is borrowed shared (&Conn): getpeername moves no stream. The buffer is borrowed unique (it is written).

3.2 std.addr: the value, its text, its key

Pure, no heap, no effect. A Peer is { family, w0, w1, w2, w3, port }: the address as four big-endian 32-bit words (an IPv4 address is w3; the rest zero) and the port. It is a val struct: copied freely, compared by same and same_address, held in a thousand-slot table without ceremony.

function
v4(a, b, c, d, port), v6(w0, w1, w2, w3, port)constructors; each normalises (§4)
decode(raw) -> Parseda Peer from the 19 bytes conn_peer wrote; Bad for another length or family
text(p, out) -> int, text_port(p, out) -> int§6; the length, or -1 for a buffer under max_text() = 47 bytes
parse(s) -> Parsedthe inverse of text (port 0); Bad for anything that is not an address
key(p) -> Key, same_key(a, b), key_family, key_bits, key_from§5
family, port, word, with_port, same, same_addressaccessors

3.3 std.conns.peer(table, slot) -> Peered

pub enum Peered { Known(addr.Peer), Unavailable(int) }     // the errno
pub fn peer[&t](table: &!t Table, slot: int) -> [] Peered

It is attach, conn_peer, detach like every other operation in the module, so the linearity the checker enforces does not stop at the table’s edge. An empty or out-of-range slot is Unavailable(9) (EBADF), as read and write answer.

4. IPv4-mapped IPv6 addresses are IPv4

A dual-stack listener reports an IPv4 client as ::ffff:a.b.c.d (§2, measured on both kernels). A bound that counts 1.2.3.4 and ::ffff:1.2.3.4 as two sources is a bound a client halves by choosing which socket family to dial with. So every constructor in std.addr normalises ::ffff:0:0/96 to the IPv4 address, decode and parse included, and a Peer made by this module never has family 6 with w0 = w1 = 0 and w2 = 0xffff. Nothing else is rewritten: ::a.b.c.d (IPv4-compatible, deprecated since RFC 4291), 64:ff9b::/96 (NAT64) and 2002::/16 (6to4) are different addresses with different owners, and guessing which IPv4 host they “really” are is not this module’s business.

5. The key for a per-address bound

key(p) is { family, bits }: for IPv4 the whole 32-bit address; for IPv6 the first 64 bits, the /64. A /64 is what one subscriber is handed, so a client rotating the low 64 bits (privacy extensions do, on purpose) does not obtain a fresh allowance with each address. IPv4 is a /32 because that is what one address is.

It is a pair, not one integer, because the two spaces do not fit in 63 bits and a packing with a collision is worse than none for a security bound (::/64 would equal IPv4 0.0.0.0; a /64 key as w0<<32|w1 is any int, including negative ones). same_key compares; a program stores the two ints (key_family, key_bits) wherever it keeps per-slot state and rebuilds with key_from. The prefix lengths are fixed (§11, question 2): the first asker that needs a /56 or a /48 for a hostile network says so.

6. Text

text writes 1.2.3.4, or an IPv6 address in the canonical form of RFC 5952: lower-case hex, no leading zeros, the longest run of two or more zero groups written :: (the first on a tie), a lone zero group written 0. This is the form for a log and for X-Forwarded-For (RFC 7239’s for= additionally wants brackets and quotes for IPv6 and for a port, which is the proxy’s concern, not the address’s). text_port writes 1.2.3.4:80 and [::1]:80.

parse accepts exactly what RFC 4291 §2.2 allows (full form, ::, a trailing dotted quad) with either case of digits, and refuses what it does not: brackets, a zone (%eth0), a prefix length, space, a dotted-quad octet with a leading zero (010 is eight in some parsers and ten in others, so it is neither), more than four hex digits in a group, two ::. parse(text(p)) is p with port 0, for every Peer this module makes; checked (§10) against Rust’s std::net as an oracle on a generated corpus rather than on the author’s examples.

7. When the address is available, and what it costs

conn_peer asks the kernel when called (getpeername); accept stores nothing. The alternative, storing the sockaddr accept already returns in a per-descriptor table beside the epoch counters (native-sockets.md §10.3), was rejected for these reasons:

  • Cost. One call is 237 ns; a whole loopback connection is 21 to 25 µs (§2), so the call is about 1% of the cheapest possible connection and a real TLS connection (4 ms of handshake CPU, tls-server.md §6) makes it negligible. The stored design costs a 64K-entry table (about 1.5 MiB of bss at 24 bytes an entry, against the epoch table’s 256 KiB) and a change to accept_cloexec on both backends, for a call a program makes once per connection.
  • No new state to go stale. A stored address would have to be cleared by conn_close, conn_detach and every path that ends a descriptor; the epoch table exists because forgetting one is how a stale value reaches the wrong connection.
  • tcp_connect conns work too, with the same builtin: the peer is the host that was dialled.

What the choice costs the program: a connection the other end has reset has no peer to ask about (§2: ENOTCONN on Linux, EINVAL on macOS), where a stored address would still be there. The rule for a caller is therefore: ask once, immediately after put, and keep the answer if it is needed later (a per-address count must be decremented under the key it was incremented under, and close is when the key can no longer be asked for). A peer that resets between accept and the call is a connection that is already dead; Unavailable says so and the program closes it. examples/tls_echo does exactly this and §10 records the test of the race.

8. Transports other than TCP

std.conns holds Conn, and a Conn is a connected SOCK_STREAM socket from tcp_accept, tcp_connect or tcp_connect_start. There is no Unix-domain socket in the language (the AF_UNIX in the process runtime is the channel pair of processes.md, whose verbs are Pipe’s, not Conn’s). The datagram sockets of udp.md are a different handle with a different, deliberate design (a sender is a ticket, never an address, so that a received address cannot become the destination of a sendto); this change does not touch them, and §11 asks whether it should. If a Conn ever is not IP, conn_peer answers EINVAL and peer answers Unavailable(22); nothing here guesses a family.

9. Authority

The address is data the program receives, not authority to dial it. tcp_connect still checks its destination against the Net capability’s bound at the call site (net_out("host:port"), connect.md §4), and a Peer is four integers: a program holding one for 203.0.113.7 and a Net("api.internal:443") still traps if it dials 203.0.113.7. conn_peer has no effect row (like conn_nodelay, conn_nonblocking, conn_connect_status: it names no resource, it asks about a handle already held), std.addr is pure, and conns.peer is []. So no label is added to cancho authority for a program that calls it, no capability widens, and narrow is untouched. A conformance test compares the authority report of a server with and without the call, and of examples/tls_echo before and after this change.

What the address is not: it is the socket peer, the other end of the TCP connection as the kernel sees it. It is not an identity claim and is not authenticated by anything above TCP. Behind a reverse proxy or a load balancer it is the proxy’s address, and the real client’s is in a header or a PROXY-protocol preface that this change does not parse (PROXY protocol is out of scope; a program that sits behind one wants std.addr.parse for the address it carries, which exists). Behind carrier-grade NAT many subscribers share one IPv4 address, so a per-address bound is a bound on a NAT, not on a person; that is the bound’s honest meaning and the reason an operator sets N above the number of people they expect behind one address. A completed TCP handshake cannot be forged blind, so a connection’s address is not spoofable by an off-path attacker, as a datagram’s is.

10. What was built, and what building it found

Built. conn_peer(&Conn, &![byte]) -> [] int (edition 5, both backends, no row; refused on WASI, so the builtin count is 134 and the refused count 59, os_tables.rs and wasm.md moved with it), std/addr.cho (new; pure), conns.peer in std/conns.cho, crates/cancho/tests/conformance/conn_peer.rs, and in examples/tls_echo/front.cho: peer= on every connection line, --per-address, --per-address-rate. Nothing in packages/ changed (no package store to republish; python3 scripts/publish_packages.py --check says so). The IPv4-mapped normalisation, the /32 and /64 key, the canonical text and the parser are in std/addr.cho; none of it needs the compiler.

Tests, each on both backends where it builds a program (cargo test -p cancho --test conformance conn_peer, 8 tests, all of them also run on Linux arm64 in Docker):

testwhat it shows
every_slot_answers_its_own_clients_address_and_port11 clients at once from 11 distinct source ports; each slot’s peer is its own client’s 127.0.0.1:<port>; slot 99, slot -1 and a closed slot are Unavailable(9); the slot freed by close and taken by a later client answers that client’s port, and slot 1 is unchanged
a_connection_that_ended_has_a_peer_only_if_it_was_not_reseta client that closed (FIN) still has its address; one that reset (SO_LINGER 0) is Unavailable(107) on Linux and Unavailable(22) on macOS, as §2 measured
the_builtin_fills_nineteen_bytes_and_refuses_fewer_without_writingan 18-byte and an empty buffer answer 22 and are left as they were (filled with 170); 19 bytes are filled in the §3.1 layout with the tail of an IPv4 address zero; a 25-byte buffer is written 19 bytes deep and no further
an_ipv6_peer_is_read_and_an_ipv4_one_on_the_same_socket_is_normalisedan AF_INET6 dual-stack socket made by the test is put under the program’s listener (dup2, through Ffi("libc"): §2 says no cancho socket is IPv6, and this is the only way to run the IPv6 half). A client from ::1 is [::1]:<port> with key 6 0; a client from 127.0.0.1 on the same socket, which the kernel reports as ::ffff:127.0.0.1, is 127.0.0.1:<port>, key 4 2130706433
the_address_type_agrees_with_rust_on_a_generated_corpusparse, text, text_port, decode and key against Rust’s std::net parser and an independent RFC 5952 formatter over 9,000-odd lines: IPv4 and IPv6 in six spellings (canonical, upper case, zero-padded, :: over any run, a dotted tail), the mapped range and its neighbours, one- to three-character mutations of all of those, random strings of the alphabet, and 1,500 raw buffers with families 0, 4, 5, 6 and 255. Zero disagreements; every line answered (a trap would be a signal, not exit 0). Each parsed address also round-trips through text and through a mapped raw buffer inside the program
the_address_api_does_what_the_document_saysmasking, the mapped boundaries (::fffe:, a nonzero second or first word), same against same_address, the key (a /64 shares, one bit of the /64 differs, the two families never collide even for :: and 0.0.0.0, a key rebuilt from its parts), the buffer limits, decode refusals
asking_for_the_peer_adds_nothing_to_the_authority_reportthe authority report’s effects and labels are byte-identical for a server with and without the call; no label mentions a peer
knowing_an_address_does_not_let_a_program_dial_itwith Net("127.0.0.1:9") and addr.v4(203, 0, 113, 7, 80) in hand, tcp_connect to that host traps, as it did before

The existing the_example_reports_a_bounded_authority_and_no_foreign_code (tls_echo.rs), which pins every label of examples/tls_echo, passes unchanged: the example now asks for each peer and reports the same fifteen labels. scripts/tls_echo_test.py has three more cases: peer, per-address and addr-rate (§ of tls-server.md 11); on Linux all three run in full, including a client from 127.0.0.2 that is not affected; on macOS the second-address step says it is skipped.

What building it found.

  • Every cancho socket is IPv4 (§2). The IPv6 half of the type exists for the day a listener is dual-stack, and is tested through dup2; it is not reachable by a program that does not hold Ffi. A gateway that wants IPv6 clients needs dual-stack tcp_listen first (question 4).
  • macOS and Linux disagree about a reset connection (EINVAL against ENOTCONN), which the first draft of the library comment had wrong (it said ENOTCONN for both); corrected in std/conns.cho and here.
  • The first draft of hex_value and hex_digit in std/addr.cho were copies of std/json.cho’s and an example’s; the repository’s duplication check (conformance/duplication.rs) refused them, and they were rewritten rather than shared, because std.addr importing std.json for a nibble would put a parser in every program that logs an address.
  • tls_echo’s log lines are matched by substrings in four test files, so peer= goes after the connection id (conn 3 peer=... established ..., refused 7 peer=... full) and every existing match still holds. case_full matches endswith(" full"), which is why the refusal reason stays last.
  • The per-address handshake bound needs a table that outlives the connection, because the attack it answers is handshake spam by connections that end at once. The example keeps 1,024 keys per one-second window in the loop’s global array (front.cho, g_ak); the state is shared by https_hello, which therefore accepts the same two flags (not tested there).

Mutants (scripts/conn_peer_mutants.py; the compiler is rebuilt for each; 54 single edits to std/addr.cho, std/conns.cho and each backend’s conn_peer, run against conformance conn_peer): 51 killed on macOS and the other 3 killed on Linux, 54 of 54, and one equivalent mutant with its argument. The first run found five survivors, which is what the run is for: from_parts was untested (the example was its only user), v4 did not mask its octets in the word (the text hid it, since it masks again), a dotted quad in the middle of an address was never in the corpus, and the errno conns.peer and each backend pass up was indistinguishable from EINVAL on macOS, which is what a reset connection answers there; the first three got tests and the errno three are killed on Linux by the ENOTCONN expectation (LINUX_ONLY in the script says so, and the script run in Docker kills them). The equivalent one is the check for a leading : in parse, which a few lines later is refused anyway as an empty group.

Measured, on macOS 26 arm64 under load from other jobs, examples/tls_echo with --per-address-rate 2 against six simultaneous clients: they complete in 2.02 s with the last having waited 1.99 s (3 windows of 2), and on Linux a client from 127.0.0.2 took 48 ms meanwhile.

Not verified. Linux x86-64 (CI is; here Linux was arm64 in Docker); the Darwin sockaddr layout is exercised on this Mac (arm64) and not on x86-64 macOS; WASI (the builtin is refused there, and wasm.md records the count); https_hello with the new flags (it builds, serves, and logs the peer through the shared front.cho, but no per-address case runs against it); a flood from more than 1,024 distinct addresses against the key table; the X-Forwarded-For header (the gateway’s change).

11. Open questions, with proposed answers

  1. Should accept store the address so a reset connection still has one? Proposed: no (§7). Revisit if a program needs the address of a connection it never got to ask about; the builtin is additive either way.
  2. Configurable prefix lengths for the key (a /56 or /48 for IPv6, a /24 for IPv4 behind a hostile network)? Proposed: not yet. key stays a pure function of a Peer; a program that needs another prefix can build its own from word in a few lines, and the first asker that does decides the signature.
  3. The peer of a UDP datagram. udp_recv_from deliberately hides it behind a ticket (§8). A DNS server wants a per-source rate limit exactly as the broker does. Proposed: a separate change that adds udp_peer(&Udp, ticket, &![byte]) -> int (it grants nothing: the ticket was already the way to reply), after cancho-dns asks.
  4. Dual-stack listening so an IPv6 client can connect at all (§2). Proposed: a separate change (tcp_listen flags), as it touches the bound’s meaning (net_in("8080") today means IPv4 port 8080). This change’s address type does not wait for it.
  5. The local address (getsockname: which of its addresses a client dialled). No asker; udp_local_port exists for the one case that did. Proposed: not now.
  6. X-Forwarded-For formatting helpers (appending to an existing header, RFC 7239 for=). Proposed: packages/http-server owns it, as its own change after this one; std.addr.text is the primitive.

Directory handles: opening beneath a directory, following no links

Status: slices 1, 2 and 4 built (4 is edition 7), both backends: open a directory, step into a child directory, open a file in it for reading (slice 1, #250), and create, append to, rename, remove and sync beneath it (slice 2). Linux measured; Darwin’s flags and Apple AArch64’s variadic call are written and run only by CI. Slice 3 (cancho-tools on top) is built there (alpibrusl/cancho-tools#4); §3 corrects what it said the row would become. Issue #227, gap L6 of agent-toolbox.md.

1. Why

A path is a string, and the kernel resolves it every time it is used. agent-toolbox.md A.4 measured what that means for a narrowed capability: under Fs("/tmp/jailprobe"), /tmp/jailprobe/link.txt – a symlink to /tmp/outside/secret.txt – read the outside file. The prefix check is lexical, and a link is not lexical. cancho-tools (#214) has the same hole one level up: its --root is checked lexically in the tool (D9), so a link inside the root reaches outside it, for write and replace as well as for the readers. D9 documented the escape and pinned it with a test (M8) that was written to flip the day a primitive existed.

The obvious fixes do not work:

  • O_NOFOLLOW on the open. It refuses a link only as the last component; root/linkdir/secret goes through a linked directory untouched.
  • realpath and re-check the prefix. Check-then-use: a link swapped in between the check and the open escapes. It is also a libc call a program could only make through Ffi("libc"), which makes the authority unbounded.
  • Refuse .. lexically, as the tools already do. Necessary and not sufficient: a link is not a ...

What does work is resolving the path one component at a time, from a descriptor: open the root once, then openat(dir, name, O_NOFOLLOW) for each component, so no path string is ever resolved twice and no link is ever followed. That is how WASI’s preopened directories, Capsicum and Linux’s openat2(RESOLVE_BENEATH) work. Measured here with a C probe (Linux 6.18, root/ holding a/b/f.txt, link.txt -> ../../outside/s.txt and dirlink -> ../outside):

pathper-component walkopenat2(RESOLVE_BENEATH | RESOLVE_NO_SYMLINKS)
a/b/f.txtopenedopened
link.txtrefused, ELOOPrefused, ELOOP
dirlink/s.txtrefused, ENOTDIRrefused, ELOOP
../outside/s.txtopenedrefused, EXDEV

The last row is the lesson: O_NOFOLLOW does nothing about .., so a walk must refuse .. (and ., and an empty component) itself. Cost, 200,000 opens of a/b/f.txt and closes: open 1.60 µs, the walk 6.14 µs (three openat and two close), openat2 1.83 µs. openat2 is Linux-only (5.6+) and macOS’s nearest, O_NOFOLLOW_ANY, is macOS 11+ and has no RESOLVE_BENEATH; the walk is the one that works on every target this compiler builds for, and 4.5 µs a file is not what a tool that reads a file spends its time on.

2. The primitives

A directory handle is a new res type, Dir: one descriptor, opened on a directory, which must be closed. Four builtins, edition 6:

open_dir(fs: &Fs(p), path: &[byte]) -> [fs_read(p)] DirOpened     // DirOpened::Ok(Dir) | DirOpened::Failed(int)
dir_enter(dir: &Dir, name: &[byte]) -> [dir_read] DirOpened
dir_open_read(dir: &Dir, name: &[byte]) -> [dir_read] Opened
dir_close(dir: Dir) -> [] int
  • open_dir is the trust anchor. It checks path against the capability’s prefix exactly as open_read does (file-handles.md §2.1) and opens it with O_RDONLY | O_DIRECTORY. Links in this path are followed: the caller named it, and it is what everything after it is beneath. A path that is not a directory is Failed(ENOTDIR).
  • dir_enter opens one child directory: openat(dir, name, O_RDONLY | O_DIRECTORY | O_NOFOLLOW).
  • dir_open_read opens one child file for reading: openat(dir, name, O_RDONLY | O_NOFOLLOW), and answers the same Opened as open_read, so file_read, file_size, file_close and the rest work on the File unchanged. Like open_read, it opens a directory too, and the first file_read on it is Failed(EISDIR).
  • name is one component. It must be non-empty, must not be . or .., and must hold no / and no NUL byte; any of those is Failed(EINVAL) (22) and no call is made. That is the check the probe’s last row says a walk cannot leave to the kernel. A missing name, a link (ELOOP on both kernels, or ENOTDIR when a link to a directory is entered on Linux) and a permission refusal are values, as a missing file is for open_read.
  • dir_close answers close’s result, like file_close.

The flags are per target and spelled once in each backend, with the probe’s values: Linux x86-64 O_DIRECTORY 0o200000, O_NOFOLLOW 0o400000; Linux AArch64 0o40000 and 0o100000; Darwin 0x100000 and 0x100. O_RDONLY is zero everywhere. openat is variadic, but its variadic argument (mode) is read only with O_CREAT, which none of these passes, so declaring it with its three fixed arguments puts every argument where the callee looks on every ABI – the reasoning filesystem.md §3 gives for open(path, O_RDONLY).

Labels. open_dir performs fs_read(p), the path label, because it spends a path under the capability. The others perform dir_read: like file_read (file-handles.md §4.1), a label that names no path, because the path was spent at open_dir and the handle is the authority. A tool whose row says dir_read and not fs_read can reach only what is beneath the directories it opened.

The walk is a library function, not a builtin. std.dirs.open_file(dir, "a/b/c.txt") splits on /, refuses an empty, . or .. component and a leading /, enters each directory with dir_enter (closing each as it goes) and opens the last component with dir_open_read. Each step is one primitive with one check, so neither backend emits a loop over a path, and the policy (what a component may be) is readable in std rather than buried in two code generators.

3. Slices

  1. Built (#250): Dir, DirOpened, the four builtins above, std.dirs.open_file and std.dirs.enter, on both backends.

  2. Built: writing beneath a directory, what cancho-tools’ atomic write (a temporary, fsync, rename over the target, fsync the directory, a lock file appended to) needs, and nothing more. Five builtins, edition 6, each name with §2’s one-component check:

    dir_open_new(dir: &Dir, name: &[byte]) -> [dir_write] Opened      // openat(O_WRONLY | O_CREAT | O_EXCL | O_NOFOLLOW, 0644)
    dir_open_append(dir: &Dir, name: &[byte]) -> [dir_write] Opened   // openat(O_WRONLY | O_CREAT | O_APPEND | O_NOFOLLOW, 0644)
    dir_rename(dir: &Dir, from: &[byte], to: &[byte]) -> [dir_write] Done   // renameat(dir, from, dir, to)
    dir_remove(dir: &Dir, name: &[byte]) -> [dir_write] Done          // unlinkat(dir, name, 0)
    dir_sync(dir: &Dir) -> [dir_write] Done                           // fsync(dir)
    
    • dir_write is its own label, discharged like dir_read by an owned Fs or World: a function that may only read beneath a Dir says [dir_read] and is refused the moment it changes anything.
    • A link is never written through. O_EXCL already refuses to create over any link, a dangling one included, and O_NOFOLLOW refuses to append through one; unlinkat removes the link itself, never its target; renameat replaces a link at to rather than following it. A rename is within one directory, which is what an atomic replacement is.
    • mode is variadic. openat’s mode is read only with O_CREAT, but these two pass it, and on Apple AArch64 a variadic argument travels on the stack. The LLVM backend declares openat variadic and calls it as one; Cranelift cannot declare a variadic callee, so on that target the call is shaped the way the callee reads it, nine integer parameters with mode the ninth, as fcntl’s already is (native-sockets.md §3). Every openat in a module goes through that one shape, slice 1’s included, since a module holds one signature per symbol. The flags for both slices are one table in cancho_ir::open_flags.
  3. Built in cancho-tools (alpibrusl/cancho-tools#4): every tool opens --root with open_dir and every path beneath it one component at a time (its toolbox.place, which is std.dirs.open_file’s walk plus one probe: when dir_enter answers ENOTDIR, the component is opened once more with dir_open_read, so a link to a directory is ELOOP on Linux too, not indistinguishable from a plain file). write and replace lock, create, rename, remove and sync beneath the parent Dir; M8’s symlink test flipped to a refusal, under a new rule path.symlink. (Corrected.) This item said the row would say dir_read “instead of fs_read("")”. It does not: open_dir spends fs_read(p), and a reader without --root still opens by path, so every tool keeps fs_read("") and gains dir_read. What disappears is fs_write(""): the writers hold dir_write and no path write at all. Without --root there is nothing to be beneath, and a reader opens the path as given; . was not opened as a root, because a working directory the process may not read would have broken every relative open.

  4. Built: a rename that never replaces a name (§5). dir_rename is renameat, and POSIX renameat silently replaces an existing destination. A tool that promises never to replace a name (cancho-tools’ move) could only look first and rename second, under a lock only its own processes take, and the look-then-rename window is real: in cancho-tools’ scripts/move_race.py, a process that takes no lock and creates the destination at a random moment during the move lost its file in 55 of 20,000 trials (0.28%), and in 100 of 100 when the rename was delayed 30 ms. One builtin, edition 7, additive (editions.md §5: dir_rename_new is a name a program may already declare, so older editions do not have it):

    dir_rename_new(dir: &Dir, from: &[byte], to: &[byte]) -> [dir_write] Done
        // Linux: renameat2(dir, from, dir, to, RENAME_NOREPLACE)    macOS: renameatx_np(dir, from, dir, to, RENAME_EXCL)
    
    • to exists: Failed(EEXIST) (17, on both targets), whatever it is – a file, a directory (an empty one too, which renameat replaces), a link, a dangling link – and its bytes, its inode and the source are untouched. The check and the rename are one call in the kernel, so there is no window to race.
    • A filesystem that cannot do it is Failed(ENOTSUP), never a replacing rename. The builtin makes one call and has no fallback. Linux answers EINVAL for a flag the filesystem does not implement, and EINVAL is also what a name that is not one component gets (§2, no call made), so the two would be the same value to a program. The builtin therefore answers a kernel EINVAL as EOPNOTSUPP (95) on Linux; macOS answers ENOTSUP (45) itself. The name check’s own EINVAL is not mapped. A program reads the pair as: EEXIST – the name is taken; EOPNOTSUPP/ENOTSUP – this filesystem cannot promise it, decide what to do; EINVAL – the names were wrong. The kernel checks that the destination exists before it looks at the flag, so a taken name is EEXIST even on a filesystem without the flag, and only a free name there is ENOTSUP.
    • Names are §2’s one component, each; dir_write; every other answer is renameat’s (ENOENT for a missing source, even onto a taken name).
    • Requirements. Linux kernel 3.15 and glibc 2.28 (renameat2 has no wrapper before that; not measured against another libc). macOS 10.12 (renameatx_np). A libc without the symbol fails at link time, not at run time, so a program cannot silently get the replacing behaviour.

4. What it is checked by

Slice 4 (tests/conformance/directory_rename_new.rs, both backends, the same expectations):

  • A table against a tree the test builds: a free name moves, byte for byte and inode for inode; a taken name is EEXIST with its bytes and inode and the source untouched; a link or a dangling link at the destination is EEXIST, the link stays and nothing is written outside; an existing directory (empty too) is EEXIST; a missing source is ENOENT, even onto a taken name; .., an empty name, a/b, . and ../escaped are EINVAL with no call.
  • A race. The program renames 300 pairs s<i> to d<i> while a thread of the test creates d<i>, exclusively, with a head start of a few trials on two of every three (the third is an uncontested control). Exactly one side may win a name: rename ok means the rival’s create was refused and the file holds the source’s bytes; EEXIST means the rival’s create succeeded, the file holds the rival’s bytes and the source is still there. A replacing rename answers ok and lets the rival’s create succeed, which the test names as the lost file. Measured here, of 300 trials: on macOS (APFS) the rival won 152 to 162; on Linux (aarch64; overlayfs, NFS v3, ntfs-3g) it won between 17 and 136, depending on the filesystem and the backend. Corrected: that was not true of every machine. On CI’s Linux x86-64 runner the LLVM program won all 300 and the rival none (the first CI run of the slice failed on exactly that), and on x86-64 Linux with the test pinned to two cores the rival wins about one trial in 300 per backend (the first contested one): which side wins is scheduling, and the head start is weak where the program is faster than the rival. So the safety checks (no replaced file, one winner per name) hold in every trial, and the power check is retried: a round is 300 fresh pairs, up to 20 rounds until the rival has won a name, and the test fails only if it never does.
  • A filesystem without the flag, when CANCHO_RENAME_UNSUPPORTED_DIR names a directory on one. CI’s filesystems all support the flag, so without the variable that test prints that it has nothing to run on and passes vacuously; it was run, with the answers in §5, on NFS v3 and ntfs-3g (Linux) and ExFAT (macOS), both backends: ENOTSUP for a free name, EEXIST for a taken one, nothing moved.
  • tests/reject/dir_rename_new_is_edition_seven.cho (not-a-function at edition 6) and dir_rename_new_not_declared.cho (effect-not-declared: [dir_read] is not dir_write).
  • Mutants: 10, all killed. Flag 0 (a plain rename) in the shared table, and on each backend alone; the exchange flag in place of no-replace; the second name unchecked, on each backend; the builtin at edition 6; the builtin performing dir_read; and, on each backend alone, the kernel’s EINVAL left unmapped, which only the Linux run on NFS v3 kills (err 22 where err 95 is expected). The first six were each killed by the table and by the race independently.

Slice 2:

  • tests/conformance/directory_writes.rs, on both backends, against a tree with a link to a file outside and a dangling link whose target is outside: create once and then EEXIST; create through either link EEXIST, and nothing appears outside; append twice reads back twice, append through either link ELOOP, and the outside file is unchanged; rename within the directory, and .., an empty name and ../escaped refused with EINVAL (nothing escapes); remove a file, remove a link (the link goes and its target stays), a missing name ENOENT; sync Ok; a created file is 0644, which is the check that would catch a mis-shaped variadic call on Apple AArch64.
  • tests/reject/dir_write_not_declared.cho, and dir_remove in the accept fixture’s owned-Fs function, so dir_write’s discharge is checked.
  • Mutants: 14, all killed. On each backend: create without O_EXCL, append that follows links, append without O_APPEND, create with mode 0, and a rename that checks its first name twice. In the IR: World or Fs not discharging dir_write, the writes performing dir_read instead, and the writes at edition 5.

Slice 1:

  • tests/conformance/directory_handles.rs, on both backends, judged from outside the program, against a tree the test builds: a file two directories down opens and reads back; a link to a file outside is refused (ELOOP); a link to a directory outside is refused when entered; .., ., an empty name, a name with / and an absolute path are refused by std.dirs.open_file and, as single names, by the builtins (EINVAL) without a call; a missing name is ENOENT; open_dir of a file is ENOTDIR; a path outside the capability’s prefix traps, as open_read does.
  • tests/accept/directory_handles.cho, on the default backend in the corpus and on both by hand: the shape of a program, every Dir closed, and a function that owns an Fs stepping beneath a directory with the row [] (owning the capability that paid for the handle discharges dir_read, as it discharges file_read).
  • tests/reject/: an unclosed Dir (linear-value-unconsumed), one taken apart by a pattern (linear-value-taken-apart, naming dir_close), a function that steps beneath a Dir with a row that does not say dir_read (effect-not-declared), and an edition-5 file that names open_dir (not-a-function).
  • Mutants: 22, all killed. On each backend: a file open or a directory step that follows links, no / check, no NUL check, no . check, no .. check, no NAME_MAX bound, and open_dir without O_DIRECTORY (seen in the exit status: open_dir of a file then answers ENOTDIR one step later). In the IR: World or Fs not discharging dir_read, the steps performing nothing, the builtins at edition 5, and a taken-apart Dir naming file_close. In std.dirs: a walk that hands the rest of the path to one dir_open_read instead of recursing. Two survived the first run – Fs not discharging dir_read, and the steps performing nothing – and the accept function and the dir_read_not_declared fixture above were added for them.

5. Slice 4 measured: which filesystems can refuse to replace

scripts/rename_noreplace_probe.c makes one call per case in a directory on the filesystem under test: renameat2(RENAME_NOREPLACE) (the raw syscall) or renameatx_np(RENAME_EXCL) onto a free name, then onto a taken one (does it answer EEXIST, do the destination’s bytes survive, does the source stay), then a plain renameat onto the taken name for contrast. scripts/rename_noreplace_matrix.sh and scripts/rename_noreplace_matrix2.sh build the Linux filesystems (loop images, an NFS server on loopback, ntfs-3g).

filesystemfree destinationtaken destinationplain renameat over a taken one
Linux 6.8 (aarch64, glibc 2.39): tmpfsmovedEEXIST, bytes keptreplaced
overlayfs (the container root)movedEEXIST, bytes keptreplaced
ext4, XFS, Btrfs, vfat (loop mounts)movedEEXIST, bytes keptreplaced
NFS v3 and v4.2 (client and server on this kernel)EINVALEEXIST, bytes keptreplaced
ntfs-3g (FUSE)EINVALEEXIST, bytes keptreplaced
macOS 26.2: APFS, HFS+, FAT32 (disk images)movedEEXIST, bytes keptreplaced
macOS 26.2: ExFAT (disk image)ENOTSUP (45)EEXIST, bytes keptreplaced

Not measured: exFAT and f2fs on Linux (the mount failed in the container), a nested overlayfs (same), kernels other than 6.8, SMB/CIFS, 9p, virtiofs, Linux on x86-64 (the syscall is the same, the table is one architecture’s), and a macOS that is not 26.2. For a filesystem not in the table the contract is the builtin’s, not a measurement: it makes one call, and whatever that answers is EEXIST, success, or an error a program can see; it is never a replace.

What the table says that the design leans on:

  • The kernel refuses a taken name before it looks at the flag. NFS, ntfs-3g and ExFAT answer EEXIST for a taken destination and only a free one is unsupported, so the answer to “may I take this name” on such a filesystem is still the correct EEXIST for the case that matters, and ENOTSUP only says the move cannot be made atomically.
  • macOS differs from renameat only in that. It follows no link at the last component either way (a link at the destination is a taken name, as on Linux; the conformance test checks both), the names are the same one component, and the unsupported answer is ENOTSUP natively where Linux says EINVAL. The CI runner’s filesystem is APFS, which supports it; the test above runs on it on every CI run.
  • EINVAL is ambiguous on Linux, which is why the builtin maps it (§3, slice 4). Measured on NFS v3 and ntfs-3g through the compiler, both backends: err 95 for the free name, err 17 for the taken one.

Directory listing and file status: reading a directory beneath a handle

Status: slices 1 and 2 built (dir_list, dir_next, dir_list_close, std.dirs.list; dir_stat), edition 6, and §3.5 built (dir_mode, dir_own_mode, edition 7, issue #243), both backends; Linux measured, Darwin’s dirent offsets run by CI’s hostile listing and its stat offsets by CI’s status test. Slice 3 (the cancho-tools list tool) is below, not built. Issue #222, gaps L2 (no listing) and L3 (no status without opening) of agent-toolbox.md. The issue asks for fs_list and fs_stat on a path under Fs; this document puts the same capability on a Dir instead (directory-handles.md, #227), §2 says why, and that change of shape was decided by a person on the design’s PR (§7).

1. Why

A tool cannot see the file tree. fs_list(f, "/tmp") and fs_stat(f, "/tmp") are not-a-function (agent-toolbox.md A.2); opendir and readdir through Ffi compile, but the report becomes bounded: false and readdir answers a c_ptr nothing can read. That blocks list (ls, find, tree), the toolbox’s sixth tool, and seek over a directory instead of named files. A program can learn a file’s size only by opening it (file_size is lseek), and cannot learn its kind or modification time at all.

docs/file-writes.md §8 named the obstacle: readdir’s struct dirent puts d_name at different offsets on different targets, and a listing answers a variable number of names. Both are layout problems, and the second has the same answer file_read already gives: the caller owns the buffer.

2. On a handle, not on a path

The issue’s shape is fs_list(fs: &Fs(p), path), performing fs_read(p): a second way to spend a path. Since #227 there is a first one, open_dir, and everything beneath it is reached by handle, following no link. Listing and status belong there:

  • Confinement for free. A name a listing answers is one component of the directory it came from, so the natural next call, dir_stat(dir, name), dir_enter(dir, name) or dir_open_read(dir, name), can only reach a child. A path-based fs_list would hand back names a caller then glues onto a path string, which is the hole #227 closed.
  • No new path label. The row of a lister is fs_read(p) (spent by open_dir) and dir_read (§3.3), the labels cancho-tools already holds; a tool that lists gains no new kind of authority, and the report stays bounded: true.
  • One check, written once. dir_stat’s name is checked exactly as dir_enter’s is (one component, not . or .., no /, no NUL, at most NAME_MAX; otherwise EINVAL with no call). A path-based fs_stat would need the prefix check and the link question again.

What the issue’s gate asks for still holds, restated for this shape (§6): a hostile directory lists identically on both backends, in bytewise order, in bounded memory; status never follows a link; the rows are exact.

3. The primitives

Two new res/value types and four builtins, edition 6, both backends:

dir_list(dir: &Dir) -> [dir_read] Listing                  // Listing::Ok(DirList) | Listing::Failed(int)
dir_next(list: &!DirList, name: &![byte]) -> [dir_read] Listed
                                                           // Listed::Name(int, int) | Listed::End | Listed::Failed(int)
dir_list_close(list: DirList) -> [] int
dir_stat(dir: &Dir, name: &[byte]) -> [dir_read] DirStat   // DirStat::Ok(int, int, int) | DirStat::Failed(int)

3.1 Listing

  • dir_list starts a listing of dir: fdopendir on openat(fd, ".", O_RDONLY | O_DIRECTORY), so the stream owns a descriptor of its own and closing it leaves dir open. (Corrected while building: this said dup(fd). A dup shares the file position, so two listings of one Dir read in turns would each see half the names; a second open of . beneath the handle does not, and is still beneath it. The conformance test reads two listings in turns.) DirList is a res: it must be closed (dir_list_close, closedir), like Dir and File.
  • dir_next answers the next entry: Listed::Name(n, kind) with the name’s n bytes copied into the front of name, Listed::End, or Listed::Failed(errno). . and .. are never answered. If name is shorter than the entry, nothing is copied and the answer is Failed(ENAMETOOLONG) (36 on Linux, 63 on macOS); a buffer of NAME_MAX bytes (255) always suffices, and std.dirs passes one. readdir reports an error only through errno, so the step clears errno before the call and reads it when the answer is null.
  • kind comes from d_type, with no extra call: 1 file, 2 directory, 3 symbolic link, 4 other (FIFO, socket, device), 0 unknown. Unknown is what a file system without d_type reports (some network and older file systems); a caller that needs to know asks dir_stat. The numbers are the language’s, not the kernel’s (DT_REG is 8 on both targets, but a program should not depend on that).
  • Order is the kernel’s, which is no order. Sorting is std.dirs’ (§4), because the gate asks for bytewise order and a sort over a variable number of names is not a builtin’s job.
  • Memory is the caller’s: one name buffer and whatever the caller keeps. The stream itself is libc’s DIR (about 32 KiB of buffer on glibc).

3.2 Status

dir_stat(dir, name) is fstatat(fd, name, &st, AT_SYMLINK_NOFOLLOW) on one checked component, answering DirStat::Ok(kind, size, mtime) (kind numbered as in §3.1, never 0; size in bytes; modification time in whole seconds since the epoch) or DirStat::Failed(errno). (Renamed while building: this said Status, a name too many programs declare for the prelude to take at an edition, as editions.md §7 found for Conn.) It never follows a link: a link answers kind 3 and the link’s own size, a dangling link included. Following one is dir_enter or dir_open_read, which refuse links anyway. Status is not opening: a FIFO answers kind 4 without the open that would block on it.

3.3 Labels

All four perform dir_read except dir_list_close, which performs nothing (as dir_close and file_close). Owning an Fs or World discharges dir_read, as it already does for slice 1’s steps. No new label: a listing reveals names beneath a directory the program opened under fs_read(p), which is what dir_read already means.

3.4 The layout table

The one design problem file-writes.md §8 named. Two structures, three targets, measured on Linux x86-64 with a C probe (offsetof on glibc 2.x) and taken from the system headers for the others:

Linux x86-64 (measured)Linux AArch64Darwin AArch64
dirent.d_type18 (u8)1820
dirent.d_name191921
stat size144128144
st_mode24 (u32)16 (u32)4 (u16)
st_size484896
st_mtim.tv_sec888848
AT_SYMLINK_NOFOLLOW0x1000x1000x20
ENAMETOOLONG363663

DT_REG 8, DT_DIR 4, DT_LNK 10 and S_IFMT 0o170000 with S_IFREG 0o100000, S_IFDIR 0o40000, S_IFLNK 0o120000 are the same on all three. The table is spelled once, in cancho_ir beside open_flags (directory-handles.md §3), and both backends read it. d_name is NUL-terminated on every target, so the name’s length is strlen from that offset, and d_namlen (Darwin only) is not needed. On Darwin AArch64, readdir, fdopendir and fstatat are the 64-bit-inode symbols under their plain names (the $INODE64 suffixes are x86-64 only). CI runs Linux x86-64 and Darwin AArch64; Linux AArch64’s column is from asm-generic/stat.h and is run by no CI job, as slice 1’s flags were not.

The stat buffer is a stack slot of 144 bytes in the function that calls, read at the table’s offsets; nothing in the language sees struct stat.

3.5 Permission bits (#243)

dir_mode(dir: &Dir, name: &[byte]) -> [dir_read] Done      // Done::Ok(bits) | Done::Failed(int)
dir_own_mode(dir: &Dir) -> [dir_read] Done

The asker is cancho-hooks: its production profile refuses to start when its data directory or a log in it can be read or written by the group or by others, and cancho could not say a file’s mode, so the service called libc’s statx through Ffi("libc") and its authority report was bounded: false for that alone. #243 asked for a path-based fs_stat; §2’s reasons put status on a handle, so the bits are two more steps beneath a Dir.

  • dir_mode(dir, name) is dir_stat’s call: the same one-component check (EINVAL, no call), fstatat with AT_SYMLINK_NOFOLLOW, and it answers st_mode & 0o7777: the nine read/write/execute bits, set-user-id (0o4000), set-group-id (0o2000) and sticky (0o1000). A link answers its own bits, never its target’s.
  • dir_own_mode(dir) is fstat on the handle’s descriptor: the directory that was opened, which no name beneath it can reach (. is refused). fstat needs no search permission on the directory and no path.
  • The answer is Done, the prelude’s integer-or-errno (file-writes.md §4), rather than a fourth field on DirStat: DirStat::Ok(kind, size, mtime) is matched by cancho-tools’ list, and a field added to a variant would break every match on it. Owner and group stay out, as §7 says, until a program asks.
  • Edition 7, which is still being built (processes.md slices 3 and 4): dir_mode is a name a program may already declare, so an edition-6 file does not see it (tests/reject/dir_mode_is_edition_seven.cho).
  • The offsets are §3.4’s: st_mode, 32 bits on Linux and 16 on Darwin, read only when the call succeeded; a failure’s value is 0. The permission bits are the same on every target.
  • Labels: both perform dir_read, as dir_stat does (tests/reject/dir_mode_not_declared.cho).

Checked by tests/conformance/directory_modes.rs, on both backends: a directory made 0o710 and, beneath it, files 0o600, 0o644, 0o400, 0o4755 and 0o000, directories 0o750 and 0o1777, a link, a missing name (ENOENT) and two names that are not one component (EINVAL), each printed by the probe and compared with what Rust’s symlink_metadata reports.

4. std.dirs: the sorted listing and the walk

The builtins answer one name at a time in the kernel’s order; std.dirs turns that into what a tool wants:

pub fn list[&h, &d](heap: &!h Heap, dir: &d Dir, most: int) -> [heap, dir_read] Names

Names holds every name in one std.buffer and their offsets, sorted bytewise (a byte is compared as unsigned, so caf\xe9 sorts after cafe; no locale exists to be consulted), with each entry’s kind. most caps the entry count: past it the answer is truncated and says so, because a directory of 10^7 entries is a value the caller must be able to refuse rather than a heap trap. Memory is the names plus 16 bytes an entry. The sort is a merge sort over the offsets in std (there is no sort in std yet; this is its first asker, and it is written for this use, not as a general std.sort).

list, the toolbox’s tool, walks a tree with dir_enter on each directory entry, depth-first in sorted order, with a depth cap; that walk is the tool’s, not std’s, until a second program wants it.

5. Cost

Measured with a C probe on Linux 6.18, ext4, a directory of 100,000 empty files, three runs:

per entry
readdir (through fdopendir)246–284 ns
plus fstatat(…, AT_SYMLINK_NOFOLLOW) on each1.73–1.86 µs more

So a listing that only needs names and kinds (all ls and most of find) costs a quarter of a microsecond an entry, and status is opt-in at seven times that. 100,000 entries list in 25 ms; with status, 200 ms.

Built, the same directory through dir_next and written out a line a name: 29.9 ms on LLVM and 29.6 ms on Cranelift (minimum of five), about 300 ns an entry with the output. Peak resident set is the same at 1,000 entries and at 100,000 (10.5 MB as Python’s RUSAGE_CHILDREN reports it, the launcher included): one name buffer, whatever the directory holds.

6. What it is checked by

  • tests/conformance/directory_listing.rs, on both backends, against a tree the test builds and judged from outside the program (Rust’s read_dir and symlink_metadata), names printed as hex so every byte survives:
    • a hostile directory: 100,000 plain files and a name with a newline, one that is not UTF-8 (caf\xe9, beside cafe; Linux only, since APFS refuses to create such a name with EILSEQ), a 255-byte name, a dangling link, a link to .., a FIFO and a subdirectory. std.dirs.list equals the entries sorted as bytes, with every kind equal to lstat’s; dir_next alone gives the same entries in the kernel’s order, with . and .. absent; the two backends agree byte for byte;
    • dir_next with a three-byte buffer on a longer name is ENAMETOOLONG and copies nothing; std.dirs.list with most 2 keeps two and says truncated; two listings of one Dir, read in turns, each see every name;
    • memory: §5’s measurement (flat between 1,000 and 100,000 entries), not a test.
  • tests/accept/directory_listing.cho: / listed by dir_next and by std.dirs.list, the two counts equal, no . or .., the sorted names strictly increasing; every DirList closed; an Fs-owning function listing with row [].
  • tests/reject/: an unclosed DirList (linear-value-unconsumed), one taken apart by a pattern (linear-value-taken-apart, naming dir_list_close), dir_next in a row that does not say dir_read (effect-not-declared), and dir_list at edition 5 (not-a-function).
  • Slice 2: dir_stat on every entry of a hostile directory (20 files plus the cases above and a 12-byte file) equal to lstat’s kind, size and whole-second mtime, on both backends; the dangling link a link of 7 bytes, not ENOENT; the FIFO answered without blocking; .., ., sub/x, an empty name and a 256-byte name EINVAL with no call. The accept fixture checks dir_stat agrees with the listing on every kind of / the listing knew, and tests/reject/dir_stat_not_declared.cho that a status performs dir_read.
  • Mutants (slice 1): 19, all killed. On each backend: d_name read one byte off; d_type read one byte off; d_type mapped wrong (a directory called a file); . and .. both leaked; .. alone leaked; the short-buffer check skipped. In std.dirs: unsorted, reversed, the cap ignored. In the IR: the steps performing nothing, the builtins at edition 5, a DirList that a pattern may take apart, and owning a DirList not discharging dir_read. The last survived the first run – no fixture owned a listing outright – and the accept fixture’s drain, row [], was added for it; the Cranelift d_type mutant first missed its target after cargo fmt rewrapped the line and was re-run.
  • Mutants (slice 2): 12, all killed. On each backend: fstatat without AT_SYMLINK_NOFOLLOW; st_mode, st_size or st_mtim read at the wrong offset; a link’s mode mapped to a file. In the IR: dir_stat performing nothing, and at edition 5. dir_stat’s name check is dir_enter’s own (dir_call), whose mutants #250 killed.

7. The decision, and what this does not do

Decided: the handle shape. The issue was written before Dir existed and asks for fs_list/fs_stat on paths. This document proposed the handle-based shape of §2 instead, and a person chose it on the design’s PR: same capability, one way to spend a path rather than two, and a listing that cannot be turned into an escape. The “two askers” bar (CONTRIBUTING.md) is the issue’s other open question; D16 already answered it (list, and cancho-log’s segment discovery in file-writes.md §8), and this document assumes that answer.

Slices. 1: dir_list, dir_next, dir_list_close and std.dirs.list, both backends. 2: dir_stat. 3: cancho-tools’ list tool and seek over a directory.

Not done:

  • No following stat. dir_stat never follows a link; a tool that wants the target opens it with dir_enter/dir_open_read, which refuse links, or does not follow at all.
  • No sub-second times, no owner. list needs kind, size and a time; ls -l’s owner has no asker. Nanoseconds are an added field when one appears. (Corrected: this also said “no mode bits”; cancho-hooks asked, and §3.5 answers them with dir_mode and dir_own_mode.)
  • No rewinddir, no telldir. A listing is read once; a second pass opens a second DirList.
  • No recursive walk in std (§4).

Processes: running a program, with an authority that says which

Status: slices 0 to 6 are built; slice 6 is letting go of a child (§4.11, #524) and signalling its process group (§4.12, #533). Nothing in this document is designed-only. Checked against main at the time of #536, by grep of std/process.cho, crates/cancho-ir/src/builtin.rs and crates/cancho/tests/conformance/ for each name:

Slice (§7)WhatWhere it isBuilt
0close-on-exec everywhere (§4.5)every backend; close_on_exec.rsyes
1Exec, exec_spawn, child_wait, child_kill, pipe_open, pipe_read, pipe_write, pipe_nonblocking, pipe_close, child_end_close (§3.2)builtin.rs; processes.rsyes
2poller_add_pipe, poller_add_child (§4.8)builtin.rs; processes.rsyes
3std.process: argv/add/list/count/drop, channels, close, capture (§7.1)std/process.cho; capture.rsyes
4the MCP server (cancho-tools#10)in cancho-tools (cancho-tools#17), on std.processyes, outside this repository
5exec_spawn_in (§4.10), std.process.channels_with_errors and capture_both (§7.2)builtin.rs, std/process.cho; processes.rs, capture.rsyes
6 (edition 9)exec_spawn_detached, child_pid, child_release and the label exec_detached (§4.11, #524)builtin.rs; process_release.rsyes
6 (edition 9)child_kill_group (§4.12, #533), and capture using itbuiltin.rs, std/process.cho; process_group.rsyes

What is still only designed or open is §9, and only that. Corrected (#536): this line used to say “slices 0 to 2 built; slices 3 and 4 designed, not built”, which was true when slice 2 merged and was never revised when capture (#276), the MCP server (cancho-tools#17) and slice 5 (#292) were. A reader took it to mean std.process did not exist.

Written before the code, the way filesystem.md, net.md and signals.md were. When building it disagrees with this document, this document is corrected in place.

Two askers (CONTRIBUTING.md): #237 part (a), a test that starts a service, kills it and restarts it; and cancho-tools#10, an MCP server that runs each tool as its own process so each keeps its own authority. editions.md §6.4 has expected an Exec since edition 2.


1. Why

cancho has no way to start a process. A program that needs one declares fork, execve and waitpid through Ffi("libc"), and reach.md §5 is what that costs: Ffi("libc") is every authority at once. The report says UNBOUNDED, and a supervisor holding lex-os’s grant (filesystem, network, exec) cannot decide the third dimension at all (under-a-grant.md §2: “exec: No”).

So the gap is the same one Net closed for the network: the authority has no name of its own. This document gives it one, and most of the work is saying exactly what that name does and does not bound (§2), because running a program is unlike every capability cancho has so far.

1.1 What the two askers need

#237 (a test harness)cancho-tools#10 (an MCP server)
start a built program with argumentsyesyes
environmentyes, chosen by the testnone
feed standard inputsometimesyes (write --stdin)
read standard outputsometimesyes, bounded
kill ityes, KILL and TERM, at a moment the test choosesyes, on a timeout
its exit statusyesyes
wait for it beside sockets and a deadlineyes (fake peers in the same program)yes (a timeout)

Neither asks for a shell, for PATH lookup, or for the child to share the parent’s console. Both want the child gone or reaped on every path.


2. What executing a program is an authority over

Every capability so far bounds what this program does: Fs("/tmp") is the files it touches, Net("api:443") the host it dials. Running a program is different in kind:

Executing a program delegates. The child does whatever its own binary does, with its own authority, and the parent’s row cannot state that authority, because it is a property of a file the parent did not compile.

That leaves two honest things a capability can bound, and this design bounds both:

  1. Which programs may be started. Exec("/opt/cancho-tools/bin") may start what lies under that directory and nothing else. That is a path prefix, with Fs’s rules (§4.1), and it is the part a grant can check: exec("p") in a report is a static bound, as fs_read("p") is.
  2. What the parent hands the child. A child must get no authority the parent did not deliberately pass: no inherited descriptor (§4.5), no inherited environment (§4.3), no inherited console (§4.4), and no blocked signal (§4.6). Without this, Exec would be a way to move authority out of the type system: release Fs in main, but leave an open Dir for a child to inherit.

What the design does not claim, stated here rather than discovered later:

  • The child’s own authority is the child’s. A report that says exec("/opt/cancho-tools/bin") composes with the reports of what lies there. When those are cancho programs, each carries its own (cancho authority, or, for cancho-tools, <tool> introspect), and the closure is computable. When they are not, it is not. The report says exec, so a supervisor knows the closure is a question.
  • The bound is on the name, not on the bytes. A script under the prefix whose #! line names /bin/sh runs /bin/sh. A symbolic link under the prefix that points outside it is followed (§4.1). This is Fs(prefix)’s position exactly, with filesystem.md §2.1’s reasoning: containment against a hostile program is a sandbox’s job (lex-os), and this is containment against an honest program’s mistakes, plus a report that says what to put in the sandbox.

3. The design

Edition 7: purely additive (editions.md §5), so no earlier file changes.

3.1 Types

typewhat it isconsumed by
Exec("prefix")a capability carrying a path prefix, like Fs; borrowed sharedrelease
Childa started process, not yet reaped: res, one leaf (the pid)child_wait, or child_release (§4.11)
Pipethe parent’s end of a channel to a child: res, one descriptorpipe_close
ChildEndthe child’s end of the same channel, before it is handed over: resexec_spawn (as a Stdio), or child_end_close
Stdioenum { Null, Pipe(ChildEnd), File(File) }: what one of the child’s three streams isexec_spawn
Pipedenum { Ok(Pipe, ChildEnd), Failed(int) }match
Spawnedenum { Ok(Child), Failed(int) } (errno)match
Exitedenum { Code(int), Signaled(int), Failed(int) }match
Split (edition 7)edition 6’s eight fields and exec: Exec("")destructuring

3.2 Builtins

builtinsignaturerow
pipe_open() -> Pipednone (an unnamed channel inside this process reaches nothing)
exec_spawn(&Exec("p"), path: &[byte], args: &[byte], env: &[byte], stdin: Stdio, stdout: Stdio, stderr: Stdio) -> Spawnedexec("p")
exec_spawn_in(&Exec("p"), &Dir, path: &[byte], args: &[byte], env: &[byte], stdin: Stdio, stdout: Stdio, stderr: Stdio) -> Spawnedexec("p"); exec_spawn whose child starts in the Dir (§4.10)
exec_spawn_detached (edition 9)as exec_spawnexec("p"), exec_detached; the child leads a session of its own (§4.11)
child_wait(Child) -> Exitednone (ending a handle is not using one)
child_release (edition 9)(Child) -> intexec_detached; the child runs on and is reaped by the runtime (§4.11)
child_pid (edition 9)(&Child) -> intnone
child_kill(&Child, signal: int) -> intchild_signal
child_kill_group (edition 9)(&Child, signal: int) -> intchild_signal; child_kill’s signals to the process group the child leads (§4.12)
pipe_read(&!Pipe, &![byte]) -> Receivedpipe_read
pipe_write(&!Pipe, &[byte]) -> Sentpipe_write
pipe_nonblocking(&!Pipe) -> intnone
pipe_close(Pipe) -> intnone
child_end_close(ChildEnd) -> intnone
poller_add_pipe(&!Poller, &Pipe, token, events) -> intpoll
poller_add_child(&!Poller, &Child, token) -> intpoll

Received and Sent are the socket handles’ enums (native-sockets.md §3.1): a pipe is a byte stream to a peer, and its outcomes are a socket’s, Again included.

Discharge follows the existing pattern: owning an Exec("p") discharges exec("p"); owning a Child discharges child_signal; owning a Pipe discharges pipe_read and pipe_write; a World discharges exec("").

3.3 One example

The MCP server’s call, in full: start one tool with a fixed argument list, give it a request on standard input, read its answer, reap it.

edition 7;

// A sketch: the request is written and the answer read in the elided part.
fn call[&h, &x, &q](heap: &!h Heap, exec: &x Exec("/opt/cancho-tools/bin"), request: &q [byte])
    -> [heap, exec("/opt/cancho-tools/bin"), pipe_read, pipe_write] (buffer.Buffer, int) {
    var answer = buffer.empty(heap, 4096);
    var status = 0 - 1;
    match pipe_open() {
        Piped::Failed(e) => { }
        Piped::Ok(to_child, child_in) => {
            match pipe_open() {
                Piped::Failed(e) => { pipe_close(to_child); child_end_close(child_in); }
                Piped::Ok(from_child, child_out) => {
                    // `\0`-separated (§4.2); a real caller builds this with `std.process.Argv` (§7.1).
                    let args = "--root\0/work\0--create\0--stdin\0notes.txt\0";
                    match exec_spawn(exec, "/opt/cancho-tools/bin/write", args, "",
                                     Stdio::Pipe(child_in), Stdio::Pipe(child_out), Stdio::Null) {
                        Spawned::Failed(e) => { pipe_close(to_child); pipe_close(from_child); }
                        Spawned::Ok(child) => {
                            // write the request, close to signal EOF, read to the end, reap.
                            ...
                            match child_wait(child) {
                                Exited::Code(n) => { status = n; }
                                Exited::Signaled(s) => { }
                                Exited::Failed(e) => { }
                            }
                        }
                    }
                }
            }
        }
    }
    return (answer, status);
}

std.process (§7, slice 3) wraps this into run(heap, exec, path, args, input, most, timeout), which is what the MCP server and a test actually call. Corrected (#275): run cannot take an Exec narrowed by its caller (§7.1), so the spawn stays here and std.process.capture does the rest: the input written while the output is read, a bound, a deadline, and the reap.


4. Semantics

4.1 The path, and the bound

  • path is the program to run, absolute or relative to the working directory, never looked up in PATH. Absolute or relative Corrected (#292, §4.10): a relative path is outside every bound but "": it does not begin with the prefix, so under a narrowed Exec it traps like any path outside it (measured with Exec("/bin"), path true, run from /bin: SIGILL; every_refused_spawn_traps_on_both_backends has the case). Relative paths are therefore for a program holding Exec(""), where the working directory is the parent’s or, with exec_spawn_in, a Dir’s (§4.10). A program that has no environment (cancho programs have none) has no PATH to search, and a search would make the program that runs depend on something the row does not name.
  • It is checked against the capability’s prefix with Fs’s rule (filesystem.md §1.1): a path prefix extends at a / or not at all, exactly the prefix is allowed (one program), and "" contains everything. A path containing a .. component is refused, as Fs refuses it (§4.1 there). Both are traps, for filesystem.md §4’s reason: a path outside the bound is a program doing what its type said it would not, not an outcome to handle.
  • A missing program, one that is not executable, or one the kernel cannot load is an outcome: Spawned::Failed(errno), with the parent’s ChildEnds and Files closed by the builtin, so a failed spawn leaks nothing.
  • argv[0] is path as given. args is the rest.

narrow(exec, "/opt/cancho-tools/bin") is the same narrow Fs has, at compile time, against a literal.

4.2 Arguments are \0-separated bytes

args is one &[byte]: each argument followed by a \0. "" is no arguments. This is C’s own encoding, so the builtin builds the argv array from it without copying a byte, and it needs nothing the language lacks: a slice of slices would need a region per element. An argument cannot contain \0 in any encoding a process can receive, so the separator excludes nothing. A malformed list (not empty, and not ending in \0) is a trap, like an out-of-range index: it is a program bug, not a kernel outcome. std.process.Argv builds one in a buffer (§7.1).

4.3 The environment is exactly what is passed

env is the same encoding, each entry NAME=value. The child’s environment is exactly env: nothing is inherited, and "" is an empty environment. A cancho program cannot read its own environment, so it could not forward it knowingly. Inheriting it silently would hand the child an authority (HOME, credentials in variables, LD_*) that no row names.

One rule on what may be passed: an entry whose name starts with LD_ or DYLD_ traps. Those variables make the dynamic loader run code the parent chose (LD_PRELOAD=/tmp/x.so), from a file outside the Exec bound, so passing one would run a program the capability does not name. That is §4.1’s broken promise by another route, so it is §4.1’s trap. #237 item 4 (a preloaded shim) is therefore deliberately out of reach through Exec; the issue already says it can stay a C file.

4.4 The three streams are what the parent gives

Each of the child’s stdin, stdout and stderr is one Stdio:

  • Stdio::Null: /dev/null, opened by the builtin.
  • Stdio::Pipe(end): the child’s end of a pipe_open. The builtin consumes it, so after exec_spawn the parent cannot still hold the child’s end. That is the classic pipe bug (the parent keeps a copy of the write end, so the reader never sees end-of-file and both wait forever), and here it is not a discipline: ChildEnd is res, and exec_spawn is the only thing besides child_end_close that consumes one.
  • Stdio::File(file): an open File, for “write the child’s output to this file”. The parent needed the authority to open it, so passing it passes nothing new.

There is no Inherit. A child that inherited the parent’s descriptor 1 would write to the parent’s console, and the parent may have released its Io: inheriting would be a way to print without io_write. Giving a child the console is an open question (§9) because the shape that is exact needs the parent to lend its Io, which a Stdio value cannot carry.

pipe_open answers two ends of a socket pair (AF_UNIX, SOCK_STREAM), not a pipe(2). The reason is SIGPIPE. A write to a pipe(2) whose reader has gone raises SIGPIPE, which ends the parent, and Linux has no per-write way to suppress it (MSG_NOSIGNAL is for sockets only). A socket pair takes MSG_NOSIGNAL on Linux and SO_NOSIGPIPE on macOS, the way Conn already does (native-sockets.md §3), so pipe_write answers Sent::Failed(EPIPE) like conn_write and never raises a signal. The child sees an ordinary descriptor it reads and writes. Changing SIGPIPE’s process-wide disposition instead was considered and refused for the same reason native-sockets.md refused it: it is global state a library would change behind the program’s back.

4.5 No descriptor crosses unless it is given

Today every descriptor the backends open is inherited across exec. Measured, in a cancho program, by reading /proc/self/fdinfo: a File (flags: 0100000), a Dir (0300000) and a Listener (02) all have O_CLOEXEC (02000000) clear. Only the Poller and the signal descriptor set it.

So slice 0 (§7) is a tightening in every edition: every descriptor the backends open is opened close-on-exec (O_CLOEXEC, SOCK_CLOEXEC, F_DUPFD_CLOEXEC, and fcntl where a platform has no flag). After it, the child’s descriptors are exactly 0, 1 and 2, as Stdio says. It is a tightening rather than an edition matter (editions.md §5): the only program it changes is one that passed a descriptor to a child through Ffi, which is the leak this closes. file-writes.md’s “No O_CLOEXEC” note is corrected by the same change.

Built (slice 0). What each builtin’s descriptor now comes from:

builtinsLinuxDarwin
fs_read, fs_write, open_read, open_diropenat(AT_FDCWD, path, ... | O_CLOEXEC)the same
dir_enter, dir_open_*, dir_listopenat(dir, name, ... | O_CLOEXEC)the same
open_write, open_append, open_new, open_rwfcntl(F_DUPFD_CLOEXEC) of fopen’s descriptorthe same
tcp_listen, tcp_connect, bind, connectsocket(..., SOCK_STREAM | SOCK_CLOEXEC, ...)socket, then fcntl(F_SETFD, FD_CLOEXEC)
tcp_accept, acceptaccept4(..., SOCK_CLOEXEC)accept, then fcntl
poller_new, signals_watchepoll_create1(EPOLL_CLOEXEC), signalfd(SFD_CLOEXEC), as beforekqueue, then fcntl

creat, open and dup are no longer called. The flag values live once, in cancho_ir::open_flags and SocketOs.

Two windows remain, and both are stated rather than closed. Where Darwin has no flag, fcntl follows the call that made the descriptor, so an exec on another thread between the two would still inherit it; and fopen’s own descriptor is not close-on-exec for the instant before its fclose, on both targets. Neither is reachable from a cancho program until slice 1 (only a foreign exec can start a process, and a cancho program has one thread per spawn it wrote), and slice 1 closes the first one for spawns it makes itself: posix_spawn on Darwin can be asked to close every descriptor but the three it is given (POSIX_SPAWN_CLOEXEC_DEFAULT).

Built (slice 1), and stronger than this section first said. A child started with exec_spawn holds exactly its three streams on both kernels, whatever the parent holds: macOS with POSIX_SPAWN_CLOEXEC_DEFAULT, Linux with posix_spawn_file_actions_addclosefrom_np(3) (glibc 2.34 and later) after the three dup2s. Close-on-exec alone could not promise that: it covers what a cancho program opens, and a descriptor the program inherited without the flag (a CI runner hands every process several, measured in slice 0’s test) would have passed straight through to a child. So both windows above are closed for every child exec_spawn starts; they remain only for a foreign exec.

Checked by close_on_exec.rs: a program runs system("ls /dev/fd") through Ffi once before opening anything and once holding one of everything a builtin opens (fourteen descriptors), and the two listings must be equal. Before the change the second had twelve more, on both backends. Equal rather than 0 1 2, because macOS CI found the first version of the test wrong: the runner hands every process descriptors of its own (131 and up), and those pass through a program untouched. fs_read and fs_write close their descriptor before returning, so no child can be shown one, and the test cannot tell those two from before.

Mutants: each site reverted alone, in each backend, 14 in all; 12 are killed. The two that survive are dir_list’s O_CLOEXEC, and they are equivalent on Linux: glibc’s fdopendir sets FD_CLOEXEC on the descriptor it is given (measured: F_GETFD answers 0 before it and 1 after). The flag stays, because it closes the moment between openat and fdopendir and does not rest on what one libc does.

4.6 Signals start at their defaults

A program that claimed TERM holds it blocked on Linux and ignored on macOS (signals.md §5), and exec keeps both a blocked mask and an ignored disposition, so a child would be unkillable by TERM. The spawn therefore sets the child’s signal mask to empty and every catchable signal’s disposition to the default (POSIX_SPAWN_SETSIGMASK, POSIX_SPAWN_SETSIGDEF). The child starts the way a shell would start it.

4.7 A Child is reaped on every path, and a kill cannot miss

Child is res, and child_wait is the only thing that consumes it. So a program that starts a process and returns without waiting for it does not compile: the zombie process is a linearity error, not a leak to find later.

The same fact makes child_kill safe. A process that has exited but not been reaped keeps its pid, so a pid cannot be reused while its Child exists. kill(pid, s) on a live Child can only reach that child, on both kernels, with no pidfd needed for correctness.

child_kill(child, signal) takes one of std.signals’ fixed bits (signals.md §2.2) and adds one only sending can use: KILL (bit 256). STOP and the fault signals are refused (Failed(EINVAL)): stopping a child leaves it in a state child_wait would wait on forever. The answer is 0 or the errno; signalling a child that has already exited answers 0.

child_wait blocks until the child ends and answers Exited::Code(n) (it called exit(n)), Exited::Signaled(bit) (a signal ended it, reported in std.signals’ bits, 0 for one with no bit), or Exited::Failed(errno). It waits for its pid only, never -1, so a thread waiting for one child cannot reap another thread’s.

Letting go (§4.11). child_release is the third way to consume a Child: the process keeps running and the runtime reaps it, so the sentence above holds for it too.

What linearity cannot do. If the parent traps or is killed, its children are not killed with it. Linux could ask for that (PR_SET_PDEATHSIG), macOS cannot, and posix_spawn cannot ask on either. The guarantee is “reaped on every path the program completes”, and it is stated that way.

4.8 Waiting beside everything else

poller_add_child registers a child’s exit as readable, as poller_add_signals does a signal: a pidfd on Linux (pidfd_open, 5.3 and later), EVFILT_PROC/NOTE_EXIT on macOS. poller_add_pipe registers a Pipe like a Conn. Together they give both askers the loop they need: a child, its output, a deadline (poller_wait’s timeout and the Clock), and, for #237, fake peers on Conns in the same loop. After poller_wait reports the child, child_wait answers without blocking.

Built (slice 2), and what building it settled.

  • The pidfd lives in the Child. epoll watches a descriptor and a pid is not one, so exec_spawn opens the pidfd itself, straight after the spawn and before anything can reap the child (an unreaped pid is still its own, §4.7). A Child is still one word: the pid in the low 32 bits and the pidfd in the high 32, and child_wait closes it as it reaps. The alternative, opening it when poller_add_child is called, leaves a descriptor with no owner: the Poller cannot know which to close, and the Child is consumed by child_wait before the Poller is. A pidfd per live child is the price; it is one descriptor, and the same one child_wait gives back (reaping_a_child_gives_back_its_pidfd).
  • pidfd_open goes through syscall. glibc has a wrapper only from 2.36, and the number, 434, is the same on both Linux architectures.
  • A refusal is the errno, carried. If pidfd_open is refused (a kernel before 5.3, or a policy that forbids the call), the high half holds the errno negated and poller_add_child answers it: ENOSYS, not EBADF for a descriptor the program never held. EMFILE is not among the reachable ones: the descriptor a child’s end held is closed before the pidfd is asked for, so one is always free. Corrected (#275): EMFILE is reachable. A spawn frees a descriptor only when it hands one over (a Pipe end, a File); a Null stream is opened in the child, so a spawn whose three streams are Null, with every descriptor in use, leaves none for the pidfd. Measured under a limit of 8 with 4 to 7 held: 24; under 9: 0 (a_child_with_no_descriptor_to_spare_says_emfile). The child is still started and reaped; only watching it is refused. A test installs a seccomp filter answering pidfd_open with ENOSYS to reach the other path.
  • A channel is watched as a Conn is. poller_add_pipe is poller_add_conn for a Pipe: a channel is a socket pair, so there is nothing more to it. There is no poller_remove for either: closing the descriptor (pipe_close, child_wait) takes it out of the set.
  • macOS does not take a zombie. kevent with EVFILT_PROC/NOTE_EXIT on a child that has already exited and not been reaped answers ESRCH (3), where a pidfd is simply readable. Measured on the first macOS CI run of slice 2, and in C on macOS 26.2 (scripts/kqueue_zombie.c): 200 children of 200, each confirmed a zombie with waitid(WNOWAIT) first. A Child nobody has reaped still owns its pid (§4.7), so ESRCH can only mean it has exited, and poller_add_child says so the way kqueue says anything is ready for the asking: an EVFILT_USER event, EV_ADD | EV_ONESHOT with NOTE_TRIGGER, in one change, carrying the token. poller_wait reads it as readable, as it does the EVFILT_PROC one. Corrected (#275): the first fix sent two changes, an add carrying the token and then a trigger, and the second change’s udata (0) replaced the first’s: the event was reported with token 0 (the second macOS CI run; reproduced in C). A later change to a knote rewrites its udata, so the add and the trigger are one change.
  • Darwin reports an exit once. EVFILT_PROC with NOTE_EXIT is registered EV_ONESHOT, and poller_wait reads it as readable. The pidfd of Linux stays readable until the child is reaped (level-triggered, as the rest of the poller is); on macOS the report comes once, which is all a program that calls child_wait on it needs.

4.9 Threads

posix_spawn is safe from any thread, and nothing here is process-wide state except what the kernel already makes so. A child started by one thread can be waited for by another, since the Child moves.

4.10 The working directory

A child started with exec_spawn starts in its parent’s working directory. A tool that is told to work “in this project” would otherwise have to be handed an absolute path for everything or to cd itself, and the program that runs it (cancho-tools’ future run) has already opened that directory as a Dir. So there is a second builtin,

exec_spawn_in(&Exec("p"), &Dir, path, args, env, stdin, stdout, stderr) -> Spawned

which is exec_spawn with the child’s working directory the directory the Dir holds.

  • A second builtin, not an eighth parameter. Edition 7’s exec_spawn has callers (the MCP server’s among them), and a parameter added to it would stop them compiling. The two lower to one IR node (Expr::ExecSpawn, with an in_dir flag) and one function per backend, so nothing about the spawn is written twice.
  • The Dir is lent. It is &Dir: shared, not consumed, so the program keeps it and can open beneath it, list it and dir_close it after the spawn. exec_spawn consumes a ChildEnd because the child’s end must not stay open in the parent (§4.4); a directory descriptor held open by the parent harms nothing, and the child never holds it (below). A Dir by value, or a File, is a type error (tests/reject/exec_spawn_in_owned_dir.cho).
  • No new row. The row is exec("p") and no more. The Dir came from an Fs the program already held, and §2’s point stands: starting a program is an authority over what that program can do, whatever directory it starts in. A program holding Exec("") and a Dir could already start a child with a path to everything under it.
  • Underneath. posix_spawn_file_actions_addfchdir_np (glibc 2.29, macOS 10.15): the descriptor is named to the kernel and the child changes directory before it runs the program. Nothing changes in the parent, so it is safe from any thread (§4.9), as a chdir before the spawn would not be. The file actions run in the order they are added, so it is added after the three streams and before the closefrom of §4.5: the Dir’s descriptor is above 2, and closefrom would close it.

Measured (scripts/process_cwd_stderr_measure.py, ctypes into libc with the flags exec_spawn sets; macOS 26.2 and Linux 7.0 with glibc 2.43):

macOSLinux
addfchdir_np is thereyesyes
the child’s pwd is the Dir’s directory; without a Dir, the parent’sbothboth
the parent’s working directory afterwardsunchangedunchanged
a relative program path (./tool) resolves against the Dir; without one, ENOENT (2)bothboth
descriptors the child lists (ls /dev/fd, /proc/self/fd), with the Dir and withoutequalequal
the chdir added after addclosefrom_np(no closefrom; under CLOEXEC_DEFAULT the chdir last works)EBADF (9): the spawn fails
a descriptor of a regular fileENOTDIR (20)ENOTDIR (20)
a descriptor that is not openEBADF (9)EBADF (9)
a directory opened for reading that has no search permissionEACCES (13)EACCES (13)
a Dir whose directory has since been removedthe spawn succeeds, the child runs in a directory that is gone (pwd exits 1)the same

The first three failures cannot come from a Dir the type system gave (ENOTDIR and EBADF need a descriptor that is not a directory); EACCES can, and it is an outcome: Spawned::Failed(13), with the parent’s ChildEnds and Files closed as for any failed spawn (§4.1). The one case that is not an outcome is the allocator refusing the file action (ENOMEM), which traps as a failed malloc for the argument list does; a dropped chdir would start the child in the wrong place, and nothing the program can arrange reaches that path, so it has no mutant (§8).

4.11 Letting go: child_release, and a child in a session of its own

#524. A server that starts a job (cancho-code’s MCP server starts a build that takes minutes, answers with a run id at once, and lets a client poll) cannot be written with §4.7 alone. A Child has to be waited for, which blocks, or killed, which stops the job, and a program that does neither does not compile. The server worked around it with four constant sh -c scripts, ps, and a heuristic for whether a pid is still the run’s. Three builtins, edition 9, replace that:

exec_spawn_detached(&Exec("p"), path, args, env, stdin, stdout, stderr) -> Spawned   // [exec("p"), exec_detached]
child_pid(&Child) -> int                                                            // []
child_release(Child) -> int                                                         // [exec_detached]
fn start[&x](exec: &x Exec("/opt/builds/bin")) -> [exec("/opt/builds/bin"), exec_detached] int {
    match exec_spawn_detached(exec, "/opt/builds/bin/build", "--all\0", "", Stdio::Null, Stdio::Null, Stdio::Null) {
        Spawned::Ok(child) => {
            var pid = 0;
            borrow child as &c in { pid = child_pid(c); }
            child_release(child);       // the job runs on; nothing is owed
            return pid;                 // the run id the client polls with
        }
        Spawned::Failed(e) => { return 0 - e; }
    }
}
  • What each does. exec_spawn_detached is exec_spawn whose child leads a new session and process group of its own. child_pid reads the pid out of the Child, which is read-only and must come before the release because the release ends the handle. child_release consumes the Child: the job keeps running, the parent holds no handle and owes no wait. It answers 0.
  • Two builtins and a reader, not a changed signature. exec_spawn has callers, as §4.10 found, so the detached start is a second builtin that lowers to the same IR node (Expr::ExecSpawn, with a detached flag) and the same function in each backend. A detached start with a working directory (exec_spawn_detached_in) is not built: no program has asked.
  • Release works on any child. A child from plain exec_spawn can be released too, and runs on in its parent’s session. The two are independent: detached is about where the child lives, released is about whether the parent owes it a wait. A server wants both; a test that starts a service and lets the harness’s end clean up wants release alone.

A released child is not the parent’s zombie. The weak point of “just let go” is the process table: a child that ends is a zombie until its parent reaps it, and a server that releases thousands of jobs over its life would fill the table. Three mechanisms were considered:

MechanismWhy not
Ignore SIGCHLD (or SA_NOCLDWAIT), so the kernel reapsIt is a per-process setting. With it set, waitpid for a child the program did keep fails with ECHILD, so child_wait stops working for every other child (measured, Python’s os.waitpid after SIGCHLD = SIG_IGN: No child processes, on macOS 26.2 and Linux 7.0)
Start the child through a double fork, so init adopts it at onceThe started process is then not the parent’s child: child_wait and poller_add_child cannot work on it, and §4.7’s guarantee that a pid is not reused while its Child exists is gone. A fork in a program with threads (§4.9) may call only async-signal-safe functions before the exec
A thread of the runtime reaps itChosen. child_release closes the pidfd (§4.8) and starts a detached thread whose only work is waitpid on that pid, then ends. It never waits for -1, so it cannot take the status of a child the program kept, and nothing about child_wait changes

Its costs, measured on macOS 26.2 (Cranelift): 200 released children still running are 200 threads and 3.3 MB more resident memory than none (1.3 MB to 4.6 MB, about 16 KB each); 50 that ended leave no thread and no process (ps finds nothing of any of them while the parent lives, on both backends). The thread has a 64 KiB stack, since it makes one call. If pthread_create fails (EAGAIN), child_release answers its error number instead of 0: the child still runs, but it is a zombie when it ends until the parent ends. When the parent ends the threads go with it and the kernel hands the children to init. The thread inherits the parent’s signal mask, so a claimed signal (signals.md) stays blocked in it too, and it installs no handler, so nothing interrupts its waitpid.

The session. exec_spawn_detached adds POSIX_SPAWN_SETSID to the flags of §4.6 (0x80 in glibc, 0x400 in macOS’s spawn.h, both read from the header). The child is its own session leader and group leader (pgid and, on Linux, sid equal to its pid) and has no controlling terminal. That is what the use needs: a hangup of the parent’s terminal, or a kill -- -pgid of the parent’s group by a shell or a supervisor, does not reach it. The tests start two released children and kill the parent’s group: the plain one dies with it and the detached one lives. What it does not do: a container’s or a service manager’s own kill (a cgroup, a launchd job) takes every process it holds, session or not; and nothing here keeps a plain child alive when the parent’s group is killed, which is the point of the second builtin.

What the caller can do with the pid. child_pid answers a number, and nothing in cancho signals a number: child_kill takes a &Child, and the Signals capability is about the signals a program claims for itself. That is deliberate and stays so. A kill(pid, s) taking any pid would give a program holding only child_signal a way to signal every process of its user, which is far more than the label says. The same effect is available today under the authority it really is: start /bin/kill through an Exec narrowed to it (exec("/bin/kill") in the report). Because the child leads its own group, kill -TERM -- -<pid> stops the job and everything it started, and kill -0 <pid> asks whether it is there. What a server must remember: a pid is only a name. After the process is gone and reaped, the number can belong to another one, and §4.7’s guarantee ended with the handle. A server that polls by pid should record the pid with something that tells runs apart (a start time, a run directory the job writes into) and not trust the number alone. A kill-by-pid builtin, with a capability of its own, is §9’s open question, not part of this slice.

The authority report. A new plain label, exec_detached, performed by exec_spawn_detached and by child_release, never by child_pid. It is path-free: which programs may be started is still exec("p"), and narrow bounds the detached start exactly as any other (the checked path is the same code, and a test starts one outside the prefix and sees the trap). So a report that says

performs
    exec("/opt/builds/bin")
    exec_detached

tells a reader: this program starts only what is under that directory, and what it starts may be left running when the program ends. The label sits on the release as well as on the detached start because the release is the act after which any child outlives the program; a program that starts a child in its own session and waits for it is reported as well, which is the conservative side. Owning an Exec discharges it, as it does child_signal (§3.2), and so does a World. It is edition 9 like the three builtins: nothing an older file can write reaches it (editions.md §5, an addition is absent from older editions), and a file that already declares a function called child_release is unchanged. cancho authority’s “never touches” list counts it with exec: a program with neither still reports “other programs” as untouched.

WebAssembly has no processes, so the three are refused there like the other process builtins (unsupported-on-target, tests/reject/exec_spawn_detached_on_wasi.cho).

4.12 Stopping what a worker started: child_kill_group

#533, found by the cancho-forge spike (spike/s7_grandchild.cho, spike/RESULTS.md S7). A supervisor runs a worker that itself starts subprocesses (a compiler, a test runner) and stops it at a deadline. capture’s kill reaches the worker’s pid only: measured, with /bin/sh -c 'sleep 77 & wait' and a 300 ms deadline, the sleep is still in state S when the supervisor has ended. §4.11 named the way out, /bin/kill -KILL -- -<pid> through a narrowed Exec, and it costs what the issue says: cancho authority reports exec("/bin"), which is wider than “one worker directory” and undoes the point of a narrow bound, and the pid is a bare number.

child_kill_group(&Child, signal) -> int      // [child_signal]

child_kill_group(c, signal) sends signal (the same set as child_kill, §4.7: std.signals’ bits and KILL, 256; STOP and the faults are EINVAL) to the process group the child leads: kill(-pid, native). 0, or the errno. It is edition 9.

The shape chosen, and what it was chosen over.

AlternativeWhy not
A flag in child_kill’s signal argumentchild_kill is edition 7, and edition 7 is closed (§6.4 of editions.md): a value that answered EINVAL yesterday would send a signal to a group today, in a file that did not change. The argument is also a signal bit; a second meaning in the same integer is the mistake Stdio::Merge was kept out for (§5)
A Signals bit, or a new capabilitySignals is about the signals a program claims for itself (§4.11). The program already holds what this needs: a Child it started. A capability for “a group” would have to be handed around beside the Child and could be pointed at any group
kill(pid, s) on a number (pid_kill), or a “pid with start time” handle§4.11 and §9: it needs a capability of its own, since it would reach every process of the user. The issue asks for the signal “held by whoever holds its Child”; this is that, and nothing wider
A distinct type for a detached child (DetachedChild), so a plain child cannot be askedEvery function that takes a Child (child_wait, poller_add_child, capture, child_release) would have to take both, or Spawned would split in two; an edition, for a question the kernel already answers (below)
A second builtin, same row, same signalsChosen. The precedent is §4.11’s own: exec_spawn has callers, so the detached start is a second builtin over the same code. Here child_kill_group lowers to the same function in each backend as child_kill, with the target negated

Only a detached child can be signalled this way, and that holds by construction, not by a check. kill(-P, s) addresses the group whose id is P, and a group’s id is the pid of its leader. The child’s pid is P. A child started by exec_spawn_detached leads a group of its own, so the group P exists and is its. A child started by exec_spawn is in its parent’s group, whose id is another process’s pid; no group is named P unless the child makes one for itself (a shell with job control does, and then the group holds only that child’s own descendants). Linux does not hand the number P to a second process while P is in use as a pid or a group id, so there is no group named P that is anyone else’s (macOS’s allocator is not read here; its CI run is what checks it). The answer for a plain child is therefore ESRCH (the constant is 3 on both kernels; measured on Linux), nothing is sent, and the parent is not signalled: kill(-P) can never address the parent’s group, since that group’s id is not P. Measured, both backends on Linux: a plain child answers 3 and the program goes on to kill the child and print what it did (tests/programs/process_group.cho, mode r). Refusing it at compile time would need the type split of the last row. A guard remains for what no child can have: a pid of 1 or less would make the call kill(-1) (every process the user may signal) or kill(0) (the caller’s own group), so those answer EINVAL with no call. No program can reach that guard (a child’s pid is never 1), and it has no test and no mutant, for that reason.

The authority report. No new label. child_kill_group performs child_signal, the label of child_kill, and a program that owns a Child discharges it (§3.2). It does not need exec("/bin"), or any Exec at all, beyond the one that started the worker: a function fn stop[&c](c: &c Child) -> [child_signal] int is the whole of it. tests/accept/process_group_kill.cho starts /bin/sleep in its own group, stops the group and waits, and its report is

performs
    child_signal
    exec("/bin/sleep")
    exec_detached
    io_write

and the supervisor’s bound can be the worker’s directory. Why the same label is honest: the group a detached child leads is in a session of its own (§4.11), and a process can join a group only from the same session, so the group holds the child’s descendants and nothing else. A signal to it is a signal on processes the program started, which is what child_signal says. What the label does not promise is that every descendant is in the group: one that calls setsid or setpgid leaves it (a build tool that does is not stopped), and a group is not a cgroup. The guarantee is the one the issue asked for, the worker’s ordinary subprocesses.

The pid-reuse hazard. The call takes a &Child, not a number. While the Child exists the leader’s pid is held, zombie or not (§4.7), so -P cannot be another process’s group: the hazard of §4.11’s /bin/kill is gone for this path. A group outlives its reaped leader while it has members (measured, S7: Z after the kill), and the kernel does not reuse its number as a pid meanwhile. After the group is empty and the Child has been waited for there is nothing to signal and no handle to signal with. The test a_leader_that_has_ended_does_not_hide_its_group signals a group whose leader has ended and not been waited for, and reaches the grandchild.

capture uses it. A supervisor holds the Child only inside capture, which consumes it, so signalling “after capture” is signalling a number again. capture therefore sends the group KILL before the pid’s, on the paths on which it kills (the deadline, the output bound, a failed watch). For a plain child the first is ESRCH and nothing more happens, so the behaviour of a plain worker is what it was. What it does not do: a worker that exits by itself and leaves a subprocess behind is not killed (the exit ends the capture, capture’s comment says so), and the left behind process runs on. A caller that wants it stopped holds the Child itself and calls child_kill_group before child_wait.

Both backends, and the compiler in cancho. Cranelift and LLVM emit the call as child_kill’s with the pid negated and the guard above (child_kill(args, group) in each). The compiler in cancho (examples/selfhost) has it as builtin 138, cancho_child_kill_group in runtime.ll; tables.cho, builtins.cho and signatures.cho are regenerated, so it is not in unported_builtins.txt. WebAssembly refuses it as it does the other process builtins (tests/reject/child_kill_group_on_wasi.cho). No new rule: child_kill_group_is_edition_nine is not-a-function (an older edition does not have the name) and child_kill_group_undeclared is effect-not-declared.

What this does not close is in §9: after a supervisor crashes there is no Child to hold, and the group id in its journal is a number again.


5. Alternatives considered

AlternativeWhy not
fork and exec as builtinsfork copies the parent’s heap and every thread’s locks into a child that runs one thread, and everything between fork and exec must be async-signal-safe. A language whose backend calls malloc cannot promise that. posix_spawn is the operation both askers mean, and it does the descriptor and signal setup (§4.4 to §4.6) in the kernel’s own order
Keep it Ffi("libc")The status quo. reach.md §5 and under-a-grant.md §4 are the argument: Ffi is the one capability whose label does not bound what it authorises
One run-to-completion builtin (exec_run(..., input, output) -> code)Serves the MCP server and not #237, which must kill a child at a moment it chooses and serve sockets meanwhile. It is the right library function, and std.process.capture is it (slice 3, §7.1: the spawn stays with the caller), built on the handles
A list of programs as the bound (Exec("seek,write"))Signals uses a set because signals are a closed list. Programs are files, and the natural unit a deployment grants is a directory (/opt/cancho-tools/bin). A prefix that is exactly one file already expresses “this one program”
The child’s ends as FileA File is a regular file: no Again, no readiness, and a write to a closed pipe raises SIGPIPE. A child’s stream is a peer, and cancho already has a handle for a peer’s byte stream. Stdio::File keeps redirecting to a real file
The parent’s ends as ConnThe same machinery, but conn_read in a row would read as network. A distinct Pipe keeps the row honest about what the program talks to, at the cost of four small builtins over one backend path
PATH lookup (execvp)§4.1: the program that runs would depend on an environment the row does not name
Inherit the environment§4.3
Stdio::Inherit§4.4: a child would print for a parent that released Io
Stdio::Merge (2>&1) for standard errorA fourth variant of Stdio stops every edition-7 program that matches Stdio exhaustively (the process probe does) from compiling, so it is an edition, not an addition. It also gives the caller one stream where a tool that reports failures on standard error wants two. Measured (scripts/process_cwd_stderr_measure.py): with both on one socket the order the child wrote is the order read, 50 of 50 runs of 500 alternating lines on each kernel, so it would be exact; that is what is given up, and §7.2 reads a third channel instead. Open (§9)
Spawn beneath a Dir (execveat)Would close §2’s symbolic-link gap the way directory-handles.md did for files. Linux has execveat and macOS has nothing equivalent, and posix_spawn takes only a path. Open (§9)

6. Underneath

Both targets have posix_spawn, posix_spawn_file_actions_* and posix_spawnattr_*, all non-variadic, so neither backend calls a variadic C function for the spawn (filesystem.md §2.2). The posix_spawn_file_actions_t and posix_spawnattr_t objects are backend-internal stack buffers, sized per target the way stat_layout sizes struct stat (directory-listing.md), and initialised and destroyed by the libc functions. The argv and envp pointer arrays are built in a stack or heap buffer from the \0-separated slices and never reach the program, the way getaddrinfo’s result never does (net.md §4.1).

stepLinuxmacOS
a channelsocketpair(AF_UNIX, SOCK_STREAM | SOCK_CLOEXEC)socketpair then fcntl(FD_CLOEXEC) and SO_NOSIGPIPE
the child’s 0, 1, 2posix_spawn_file_actions_adddup2, _addopen("/dev/null")the same
the child’s working directory (§4.10)posix_spawn_file_actions_addfchdir_np (glibc 2.29), before the closefromthe same (macOS 10.15)
everything else closedposix_spawn_file_actions_addclosefrom_np(3) (glibc 2.34), over close-on-exec everywhere (slice 0)POSIX_SPAWN_CLOEXEC_DEFAULT, over the same (§4.5)
signalsPOSIX_SPAWN_SETSIGMASK, POSIX_SPAWN_SETSIGDEFthe same
exit readinesspidfd_open (via syscall, since glibc only wraps it from 2.36)kqueue EVFILT_PROC, NOTE_EXIT
reapwaitpid(pid, &status, 0)the same

7. Slices

SliceWhatEdition
0Close-on-exec on every descriptor the backends open (§4.5), with a conformance test that asks a child what it inherited. Builtevery edition (a tightening)
1Exec, the exec, child_signal, pipe_read, pipe_write labels, Split’s ninth field, pipe_open, exec_spawn, child_wait, child_kill, the Pipe operations; both backends, both targets. Built (§8)7
2poller_add_pipe, poller_add_child. Built (§4.8, §8)7
3std.process: Args (the \0 builder) and run(heap, exec, path, args, input, most, timeout) Argv (the \0 builder), channels, and capture(heap, clock, child, to_child, from_child, input, most, timeout), a bounded capture with a deadline, built on slices 1 and 2. Corrected (#275): neither name nor signature could be built; §7.1. Built (§8)7
4cancho-tools#10: the MCP server, as a cancho program holding Exec narrowed to the tools’ directory and nothing that writes and no Fs, Net or Ffi. Built (cancho-tools#17, §8)7

| 5 | The two prerequisites of a run tool in cancho-tools: exec_spawn_in (the working directory, §4.10) and std.process.capture_both (standard error beside standard output, §7.2). Built (§8) | 7 | | 6 | Letting go of a child (§4.11, #524) and signalling its group (§4.12, #533): exec_spawn_detached, child_pid, child_release, child_kill_group, and capture killing the group. Built (§8) | 9 |

7.1 Slice 3: std.process

Two of §7’s promises could not be built, measured with the compiler of slice 2.

  • run cannot take the Exec. A function’s parameter names one prefix, and nothing is polymorphic over it (effect-polymorphism.md). A library run would have to take &Exec(""), and a program holding Exec("/usr/bin") cannot pass it: expected "", found "/usr/bin". The MCP server’s whole point (§3.3) is holding Exec narrowed to the tools’ directory, so a run it can call does not exist. The spawn stays with the caller, under the caller’s own Exec, which is where the report should see it, and the library does everything after it.
  • Args is taken, and so is arg. `Args` is a built-in type and cannot be redeclared: it is the argument capability every Split holds. The builder is Argv, and adding to it is add, since arg is the builtin that reads one argument.

The surface.

pub res struct Argv { .. }                       // a `\0`-separated list, in a buffer.Buffer
pub fn argv[&h](heap: &!h Heap, capacity: int) -> [heap] Argv
pub fn add[&h, &s](heap: &!h Heap, list: Argv, one: &s [byte]) -> [heap] (Argv, int)
pub fn list[&a](list: &a Argv) -> [] &a [byte]  // what `exec_spawn` takes
pub fn drop[&h](heap: &!h Heap, list: Argv) -> [heap] int

pub enum Channels { Ok(Pipe, ChildEnd, Pipe, ChildEnd), Failed(int) }
pub fn channels() -> [] Channels                 // to the child, from the child
pub fn close(to_child: Pipe, from_child: Pipe) -> [] int

pub enum Ran { Code(int), Signaled(int), TimedOut, TooMuch, Failed(int) }
pub fn capture[&h, &c, &i](heap: &!h Heap, clock: &c Clock, child: Child,
        to_child: Pipe, from_child: Pipe, input: &i [byte], most: int, timeout: int)
    -> [heap, clock, poll] (buffer.Buffer, Ran)

The MCP server’s call (§3.3), with it:

match process.channels() {
    Channels::Failed(e) => { ... }
    Channels::Ok(to_child, child_in, from_child, child_out) => {
        match exec_spawn(exec, path, process.list(argv), "",
                         Stdio::Pipe(child_in), Stdio::Pipe(child_out), Stdio::Null) {
            Spawned::Failed(e) => { process.close(to_child, from_child); ... }
            Spawned::Ok(child) => {
                let (answer, ran) = process.capture(heap, clock, child, to_child, from_child,
                                                    request, 1048576, 10000);
                ...
            }
        }
    }
}

capture’s row is [heap, clock, poll]: owning the Child and both Pipes discharges child_signal, pipe_read and pipe_write (§3.2; checked: a function that owns them and kills, reads and closes declares none of the three), and rows are exact, so poll is there because poller_wait is. Nothing in std.process names exec: a program’s report says exec("p") exactly where it spawns.

What capture does, and the measurement behind each choice (scripts/process_run_measure.py, run on each kernel). All of it on one Poller: the output channel (readable, token 1), the input channel (writable, token 2, until the input is written) and the child (token 3).

  • Input and output are interleaved, never write-all-then-read. A channel holds 8,192 bytes on macOS 26.2 and 180,224 on Linux 6.8 before EAGAIN (an AF_UNIX socket pair, default buffers). Writing all the input before reading any output, to cat, finishes at 16 KiB and deadlocks at 32 KiB on macOS, and at 256 KiB and 512 KiB on Linux: the child blocks writing output nobody reads, and the parent blocks writing input nobody reads. A loop that passed every Linux test would deadlock on macOS at a size no Linux test tried, so both channels are non-blocking and both are on the poller from the start. The input end is closed once the input is written (the child’s end of file), at once for empty input, and as soon as a write answers Failed (a child that stopped reading is not an error; its exit status says what it thought).
  • The child’s exit ends the capture; the end of the stream does not. Once the poller reports the child, the output is read until Again or End, and the capture stops. A write to a socket has reached the reader’s queue when it returns, so everything the child wrote is there when it exits: for a child writing 1 byte, 64 KiB and 1 MiB and exiting, the bytes captured this way were all of them in 600 runs of 600 on each kernel (and the end of the stream had already arrived in every one). Waiting for the end instead would wait for any process that inherited the end: sh -c 'sleep 2 & echo hi' exits at once and its output ends 2.0 s later, on both. A grandchild’s output after the child has exited is not the child’s, and is not captured.
  • A deadline is a Clock, not a timeout per wait. poller_wait’s timeout restarts at every wake, so capture reads clock_ms once at the start and waits each time for what is left. When nothing is left, the child is killed (KILL, which cannot be caught or blocked), reaped, and the answer is TimedOut with what was captured so far.
  • most is a bound on what is kept, and exceeding it ends the child. The buffer never grows past most bytes. The first byte beyond it kills the child, which is reaped, and the answer is TooMuch with the first most bytes. An output bound that kept reading and discarded would still let a child run unbounded in time; with a deadline that is bounded too, but a tool that is producing more than its caller will read has already failed.
  • Every path reaps. child_wait is the only way out of capture that ends the Child, so linearity says this. Code(n) and Signaled(bit) are Exited’s; TimedOut and TooMuch say why capture killed it, rather than a Signaled(256) that could have come from anywhere.
  • A child the poller cannot watch is killed, not waited on blind. If poller_new or poller_add_child fails (EMFILE §4.8, ENOSYS on Linux before 5.3), there is no way to wait for the child and the deadline at once, and a blocking child_wait could outlast any deadline. The child is killed and reaped and the answer is Failed(errno). A Linux before 5.3 therefore cannot capture; it can still use the handles.
  • Standard error is the caller’s. capture reads one stream: the MCP server returns a tool’s standard output unchanged with its exit code (cancho-tools#10), and #237 drives its children through the handles. A caller passes Stdio::Null or a File for the third stream. Extended (§7.2): capture_both reads it too, for a caller that needs it; capture itself is unchanged.

add refuses an argument containing a \0, as an outcome, not a trap. Such an argument would arrive as two, and the second could be anything, --root included. exec_spawn traps on an unterminated list because that is the program’s own mistake (§4.2); an argument is data, and the MCP server’s arguments come from a model. So add answers 22 (EINVAL) and adds nothing, 0 when it adds, and the caller decides.

Not in slice 3: an environment (the MCP server passes none, and #237 builds its own list with Argv and calls exec_spawn itself); capturing standard error (built in §7.2); a capture for a child started with Stdio::Null input (open channels and pass empty input; it costs one socket pair).

How it was planned to be checked (§8 has what was built and found; both backends, both targets): cat fed and read 1 MiB, past both kernels’ deadlock sizes; most exceeded is TooMuch with exactly most bytes and the child reaped; sleep 30 with a deadline of 200 ms is TimedOut within the deadline and a margin; sleep 2 & echo hi answers hi well before 2 s; a child that never reads its input still exits Code(0); an argument with a \0 is refused and nothing is spawned with it; a caller whose row leaves out clock is refused (effect-not-declared; a test, since reject fixtures are checked without --std). Mutants, as for the slices before: the interleaving (write all, then read), the drain after the exit, the deadline taken per wait, the bound off by one, and the kill on each path.

7.2 capture_both: standard error beside standard output

A tool that runs a program for a model (cancho-tools’ future run) must return both what the program printed and what it complained about, and they are not one stream: an exit code with an empty stdout and a reason on stderr is the common failure. capture read one channel and left the third stream to the caller. Both channels are now captured, bounded and under the deadline, by capture_both.

pub enum ChannelsWithErrors { Ok(Pipe, ChildEnd, Pipe, ChildEnd, Pipe, ChildEnd), Failed(int) }
pub fn channels_with_errors() -> [] ChannelsWithErrors   // input, output, errors: parent's end then child's
pub fn capture_both[&h, &c, &i](heap: &!h Heap, clock: &c Clock, child: Child,
        to_child: Pipe, from_child: Pipe, from_errors: Pipe, input: &i [byte],
        most: int, most_errors: int, timeout: int)
    -> [heap, clock, poll] (buffer.Buffer, buffer.Buffer, Ran)   // output, errors

The caller passes Stdio::Pipe(child_errors) as the third stream. capture is now a thin call of the same loop with no errors channel; nothing about its behaviour changed (its tests and mutants are unchanged and pass).

What it does, and what was measured (scripts/process_cwd_stderr_measure.py, part B):

  • The errors channel is read on the same poller, never after the output. A channel holds 8,192 bytes on macOS and 180,224 on Linux. A parent that reads the output to its end and then the errors deadlocks against a child that writes more than that to its errors first: measured, 8,192 bytes of errors finish and 16,384 deadlock on macOS, 131,072 finish and 262,144 deadlock on Linux. On one poll set a child writing 1 MiB to each of its two streams completes on both. So the third channel is registered beside the other two from the start (token 4), non-blocking, and drained whenever readable. a_flood_of_errors_does_not_stall_the_output is a megabyte of errors before any output.
  • The exit still ends the capture, and both are drained to Again after it. For a child writing 1 byte, 64 KiB and 1 MiB to each stream and exiting, all of both streams were captured in 200 of 200 runs per size on each kernel, with both ends of stream already present in every one. Waiting for the end of either would wait for any process that inherited it (§7.1).
  • Each channel has its own bound. most for the output, most_errors for the errors: what is kept is at most most + most_errors bytes of content, whatever the child writes. The first byte past either bound ends the child (KILL), and the answer is TooMuch with both buffers as they were: the one that overran holds exactly its bound. TooMuch does not say which; a caller compares the sizes with its bounds (exactly the bound is not an overrun: four bytes of output under a bound of four is Code(0) in errors_past_their_bound_end_the_child, four of errors under three is not).
  • One deadline for both. It is the Clock’s, as in §7.1: a deadline that passes kills the child, reaps it and answers TimedOut with both buffers as read so far (a_deadline_returns_both_channels_so_far: a on one and b on the other, then sleep 30 under 300 ms).
  • A channel that has ended is closed, and nothing is spun on. A child that closes all three of its streams and sleeps for a second costs the capture no processor time by wait4’s account (bound 400 ms), as for capture. Every channel is also given back when the deadline ends the capture: sixty captures under a limit of 40 descriptors all complete.
  • The order between the two streams is not kept. Two channels are two queues; a caller that needs the interleaving reads one channel (the alternative of §5, and measured there: one socket keeps it exactly).

8. What it is checked by

What follows is slice 1 as built and measured; the poller (§4.8) is slice 2’s, and is checked there.

  • Refusals (tests/reject/), each with its rule tag: exec_without_capability (capability-misused), exec_widened (Exec("/a/b") cannot become Exec("/a")) and exec_sibling_prefix (/opt/x cannot become /opt/xevil) (capability-not-narrowable), exec_effect_undeclared (effect-not-declared), child_unwaited and child_end_kept (linear-value-unconsumed), child_taken_apart (linear-value-taken-apart), exec_is_edition_seven (unknown-name). One accepted program, tests/accept/process_spawn.cho, prints what /bin/echo wrote down a channel.

  • Conformance tests over real processes (tests/conformance/processes.rs). Each case runs on both backends, and the two must print the same:

    • the child’s argument list (an empty argument, a space inside one) and environment are exactly what was passed, and none of the test’s own environment arrives;
    • a channel carries the parent’s bytes to cat and back;
    • the child holds 0, 1 and 2 and nothing else, while the parent holds eight more channel descriptors of its own and one it inherited without close-on-exec;
    • KILL by its bit is Signaled(256);
    • TERM ends a child although the parent holds TERM claimed;
    • a bit naming no signal is EINVAL;
    • a missing program is Spawned::Failed(ENOENT), and exit 3 is Code(3);
    • writing to a reaped child is Sent::Failed(EPIPE), and the parent lives;
    • a child writes to Stdio::Null stdout and stderr as to any stream;
    • a path outside the bound, .., a sibling of the bound, LD_PRELOAD, DYLD_INSERT_LIBRARIES, and an argument or environment list not ending in \0 each trap: SIGILL or SIGTRAP, not merely a death by signal;
    • the authority report says exec("/bin") and is bounded.
  • Mutants. 22 of them, eleven a backend, each run against the process tests:

    • the path check, the loader refusal, the unterminated-list trap, closefrom, the signal mask and default flags, the parent’s close of the ends it gave, the exit-code decode, the kill bit, Null opened read-only, and argv[0]: all 20 are killed. The two closes are killed by a hang: a parent still holding the child’s end of its output never sees that output end.
    • The signal decode masked with 0x3f instead of 0x7f survives, and is equivalent: the two masks differ only for signal numbers from 64, and no signal a child can be ended by is numbered that high on either target.

    The first run left four survivors. Each had the same cause: the tests did not look at the thing the check changes.

    • Without its trap, an unterminated list makes memchr find nothing, and the walk past the end crashes. The test asked only that the program die by a signal, so it now asks for the trap’s signal.
    • Without closefrom, nothing changed, because slice 0 had already made every descriptor the parent opens close-on-exec. Only a descriptor inherited without close-on-exec tells them apart, so the test now starts the probe holding one.
    • Null opened read-only went unnoticed until a child wrote to it.

    The equivalent mutant was killed on one backend in each of two runs. That led to a flaky case: “writing to a child that has ended” took the end of the child’s output as proof that its input was closed too. Linux releases a dying process’s descriptors through deferred work, in no promised order, and about 1 run in 10 wrote to a channel the child still held. The case now reaps the child before it writes, since reaping comes after every descriptor is released. It passed 60 runs in a row.

  • Letting go (§4.11). Fixtures: tests/accept/process_release.cho; tests/reject/child_released_then_used.cho (linear-use-after-move), child_release_undeclared.cho and exec_spawn_detached_undeclared.cho (effect-not-declared), child_release_is_edition_nine.cho (unknown-name), exec_spawn_detached_on_wasi.cho (unsupported-on-target). Conformance tests over real processes, both backends (tests/conformance/process_release.rs, driver tests/programs/process_release.cho): a detached child is alive after its parent exited, leads its group and session, and a group signal reaches it; of a plain and a detached child released together only the detached one survives its parent’s group being killed; fifty released children leave no zombie while the parent lives, and a child it kept is still waited for; releasing one that has already ended answers 0; fifty releases leave the descriptor count as it was (Linux); a detached start outside the prefix traps; the report carries exec_detached, and tests/accept/process_spawn.cho’s does not. Mutants: scripts/process_release_mutants.py.

  • The bound check under -O2. The first Exec program built with the LLVM backend exited through the wrong arm: checked_path loaded a byte past the bound for every index of the path, and the optimiser used that poison to delete the path. It was fixed for Fs and Net as well, in #273, which this slice carries (filesystem.md §4).

  • Measured on macOS (slice 1). The first macOS CI run of slice 1 (#274) passed CLOEXEC_DEFAULT, SO_NOSIGPIPE on the socket pair and Darwin’s signal numbers. It failed one test, and the test was wrong: ls /dev/fd lists five entries there to Linux’s four, and the status line after the listing, == code 0, had been read as a descriptor.

Slice 2

  • Conformance tests (processes.rs, the waiter), each on both backends, the two to print the same:
    • a channel and a child watched together: while the child sleeps nothing is reported, then its output arrives, the channel ends, and the child’s exit is reported;
    • a child that ended before it was registered is still reported;
    • poller_wait’s deadline passes with the child running, a kill follows, and the exit is reported;
    • sixty children come and go under a limit of 24 descriptors and the next is still watchable: reaping gives the pidfd back;
    • a kernel that refuses pidfd_open (a seccomp filter answering ENOSYS, installed before the waiter starts) is told so by poller_add_child, and the child is still reaped.
  • Refusals: poller_add_child_is_edition_seven (not-a-function) and poller_add_child_effect_undeclared (effect-not-declared).
  • Emission, from any host: Linux asks for syscall (the pidfd), epoll_ctl and closefrom, Darwin for kevent and none of the three; all four triples are accepted by clang, and Cranelift builds the Mach-O object.
  • Mutants. Thirteen, one site each, run against the process tests with a limit of five minutes (scripts/process_mutants.py; Linux, aarch64, kernel 6.8, where every one but the dispatch is on the path taken): the pidfd not closed, pidfd_open asked for pid 1, the refusal’s errno not negated, the pidfd watched for EPOLLOUT, a missing pidfd not checked, and poller_add_pipe taking the modify path, each on both backends; and the pidfd read from the Child’s low half (Cranelift). All 13 are killed. Seven fail a test: the two closes leave the sixty-child test without a descriptor, and the refusal’s errno and the missing check are caught by both the seccomp test and the EMFILE one. Six are killed by a hang, which a mutant that watches the wrong thing can only be: pid 1’s pidfd, the pid taken as a descriptor, EPOLLOUT on a pidfd, and a channel never added (EPOLL_CTL_MOD answers ENOENT) are never reported, and the waiter waits. The first two had been run in an earlier session and killed; that run was stopped there, and its script was not kept. This one is in the repository and runs all thirteen.
  • Measured on macOS (slice 2). The first macOS CI run passed the tests of live children (output and exit; a deadline, then a kill) and failed the two that register a child that had already ended, with ESRCH (§4.8). The second run, with a fix sending two changes, reported that child with token 0 (§4.8 says why). With one change, all the process tests pass on macOS 26.2 (arm64), and the four poller tests passed 40 runs of 40 there.
  • EMFILE (§4.8): a_child_with_no_descriptor_to_spare_says_emfile reaches it on Linux and checks that Darwin, which watches the pid, needs no descriptor.

Slice 3

  • std/process.cho (docs/standard-library.md): Argv, add, list, count, drop; Channels, channels, close; Ran and capture, as §7.1 gives them. capture’s row is [heap, clock, poll], as §7.1 said: it calls drain, which declares pipe_read, and owning the Pipe discharges it.

  • Conformance tests (tests/conformance/capture.rs, one driver, tests/programs/process_capture.cho, both backends printing the same):

    • 1 MiB through cat, every byte back in order (1,048,576 bytes, 5 to 8 ms on macOS, where 32 KiB written first deadlocks);
    • exactly most bytes is Code(0); one more is TooMuch with most kept, and a child that lingers after its output is killed, not waited for;
    • sleep 30 under a 200 ms deadline is TimedOut (203 ms on macOS);
    • sleep 2 & echo hi gives hi in 4 ms, not 2 s;
    • a child that reads none of 1 MiB of input exits with its own status;
    • a child that closes both streams and sleeps for a second costs the capture no processor time (about 0 ms by wait4, against a bound of 400 ms): an ended channel or a gone reader is closed, not spun on;
    • with pidfd_open refused (Linux, the seccomp stand-in of slice 2), capture answers Failed(38) and kills sleep 30 rather than wait for it;
    • add refuses an entry containing a \0 with 22, the list runs without it;
    • a caller whose row leaves out clock is refused, effect-not-declared.
  • Mutants. Eleven (scripts/process_mutants.py --capture, Linux aarch64): the input written blocking, no drain after the exit, the deadline taken per wait, the bound off by one, no kill on each of the three paths that need one, the input end never closed, an ended output not closed, a failed write not ending the input, and a \0 let through. The first run killed 7 and left 4, and each survivor was a test that did not look at what the choice changes:

    • No kill past the bound survived because closing the output ends a child that is still writing (SIGPIPE). The child now lingers after its output, and only the kill ends it in time.
    • No kill when it cannot be watched survived because nothing reached that path, and descriptor limits cannot: the most capture holds at once (four, in channels) comes before the poller. The seccomp filter reaches it, so the test is Linux’s.
    • An ended output not closed and a failed write not ending the input each leave a channel that is always ready on the poller, and the loop spins until the child exits: the right answer, at the cost of a processor. Only the processor time shows it, so a test now measures it.

    With the four sharper tests, 11 of 11 are killed.

  • Found while building it. arg is a builtin as well as Args a type, so the builder’s operation is add (§7.1). And a qualified call is resolved against local bindings: with let max = 3; in scope, math.max(max, 2) is refused as “max is a local binding”. That is the compiler’s, not this slice’s, and is reported separately; std.process’s driver names its local otherwise.

Slice 4

Built in cancho-tools (#17); its design and measurements are that repository’s docs/mcp.md. What it says about this document’s work:

  • The design held. The server calls std.process.capture once per tool call and nothing else of slices 1 to 3 beyond exec_spawn, which it makes under its own Exec("<bin>"), as §7.1 decided. Its derived authority is bounded: exec("<bin>"), child_signal, pipe_read, pipe_write, poll, clock, the console and args, and no Fs, Net or Ffi. The row’s “nothing that writes” is corrected above: the server writes its answers to standard output, and writes no file.
  • The bound is a literal at build time. narrow takes one, so the server’s source names /opt/cancho-tools/bin and a build script bakes another directory in, as cancho-tools’ D14 variant bakes --root – the open question of §9’s “a bound chosen at deployment”, met for Exec.
  • A missing flush is invisible to batch tests. A stdio server’s answers sit in libc’s buffer until flush_out; tests that send every request and close the input see them at exit anyway. Only a client that waits for each answer with its input open catches it, so the server’s tests include one.
  • The qualified-call shadowing (let name = ...; tools.name(k) is refused) was met twice more there; it is reported against the compiler.

Slice 5

The working directory (§4.10) and capture_both (§7.2): the two things a run tool in cancho-tools needs beneath it. Measured on macOS 26.2 (aarch64) and Linux 7.0 with glibc 2.43 (x86-64), by scripts/process_cwd_stderr_measure.py; the Linux tests and mutants ran on the x86-64 host, the macOS tests on the Mac.

  • Code. Builtin::ExecSpawnIn and an in_dir flag on Expr::ExecSpawn; exec_spawn in both backends takes the flag and adds posix_spawn_file_actions_addfchdir_np (declared in the LLVM backend’s emit.rs) between the three streams and the closefrom. std/process.cho: ChannelsWithErrors, channels_with_errors, capture_both, and the shared loop gather, which capture now calls.
  • Refusals. Two fixtures under tests/reject/: exec_spawn_in_owned_dir (type-mismatch) and exec_spawn_in_arity (arity-mismatch). No new rule.
  • Conformance tests, both backends, both targets. processes.rs (driver tests/programs/process_cwd.cho): the child’s pwd is the Dir’s and without a Dir the parent’s, and a second plain child afterwards shows the parent has not moved and dir_close still answers 0; ./tool is found beneath the Dir and is ENOENT without one; the descriptors the child lists are equal with and without the Dir; a directory that does not open starts nothing; a directory without search permission is Spawned::Failed(13); a relative program path under Exec("/bin") traps. capture.rs (driver tests/programs/process_capture.cho, new modes): output and errors kept apart with the exit status; a megabyte of errors before any output; each bound its own (an exact bound is no overrun, one byte past is) and the child killed; the deadline returns both buffers; no processor spent on an ended errors channel (wait4); sixty timed-out captures under a limit of 40 descriptors.
  • Mutants (scripts/process_mutants.py, Linux x86-64): 15 new, 15 killed, and the eleven of §7.1 re-run against the refactored loop (--capture: 17 of 17 killed); the thirteen of slice 2 were not re-run in full (the first seven were, and were killed), since that code did not change. The spawn: no chdir, the chdir after the closefrom, the chdir to descriptor 0, the Dir closed after the spawn, the wrong argument taken for the Dir (both backends, nine in all). capture_both: the errors never read, read under the output’s token, bounded by the output’s bound, an overrun not ending the child, an ended channel not closed, the channel not consumed at the end. Two things the first run taught:
    • A mutant I wrote was equivalent (0 * poller_add_pipe(...) still registers; it only dropped the check of the answer). It is replaced by one that registers under the wrong token.
    • The errors drain after the exit has no mutant, on purpose. Skipping it survives, and so would skipping the output’s. It is redundant by construction: the poller is level-triggered and a child’s last write precedes its exit, so a channel still holding bytes is in the batch that reports the exit, and the event loop has read it before the drain is reached. It stays as a guard; no test can tell it from its absence, which is the honest reason it is not in the list.
    • The last, “left open at the finish”, is killed by the compiler: errors is res, so a path that does not consume it does not compile (§4.7’s argument for Child). The 60-capture test is what checks the paths that do consume it all do so before returning.
  • Not mutated: the trap on a refused addfchdir_np (ENOMEM), which no program can provoke (§4.10); the macOS side of the ordering (a closefrom is Linux’s), which the Mac’s tests exercise (the same pwd, relative-path and descriptor cases pass there) but a mutant run on Linux does not.
  • Found. §4.1 said a path may be relative to the working directory; under a narrowed Exec it traps (corrected in place, with a test). A removed directory is not refused: the child starts in it (measured on both). Stdio::Merge is an edition (a new variant breaks every exhaustive match of Stdio), so it was not built.

Slice 6

Letting go (§4.11) was checked in its own PR (#525); this is what #533 (§4.12) added, measured on Linux 6.18 x86-64 in a container with no init to adopt orphans, so a killed grandchild stays a zombie (Z) and the tests call a process dead when it is gone or Z. macOS: the code is the same call with the pid negated, and was not run.

  • Code. Builtin::ChildKillGroup (edition 9, row child_signal, region count 1, refused on WASI); child_kill(args, group) in both backends negates the pid and answers EINVAL with no call for a pid of 1 or less; std/process.cho is edition 9 and capture sends the group KILL before the pid’s. The compiler in cancho: code 138 in codes.cho, netcode.cho and exprs.cho, cancho_child_kill_group in runtime.ll, and the generated tables.
  • Refusals. tests/reject/child_kill_group_is_edition_nine (not-a-function), child_kill_group_undeclared (effect-not-declared), child_kill_group_on_wasi (unsupported-on-target). No new rule. tests/accept/process_group_kill.cho.
  • Conformance (process_group.rs, driver tests/programs/process_group.cho, both backends): the S7 scenario run for real: a detached worker sh -c 'sleep 77 & echo $!; wait', a 300 ms deadline through capture, and the grandchild (pid printed by the worker) gone or Z after the driver ends; the same worker started plain, whose grandchild is still S (the control: without it the first test could pass for any reason); the answers of child_kill_group for a detached child (0, then Signaled(256)), a plain child (3, and the program goes on), and a signal that is none (22); a worker already ended and not waited for, with a grandchild still running, whose group is still reached; and the authority report (child_signal, exec("/bin/sleep"), no exec("/bin"), no kill).
  • Mutants, each run to a red conformance test and then reverted. (1) capture without the group kill: the S7 test fails, the grandchild survived: Some("S"). (2) Cranelift with the pid not negated: three tests fail (the plain child answers 0 instead of 3; both grandchildren survive). (3) The Rust LLVM backend (CANCHO_COMPILER=rust), the same mutation: the same three fail. (4) The compiler in cancho’s runtime.ll with the pid not negated: the same three fail on the llvm backend. Not mutated: the pid > 1 guard, which no program can reach (§4.12).
  • Found. docs/wasm.md’s builtin counts (162, 70 refused) were already stale since #524 (165 and 73) and are 166 and 74 now, corrected there in place. --backend llvm builds through the compiler in cancho by default, so a test run without CANCHO_COMPILER=rust never exercises the Rust LLVM backend’s code: the third mutant needed the variable.

9. Open

QuestionWhy it waits
Giving a child the console§4.4. The exact shape needs the parent to lend &!Io for the life of the child, and a Stdio value cannot carry a borrow. Neither asker needs it
Spawning beneath a Dir§5’s last row. Linux only, so it needs a decision about a capability that exists on one target
The working directoryBuilt (§4.10): exec_spawn_in. Open still: a working directory that is not a Dir (a path), which needs the Fs rule’s check and has no asker
Stdio::Merge§5. Needs an edition; capture_both serves the asker who wanted standard error
A bound chosen at deploymentnarrow takes a literal, so a server whose tools’ directory is chosen when it is installed holds Exec("") or a literal it was built with. Fs has the same question (authority.md §2.3, lines.cho), and it should be answered for both at once
Children of a parent that dies§4.7. PR_SET_PDEATHSIG exists on Linux only. §4.11 is the opposite request, and a detached child is meant to outlive its parent. Narrowed by §4.12 (#533), not closed: while the supervisor lives it can stop a worker and what the worker started, with the Child it holds and no /bin/kill. After the supervisor crashes nothing changes: the Child died with it, an orphaned worker’s group is a number in a journal, and stopping it is the next row. Not measured: a supervisor that traps with workers running (the issue’s “related, not measured”)
Signalling a pid the program holds only as a number§4.11, §4.12. A kill(pid) builtin needs a capability of its own, since child_signal is about a Child; today /bin/kill through a narrowed Exec does it, and says so in the report. Narrowed (#533): for a worker the program still holds, child_kill_group does it with no Exec and no number. What stays open is the orphan after a crash and the pid-reuse hazard that comes with a recorded number (record it with something that tells runs apart, §4.11); it needs a capability or a handle that survives the process, and no program has asked for more than the issue does
child_kill_group reaching a descendant that left the group§4.12. A subprocess that calls setsid or setpgid is outside the group, and no group signal reaches it. A cgroup (Linux) or a job object would; neither exists on both targets, and no asker has needed it
exec_spawn_detached with a working directory§4.11. No program has asked: exec_spawn_in does not detach, and the combination is the same node with two flags
One reaper thread for all released children§4.11. A thread per running released child costs about 16 KB (measured); a single reaper needs a table shared between threads, which no program needs yet
Mapping exec("p") onto lex-os’s exec levelThe same bridge agent-toolbox.md §2.5 found missing for net_out. The report carries what the bridge needs

Foreign authority: what a program that calls C can reach, said symbol by symbol

Status: built (written before the code; sections 6 and 10 are what building it corrected and what it could not check).

1. Why

cancho authority prints UNBOUNDED for any program that makes a foreign call, and says one thing about it: a library is not an authority domain (docs/under-a-grant.md). That is true, and it is the same sentence for a program that calls one harmless function and one that can call anything. Two real programs are affected:

  • cancho-hooks today reads a file’s mode with one call to libc’s statx (cancho#243). Its report went from bounded to UNBOUNDED for that call.
  • cancho-hooks with https delivery will call OpenSSL in-process, about 25 functions from libssl and libcrypto (docs/tls-nonblocking.md section 6; the spike’s examples/tls_nb/ reaches 37 symbols, one of them libc’s signal). The report should say which 25 and where they live, because that list is the thing a reviewer reads and a CI pin diffs.

The product’s claim is that the signature says what the program can do (AGENTS.md section 3). For a foreign call the signature can say exactly one fact, which symbol, and it did not. It said ffi("libc"), a label naming a library the program had claimed.

2. What the code did, measured

Everything below marked confirmed was run on the unmodified main (32bad9c) with cancho check / authority / run, both backends where it matters; the rest was read from the checker’s source.

measured
An extern fn is extern fn getuid[&f](ffi: &f Ffi("libc")) -> [ffi("libc")] c_int;. Its Lex name is the linked symbol (docs/foreign-linking.md, gaps/g11), so the set of foreign symbols a program can reach is a closed set in its text: the Callee::Extern call sites of the reachable functions (docs/authority.md section 3 pruned it to that).yes: foreign_symbols already lists it
The scope in Ffi("libc") is a label: libc’s system declared under Ffi("openssl") checks, builds and runs (examples/tls_nb/gaps/a2_scope_is_nominal.cho).confirmed
narrow consumes the one Ffi that split hands out, so a program has one scope: a function taking Ffi("libc") and Ffi("tls") can never be called (gaps/g12). net.sockets/net.connect hard-code "libc", so OpenSSL and the socket packages could not share a program, and the TLS spike named its scope by purpose ("tls") and put libc’s signal under it.confirmed (gap 10)
A program with two libraries therefore could not be written, so the report could never say “per library”.follows
A foreign function needs no capability at all. extern fn system[&c](command: &c [byte]) -> [] c_int; with no Ffi parameter and the row [] was accepted. Called, it ran a shell on both backends, and the report said "bounded": true, performs nothing, never touches foreign code.a hole in the report’s central claim, found while measuring this; closed in section 5.1
Label::covers for ffi was a text prefix, and so was narrow: Ffi("libc") could be narrowed to Ffi("libcrypto") and ffi("libc") covered ffi("libcrypto").confirmed by reading ir.rs and lower/mod.rs; the same bug docs/signals.md section 2.1 avoided for signals
108 extern fn declarations in 36 .cho files; 218 mentions of Ffi("libc"), 81 of Ffi("tls"), none with a comma.grep

3. What a report can know: a fact, a claim, and an open question

Three different things were being called “the library”:

what it iswho establishes itin the report as
the symbolthe name the linker binds. A program that does not declare statx cannot call it.the program text, exactly, by reachabilitya fact
the scopethe library the declaration says the symbol lives in. Nothing checks it (a2).the declaration’s authora claim, printed beside the fact
what the symbol doesstatx reads a mode; syscall does anything; dlsym and system and execve do the rest.the linked library, and a readeropen, and cannot be closed by the compiler

The last row is why a deny-list is theatre (under-a-grant.md section 6: refusing syscall only moves the program to a wrapper), and why an allow-list belongs to whoever reads the report, not to the compiler. What the compiler can do is make the first row as precise as it is and put the second beside it, honestly named.

So the word bounded stays exactly what it was: the labels bound what the program can reach. It is false whenever a foreign symbol is reachable, because nothing in the language bounds what that symbol does. What changes is that false is no longer a verdict without a cause. The report now says what it is made of, and everything it does not list is bounded.

4. The options

what the report/type sayscostverdict
A. Report onlyunbounded_by: ["libc:statx"]; no language change~60 lines in the CLInecessary, not sufficient: a program still has one scope, so “per library” is the author’s convention, and the hole of section 2 stays
B. A scope is a set of librariesFfi("libc,libssl"), canonical like Signals("INT,TERM"); each extern fn names one library; the capability is lent narrower to a function that needs lesschecker only: one new module, narrow, covers, one coercion; no backendbuilt. Makes “two libraries in one program” writable, so the pairs are meaningful
C. Symbol-level rowsffi("libc:statx") in every function’s rowevery function on the chain names every symbol under it: the TLS chain’s top function would declare up to 37 labels (the report would not need them: it computes the union); Label::covers would need a second separator rule; every existing [ffi("libc")] row changesnot worth it. The union is already computed exactly; per-function symbol rows add a signature a reader cannot take in and give the supervisor nothing the report lacks
D. Symbol-level capabilityFfi("libc:statx"), a capability that can call exactly one symbolneeds narrow to split (two disjoint capabilities from one), which no capability has (signals.md section 3: “nothing can make two disjoint capabilities yet”); a program holding 25 would write them in every signatureno, until a fork-style narrowing exists and a program asks
E. --allow-foreign FILEthe tool compares unbounded_by against an allow-list and exits non-zerosmall, but it is a policy format and a second source of truth next to the grantnot now. unbounded_by is the interface: a supervisor holds the set it accepts and checks unbounded_by ⊆ allowed

Recommendation (taken): A and B together, plus the hole fix, which is not optional. They are one slice because A without B cannot show two libraries, and B without A has nothing to show.

4.1 Against the signals precedent

signals("INT,TERM") put a set in the capability’s type and made the row exact, because eight short names exist and a program claims two. The same shape is right for libraries (a program links two or three) and wrong for symbols (a program calls tens), which is why B follows it and C and D do not. The part that carries over unchanged: the set is canonical (alphabetical, no spaces), narrow accepts any order and answers the canonical one, covers is set containment and not a text prefix, and a refusal has a rule tag.

4.2 Against under-a-grant.md and a lex-os grant

A grant says filesystem, network, exec, and an egress list. exec and network are exactly the dimensions a foreign symbol reaches (system, socket). The report cannot say which domain a symbol belongs to, and could not honestly: that is the heuristic under-a-grant.md section 3 refuted (syscall). What the report can give a supervisor is the exact set of pairs, and a supervisor that has decided “libc:statx and libc:getpid are fine” holds a grant over symbols, which is finer than the one lex-os has and is decidable. The check is unbounded_by ⊆ granted; bounded: true programs need no such grant. A symbol the supervisor does not recognise is refused. A symbol it recognises is trusted for what the library does, which is the supervisor’s judgement and not the report’s.

5. The design

5.1 A foreign function is reached through exactly one Ffi (the hole)

extern fn must borrow exactly one Ffi capability, and that capability must name one library. Refused under the existing rule foreign-declaration:

//~ RULE foreign-declaration

extern fn system[&c](command: &c [byte]) -> [] c_int;

fn main(world: World) -> [] int {
    let Split { io, ffi, fs, heap, args } = split(world);
    release(io); release(ffi); release(fs); release(heap); release(args);
    return system("true\0");
}

The three ways a declaration breaks it, each with its message: borrows 0 Ffi capabilities (including borrowing only an Io, which has a row of its own), borrows 2 (a symbol lives in one library, so a second capability would make the pair say less than the declaration did), and names several libraries (Ffi("libc,libm") on an extern). A declaration that names an effect without the capability, or a capability without the effect, still gets the older effect-not-declared it always did.

5.2 A scope is a set of libraries

Ffi("libc,libssl"). Library names are letters, digits, _, ., +, -; comma separated; each once; any order when written and alphabetical when answered, in narrow and in a type alike (Ffi("libssl,libc") in a parameter is Ffi("libc,libssl")). The empty string is still the root split hands out. Malformed scopes are the new rule foreign-scope.

rule
narrow(ffi, "libc,"), "libc,libc", "libc libm", "libc:statx", "lib/c"; the same in a typeforeign-scope
narrow to a library the capability does not hold, to the root, or (new) to a text-prefix neighbour such as "libc" to "libcrypto"capability-not-narrowable
narrow to the same setcapability-not-narrowable
an extern borrowing a set, no Ffi, or twoforeign-declaration
lending a library the capability lacks, or the unnarrowed root, to a function that wants onetype-mismatch

covers for ffi is set containment: ffi("libc,libssl") covers ffi("libssl"), the root covers all, and ffi("libc") no longer covers ffi("libcrypto"). That is a behaviour change: narrow from "libc" to "libcrypto" was accepted before and is refused now. Nothing in this repository did it (the only fixture for the direction, tests/reject/effect_widened.cho, is the refused one), and it was a bug of the same kind signals.md section 2.1 describes. No edition gates it: every scope in this repository is a single plain name, so it spells the same set before and after.

Lending. A reference to a capability over several libraries is accepted where a reference to a subset is wanted, in any context a reference is expected. That is the only coercion between capability types. It is attenuation: the callee can reach fewer libraries than the caller holds. Not from the root (a program says which libraries it calls by narrowing), and not for an owned Ffi (that is narrow, which consumes). It is what lets net.sockets keep its Ffi("libc") while a program also holds libssl.

edition 5;

extern fn labs[&f](ffi: &f Ffi("libc"), n: int) -> [ffi("libc")] int;
extern fn pthread_self[&f](ffi: &f Ffi("libpthread")) -> [ffi("libpthread")] int;

// One capability over both libraries, lent down to what each helper needs.
fn magnitude[&f](ffi: &f Ffi("libc"), n: int) -> [ffi("libc")] int {
    return labs(ffi, n);
}

fn main(world: World) -> [] int {
    let Split { io, ffi, fs, heap, args, net, clock } = split(world);
    release(io); release(fs); release(heap); release(args); release(net); release(clock);
    let native = narrow(ffi, "libpthread,libc");
    var status = 1;
    borrow native as &f in {
        if magnitude(f, 0 - 7) == 7 && pthread_self(f) != 0 { status = 0; }
    }
    release(native);
    return status;
}

Rows stay exact in both directions, per library: magnitude performs ffi("libc") and declares it; one that declared ffi("libpthread") too would be effect-declared-not-performed. Owning the set discharges every library in it (main declares []).

5.3 The report

bounded is still the first key. The second is new:

{
  "bounded": false,
  "unbounded_by": [
    "libc:statx"
  ],
  "effects": ["ffi"],
  "labels": [
    { "name": "ffi", "argument": "libc", "bounded": false }
  ],
  "foreign_symbols": ["statx"],
  "pure": [],
  "folded_operators": 0,
  "folded_calls": 0,
  "functions": 2
}
  • unbounded_by: every reachable foreign symbol as scope:symbol, sorted, each once, one per line, so a CI pin of the report shows an added symbol as one added line. [] when bounded is true. The scope is the one the declaration claims (section 3).
  • The entry "*" is foreign reach the symbol list does not account for: the program performs an ffi label and no reachable call site names a symbol. No program the checker accepts produces it (every foreign transfer of control is a call to a declared symbol; there is no call through a pointer), and it exists so that if that stops being true the report fails closed rather than calling an unaccounted-for program bounded. It is the only remaining meaning of the old unqualified UNBOUNDED, and it is unit-tested because no program reaches it.
  • foreign_symbols and labels are unchanged, so every consumer of the old shape still reads it. A pin should use unbounded_by: it is the only field that carries the scope.
  • bounded stays false for any foreign symbol, true otherwise. It is not true “given an allow-list”: the allow-list is the reader’s.

The text form:

UNBOUNDED: this program calls foreign code. The labels below bound it
everywhere except through the symbols under "unbounded by", and what
a symbol does is the linked library's, not the language's.
See docs/foreign-authority.md.
performs
    ffi("libc")    <- unbounded
never touches
    ...
unbounded by
    libc:statx

6. Decisions on the edges

  • A declaration nobody calls is not reach. The report lists a symbol when a reachable call site names it, the standard the labels were already held to (docs/authority.md section 3). Measured: a program declaring getpid and a symbol that exists nowhere, calling neither, reports bounded: true, unbounded_by: [], and builds and runs on both backends: an unused declaration is not an undefined reference. A function that calls a symbol but is itself unreachable is the same. The cost is that deleting a call from main changes the pin without touching the declaration, which is the point.
  • The same symbol under two scopes is two entries (a:f, b:f), because they are two claims. Within one program the checker already refuses two declarations binding one symbol (foreign-declaration), so this needs two libraries to claim one name through different Lex names, which cannot happen; the case is a property of attribute, tested there.
  • The scope is a claim. system declared under Ffi("openssl") is reported as openssl:system. The report never says “this is OpenSSL”; it says “the program says this is OpenSSL”, next to the symbol that decides. Checking a symbol against the library that defines it would need the linker’s answer (-l), and check and authority do not link. Not built.
  • -l/-L are not in the report. They are the build’s, not the program’s; a different link line can bind a symbol to different code. The report is about the program text, and says so (section 10).
  • Zero symbols, bounded: true. A program that holds an Ffi and calls nothing is bounded: the capability was released, and a released capability reaches nothing (authority.md section 2.2).

7. What it is checked by

crates/cancho/tests/conformance/foreign_authority.rs, real programs built and run on both backends:

claimtest
no foreign code: bounded: true, unbounded_by: [], never touches foreign codea_program_with_no_foreign_code_is_bounded_and_unbounded_by_nothing
one libc symbol: the exact JSON and the exact text, exits 0 on both backendsone_libc_symbol_is_named_exactly
two libraries in one program, attributed symbol by symbol, run on both backendstwo_libraries_are_attributed_symbol_by_symbol
a declaration nobody calls: bounded, no symbols, links and runsa_declaration_nobody_calls_is_not_reach
the pin: adding a symbol adds exactly one line to unbounded_byadding_a_symbol_changes_the_pin_by_exactly_that_symbol
the scope is a claim: system under openssl is openssl:systemthe_scope_is_a_claim_and_the_symbol_is_the_fact
the hole: no capability, two, an Io, or a set on an extern is refuseda_foreign_function_without_a_capability_is_refused
every narrow refusal with its rule: malformed, widening, text-prefix neighbours, the root, itself; and in a typea_scope_is_narrowed_as_a_set_and_every_refusal_has_its_rule
lending: narrower accepted, a library the capability lacks and the root refuseda_capability_is_lent_narrower_and_never_wider
rows exact per library, both directionsrows_stay_exact_per_library
the follow-ups of section 8 are pinned, so a fix turns them reda_path_cannot_be_passed_to_statx_as_c_has_it, a_string_literal_has_no_hex_escape

Beside them: the unit tests of scope parsing and covering (cancho-ir/src/tests/foreign_scope.rs), of the checker (tests/foreign.rs), of the attribution (cancho/src/tests/foreign_report.rs, including the "*" branch no program reaches), tests/accept/foreign_two_libraries.cho, tests/reject/foreign_scope.cho (the new rule’s fixture), tests/reject/foreign_without_capability.cho, and the checked blocks of this document and AGENTS.md section 3.3.

Mutants. 26 deliberate breakages of the new compiler code, each checked to compile and then run against the unit tests, and if they passed, the conformance suite; each killed (19 by the unit tests of cancho-ir/cancho-syntax, 7 only by the conformance suite or the report’s unit tests). In the scope parser: an empty name allowed, a duplicate allowed, any character allowed, no canonical sort. In the cover test: the root covering nothing, the root covered by anything, any where all belongs, and Label::covers falling back to a text prefix. In narrow: widening allowed, narrowing to itself allowed, a malformed scope under the wrong rule. In lending: the root lent, anything lent, nothing lent. In the extern checks: no capability allowed, two allowed, a set allowed. In the type: the scope neither checked nor canonicalised. The rule’s tag renamed. In the report: the "*" branch removed, pairs unsorted, not deduplicated, bounded always true, the scope read from the wrong label, unreachable externs listed, scope and symbol swapped. Two lines are deliberately not mutated because no program reaches the difference: bounded = labels_bounded && foreign_bounded and ffi_performed in print_authority are the fail-closed wiring around the "*" branch, whose logic is unit-tested in attribute and which no checked program can drive.

8. Follow-ups found, not fixed

Each has a reproducer pinned in the conformance suite, so fixing it turns a test red and is the prompt to move the row.

  1. A slice crosses as a pointer and a length, and there is no bare pointer. statx(AT_FDCWD, path, flags, mask, buf) cannot be declared as C has it: extern fn statx[&f, &p, &b](ffi: &f Ffi("libc"), dirfd: int, path: &p [byte], flags: int, mask: int, buf: &b [byte]) -> [ffi("libc")] c_int; passes seven values, the length of path lands in flags, flags in mask, and mask in buf. Measured with strace -e statx on both backends: statx(AT_FDCWD, "/etc/passwd", AT_STATX_SYNC_AS_STAT|0xc, 0, 0x4) = -1 EINVAL. cancho-hooks declares statx shifted to compensate. Same family as tls-nonblocking.md gap 4 (strcmp, SSL_CTX_load_verify_locations, BIO_new_bio_pair). Not trivial: it is a new parameter kind (a NUL-terminated slice passed as a pointer only, or an out-parameter form) in both backends and the foreign-boundary rules. Reproducer: a_path_cannot_be_passed_to_statx_as_c_has_it.
  2. A different arity than the declaration shape. The same defect from the other side: the number of C arguments is the number of declared parameters plus one per slice, so a C function with n arguments and a slice anywhere but last has no declaration. Fixing 1 fixes this.
  3. A string literal has no \x escape. A byte such as 0xFF or a binary statx mask cannot be written in a literal; "a\x41" is unknown-escape. AGENTS.md section 6 says so, and strings.md section 8 decided it for source text that is UTF-8; the C boundary is where it hurts. Trivial in the lexer, deliberately not done here: it is a change to the language’s literal syntax (a byte that is not UTF-8 in a [byte] literal) with its own design questions (is "\xFF" a str?). Reproducer: a_string_literal_has_no_hex_escape.
  4. c_int only in return position. A C int parameter is declared int and relies on C reading the low 32 bits; dirfd: c_int is unknown type. Harmless today (the callee ignores the upper half), recorded because reach.md section 3.4 argued the return side only.
  5. Scope against the link line. A scope is not checked against the libraries -l names, so libssl can be claimed with no -lssl. A check at build (every claimed scope other than libc has a matching -l) is cheap and would give the claim one real constraint. Not built: authority does not link.
  6. foreign_symbols is redundant now (unbounded_by is the same list with the scope). It stays for the consumers of the old shape; a future shape change can drop it.
  7. Split still hands out one Ffi. B makes one capability cover several libraries; two disjoint capabilities (D) still need a splitting narrow. Nothing has asked.

9. What cancho-hooks changes to adopt it

  1. Nothing to keep working: a program with Ffi("libc") compiles as before and reports the same labels. Its report gains unbounded_by.
  2. statx. Keep Ffi("libc"). The report becomes bounded: false, unbounded_by: ["libc:statx"] (once the shifted declaration is the only foreign symbol; today it also lists whatever else ops.cho declares: the four signal functions go with docs/signals.md section 7). Commit the JSON as the pin; CI diffs it.
  3. https. narrow(ffi, "libc,libcrypto,libssl") once in main; declare each OpenSSL function under its own library (SSL_*, TLS_* under Ffi("libssl"); BIO_*, ERR_*, X509_* under Ffi("libcrypto"); statx under Ffi("libc")); lend each chain function only the part it uses; rows then name at most three libraries instead of one purpose-word, and the pin lists the 25 pairs grouped by library. tls.cho of the spike would change its 37 externs’ scope from "tls" and move its one libc symbol (signal) out. Build with -l ssl -l crypto as before.
  4. cancho-hooks’ README says “No Ffi, no unsafe”. It must say: no Ffi beyond the pinned list, and link to the pin.
  5. Nothing else in cancho-hooks is touched by this change; the shifted statx declaration and the missing \x are follow-ups 1 and 3.

10. What is not verified, and what building it found

  • Not run on cancho-hooks or on a real https build. tls_nb was run for its report (37 pairs, all under the spike’s "tls"); it was not converted to per-library scopes, so the claim that its rows would name at most three libraries is by counting its prefixes (SSL_/TLS_: libssl; BIO_/ERR_/X509_: libcrypto; signal: libc), not by compiling it.
  • The scope is unchecked. Section 6. A person reading libc:system and openssl:system sees the same symbol; the first is what it is.
  • The link line is outside the report. A different -l order or LD_PRELOAD binds a name to other code. So does a constructor in a linked library, which runs before main and appears nowhere.
  • macOS is not run here. The checker change is target-independent; the two programs in the suite call getpid, labs and pthread_self, which every libc has.
  • libpthread is a claim. glibc has folded it into libc since 2.34, so pthread_self under Ffi("libpthread") is exactly the unchecked claim of section 3 and is used on purpose: it is the one integer-only function here that a second library nominally owns.
  • Corrected in place while building it: the first draft of section 5.1 refused an extern with no Ffi before the older row checks, which changed the message of tests/reject/ffi_without_capability.cho’s refusal and of a unit test; the new check runs after them, so an older refusal keeps its words.

A WebSocket spike: what it takes to hold ten thousand charge points

Status: S1 measured (§8), S2 half done (§9), gaps catalogued (§10). The design and the gates (§1 to §7) were written before the server; the results and the gaps are added after them, and a claim that turns out false is corrected where it stands. The one change to the gates after the first run is recorded in §8.

1. The question

lex-csms (the OCPP central system of the EV-charging services) holds a WebSocket per charge point, answers a few messages on each, and sends commands back down one. The question that was asked is whether cancho is a better fit for that than the runtime it is written on. Two things are wanted from one program:

  1. A measurement, not an opinion: the same connection-holding job on cancho and on the lex runtime lex-csms runs on.
  2. A list of what cancho lacks to write an equivalent service, found by writing the smallest piece of one: every missing piece is a line in §8, with where it would live.

The job is the connection layer: accept, upgrade to WebSocket (RFC 6455) with the ocpp1.6 subprotocol, and answer the OCPP-J calls a charge point sends on a schedule (BootNotification, Heartbeat, StatusNotification, MeterValues). No database, no authorisation, no commands down: lex-csms does those too, so a comparison with it is not a comparison of two equal programs, and §5 says how that is handled.

2. What is built

examples/ocpp_ws/: one cancho program, single threaded, on the poller: a listener, a table of connections (std.conns), a read buffer and a write buffer for each, and a state per connection (handshake, open, closing). It answers:

a charge point sendsthe server answers
[2,"<id>","BootNotification",{...}][3,"<id>",{"currentTime":"<ISO 8601>","interval":300,"status":"Accepted"}]
[2,"<id>","Heartbeat",{}][3,"<id>",{"currentTime":"<ISO 8601>"}]
[2,"<id>","StatusNotification" or "MeterValues",{...}][3,"<id>",{}]
any other call[4,"<id>","NotImplemented","",{}]
a WebSocket ping, closea pong; a close, and the connection is closed

A fragmented message, a frame over the buffer, a binary frame and a client that does not ask for ocpp1.6 are refused (close code 1002/1003/1009, or HTTP 400); that is a limit of the spike and §8 lists it. It needs three things std does not have, which the example carries itself for now and §8 counts: SHA-1, base64, and an ISO 8601 time from a Unix one.

3. What is measured, and how

  • Load generator: scripts/ws_load.py, Python asyncio and raw sockets (so it is the same for every server), several processes. It does the handshake, sends a BootNotification, checks the answer (id echoed, Accepted, a parseable currentTime, an integer interval), then does what the scenario says.
  • Server numbers are read from the kernel, not from the program: resident memory from /proc/<pid>/status and CPU time from /proc/<pid>/stat, before and after.
  • The machine has four cores, shared by the server and the generators: the server is pinned to one core and the generators to the other three, and the numbers are the median of five runs.
  • An independent check of the protocol: the websockets library (a WebSocket implementation that is not ours) connects to the server and speaks to it, and the RFC 6455 example (dGhlIHNhbXBsZSBub25jZQ== gives s3pPLMBiTxaQ9kYGzzhZRbK+xOo=) and SHA-1’s own test vectors are checked against hashlib.
  • Runs. The plan said “median of five”; what was run is one full 10,000-connection run of the cancho server (the scenario is nine minutes with the idle and heartbeat phases) and one run of each lex runtime point. §8 says which numbers are one run; none is a median. Treat differences under about 2x as noise and read only the large ones.

4. The gates, fixed before any run

gate
G1 correctthe websockets client completes the handshake with the ocpp1.6 subprotocol and gets the right answer to each of the four calls; a ping is answered with the same payload; the SHA-1 and base64 of the example match hashlib for 1,000 random inputs of every length from 0 to 130.
G2 scale, idle10,000 connections open and each accepted by a BootNotification: the server’s resident memory is at most 100 MB (10 KB a connection), and with nothing sent for 30 s its CPU time rises by less than 1% of a core.
G3 latencywith those 10,000 connections each sending a Heartbeat every 30 s (333 a second) for two minutes: p99 round trip under 20 ms. And all 10,000 sending a Heartbeat in the same instant: every one answered within 2 s.
G4 refusala message larger than the buffer, a fragmented one, a ping with a 200-byte payload, and 1,000 connections that connect and send nothing (no handshake): none crashes or wedges the server; each is refused or closed, and a good connection still works afterwards.
G5 throughputreported, no gate: 200 connections in a closed loop, each sending a 1 KiB MeterValues as soon as the last is answered, for ten seconds: messages a second.

5. The comparison, and what it may not be used for

The same scenarios run against two servers on the lex runtime, each of which is a different thing and is labelled as such:

  • lex-csms itself, if it can be started on this machine (its dependencies are git repositories; some may not be reachable from here). It does more per message than the spike (a database write on BootNotification), so on Heartbeat it is the fairer comparison and on BootNotification it is not.
  • A minimal WebSocket echo of the same four answers written on the lex runtime (net.serve_ws_fn_actor), which separates “the runtime” from “what lex-csms does”. If lex-csms cannot be started, this is the only baseline, and the report says so.

What was actually run: the stand-in only (scripts/ws_standin.lex, run with lex run --allow-effects concurrent,io,net,time, lex built from lex-lang main at the time; it listens on 9201). lex-csms was not started: the stand-in already cannot hold the load (§8), lex-csms does strictly more per message than the stand-in, so a number from it could not change the conclusion about the connection layer, and it would have added a database and a dependency-resolution variable. That is an inference, not a measurement, and it is the first thing to repeat if the conclusion is ever disputed. The stand-in answers with a constant currentTime (cheaper than the real clock, so it flatters the lex runtime) and registers no actor name (name_of returns ""; lex-csms registers one per connection).

What the numbers may not be used for: to say cancho is faster or smaller than lex-csms as a service. They say what the connection layer costs in each, on one machine, with these scenarios.

6. Redis, and whether cancho-cache can stand in for it

What lex-csms uses it for, read from its source (src/frames.lex, src/server.lex): with REDIS_URL set, a command for a charge point is published (PUBLISH csms:cmd:<cp_id>), and a subscribe_loop is meant to PSUBSCRIBE csms:cmd:* so the pod that holds the connection delivers it. With REDIS_URL unset, and in the recommended single-process main_all, Redis is not used at all: dispatch is an in-process registry lookup. And the multi-pod path does not work today: frames.lex says the subscriber is started with conc.spawn, which does not run it in the background, so cross-process delivery “remains unfixed”, and the README says the same of the 2.0.1/2.1 servers. So Redis is, today, a publish with no working listener, in an optional mode.

cancho-cache answers 38 of Redis’s commands (strings, expiry, an LRU policy, INCR) and not PUBLISH, SUBSCRIBE or PSUBSCRIBE; §9 confirms it against the running server. Two ways to get the same effect:

  1. A presence registry instead of a bus. A pod writes SET cp:<id> <pod address> EX 90 when a charge point connects and on every Heartbeat (the TTL is the liveness detector: a pod that dies stops refreshing), and DELs it on disconnect. A command is routed with GET cp:<id> and sent to that pod directly (HTTP). This uses only commands cancho-cache has, and replaces the thing that does not work today. What it needs from cancho is a RESP client, which does not exist (cancho-cache is a server; cancho-pg is the Postgres client).
  2. Add PUBLISH/PSUBSCRIBE to cancho-cache. A server that already holds connections and pushes to them in one loop is the easy case; it is a slice of cancho-cache, not of cancho, and it is only worth doing if something needs a bus rather than a registry. Not started.

7. Order of work

S1: SHA-1 and base64 (differential tests first), the frame codec (tested without a network), the server, G1 to G4, the load generator, the baseline, G2 to G5. S2: a RESP client in cancho and the registry. S1 is done. S2 is done only as far as the server side of the registry (§9); the cancho RESP client is not written (§10).

8. S1 results

All numbers: one run, server pinned to core 0, generators (3 processes) on cores 1 to 3, scripts/ws_load.py, server CPU and RSS from /proc. Reproduce with the commands in the header of each script.

G1 correct: passed. scripts/ws_conformance.py (the independent websockets client, 1,000 silent connections underneath, every action, ping, close, bad JSON, non-OCPP JSON, a 1.5 KiB message) passes all checks; scripts/ws_codec_check.py finds 0 differences in 2,096 SHA-1/base64 comparisons against hashlib/base64. G4 refusal: passed, same script (oversize, fragmented, 200-byte-ping, 1,000 silent connections; the server runs on and a good connection still works).

(one run)gatecancho server, 10,000 connectionslex runtime stand-in
G2 memoryat most 100 MB79.8 MB (38 MB of it is the slabs allocated before the first connection)187 MB at 1,000 connections, 356 MB at 2,000, 778 MB at 4,500: ~0.18 MB a connection (a thread each); 10,000 would be about 1.8 GB (extrapolated, not run)
G2 idle CPUunder 1% of a core in 30 s0.0%72% at 1,000, 98% at 1,500, 99% at 2,000 (saturated): about 0.07% of a core per idle connection
G3 burstall answered in under 2 s9,999 answered in 0.21 s (p99 121 ms)1,000: 0.18 s (p99 123 ms); 2,000: 0.48 s (p99 315 ms); 4,500: 36.9 s
G3 heartbeatsp99 under 20 ms333/s offered for 150 s: p50 0.34 ms, p99 1.2 ms, max 55 ms, server at 1.7% of a corenot comparable (see below); at 1,000 connections and 5 s interval, p99 8.5 ms with the server at 75% of a core
can it open them9,999 of 9,999opened 1,000, 1,500, 2,000; failed to open 2,500, 3,000, 5,000, 7,500 and 10,000 (the load generator got Connection reset by peer; not diagnosed). 4,500 did open when the generators opened only 15 at a time, but took about 20 minutes and 1,246 CPU-seconds
G5 throughputreported40,015 msg/s at 56% of the core: generator-bound, so a lower bound16,576 msg/s at 100% of the core

G2 and G3 pass for cancho. The one change to the gates after the first run: ws_load.py read the handshake reply with a single recv, and compared the accept header case-sensitively; both are bugs in the generator that the lex runtime’s server (which writes the reply in pieces, with lower-case header names) exposed. They were fixed (read to the blank line; compare the header case-insensitively and the key exactly), and the cancho numbers above were taken before the fix with a server whose reply arrives in one piece, which the fix does not change. No gate was edited.

What this says, and does not.

  • On one core the lex runtime’s WebSocket server holds about 1,000 connections before it spends 70% of the core doing nothing, and does not hold 10,000 at all here; cancho holds 10,000 in 80 MB with the core idle. The mechanism the numbers fit is in crates/lex-runtime/src/ws.rs: each connection is a thread whose read has a 50 ms timeout (set_read_timeout(Some(Duration::from_millis(50))), six places), so each idle connection wakes 20 times a second. Idle CPU rose linearly with connections until it saturated, which is what that predicts. I did not profile it, so it is the likely cause, not a proven one.
  • Pinning to one core is the comparison’s weak point. The lex runtime is multi-threaded and a production deployment gives it cores. Linear extrapolation of 0.07% of a core a connection says 10,000 idle connections need about seven cores just to sit there; that is extrapolated, not measured, and the real figure could differ. cancho’s server is single threaded by design and was given one core.
  • It does not say cancho is a better service: the cancho program answers four messages and does no authorisation, storage, TLS or commands down. It says the connection layer, the part that must hold ten thousand sockets, is cheap on cancho and expensive on this runtime, by a margin (0.0% against 72% of a core idle at 1,000 connections; 80 MB against a projected gigabyte-plus) far outside the noise of single runs.

9. S2: the registry workload, against cancho-cache and Redis

scripts/ws_presence.py runs what the registry in §6 needs against any RESP server (10,000 SET cp:<id> <pod> EX 90, then 333 refreshes a second for 20 s with a GET every tenth, one request in flight, then the two commands lex-csms uses). Redis 7.0.15 (not pinned to a core) and cancho-cache (pinned to core 0) on the same machine, one run each:

Redis 7.0.15cancho-cache
register 10,000 keys with a TTL1.57 s1.39 s
refresh/lookup latency p50 / p99 / max0.34 / 0.86 / 8.6 ms0.33 / 0.79 / 9.8 ms
server CPU at 333 refreshes a second3.2% of a core2.4% of a core
resident memory14 MB146 MB (a fixed arena and index sized at start: the defaults are 64 MiB and 1,000,000 keys; cache <port> <MiB> <keys> sizes them, not tried)
PUBLISH0 (no subscribers)unknown command 'PUBLISH'
PSUBSCRIBEenters subscribe modeunknown command 'PSUBSCRIBE'

For a presence registry, cancho-cache is a drop-in: same latency, same work, with the TTL doing the liveness. It is not a drop-in for the publish/subscribe path, which it cannot answer at all. Measured with a Python client; the cancho side of the registry (a RESP client) is not built, so this shows the server is adequate, not that a cancho charge-point service could use it yet.

10. What cancho lacks to write the equivalent service

Found by writing the spike (found), or read from the service and not tried (inferred). Where it would live in brackets.

#gaphow foundwhere
1no SHA-1 and no base64 in std (the WebSocket accept key needs both); examples/ocpp_ws/{sha1,b64}.cho carry them, differentially testedfoundstd.crypto (SHA-1 is also what Git and many protocols want)
2no ISO 8601 / calendar from a Unix time (OCPP’s currentTime); timefmt.chofoundstd.time
3no WebSocket codec: handshake, framing, masking, close codes; ws.cho is single-purpose (no fragmentation, no extensions, text only)founda packages/ws, sans-io like http-server
4a region chunk is 64 KiB: one larger alloc_slice traps, so the per-connection slabs need box_slice and nested borrows (server.cho)founda documented limit; a box_slice-backed slab helper
5no RESP client (the registry needs SET EX/GET/DEL)foundpackages/resp, beside cancho-pg
6cancho-cache has no PUBLISH/PSUBSCRIBEfoundcancho-cache, only if a bus is wanted
7no TLS server (a charge point connects over wss in production; cancho has a TLS client through Ffi("libc")/OpenSSL)inferreda terminating proxy, or a TLS server (opaque-pointers.md covers the client)
8storage. The lex-csms checked in here opens SQLite (conn.connect_sqlite, CSMS_DB); the deployment uses Postgres, which is what cancho has (cancho-pg: wire protocol, non-blocking pg.pool, generated typed queries). So the driver is not a gap; what is untried is lex-csms’s schema and queries (written through lex-orm) on cancho-pg, and whether the SQL they emit is plain enough for pgeninferreda spike of its own: port one table (e.g. the BootNotification upsert)
9the HTTP API that sends commands down a socket must live in the same loop as the connections (one thread, one poller): std.conns has the table and http-server has the loop, but a server that runs both on one poller is untriedinferredan example first; the design is http-server.md plus this one
10single thread: 10,000 idle is free, but CPU-bound work (JSON of a large MeterValues, authorisation) shares the one core; the answer is the threads strategy doc, not measured hereinferredthreads.md
11small friction while writing it: Split has seven fields and every main must name them; text, live and res are not usable as local names; http.version answers 10 or 11, not a stringfounddocs and diagnostics, not features
12OCPP itself: only four actions, no schemas; the 1.6, 2.0.1 and 2.1 message sets and their validation are lex-ocpp’sinferreda cancho port of lex-ocpp, the largest piece by far

Reading the table: 1 to 6 are what a connection layer needed, and are small; 7 to 12 are what the service needs and are where the work is (8 is smaller than it first looked: the database is Postgres, and cancho has a Postgres client). The spike supports “cancho can hold the sockets”; it does not support “cancho can replace lex-csms soon”. The sensible first step, if one is wanted, is the connection layer only behind lex-csms (a proxy that terminates WebSocket and forwards OCPP frames), which needs 1 to 4 and none of 7 to 12.

Slicing

Status: settled and built, and the case for it was made by a library that already shipped.

std.bytes.find returns an index into a slice, and until now a program had no operation that could take it. The library handed back a position and nothing to do with it, and starts_with exists only because equal(text[0..len(prefix)], prefix) could not be written.

It also turned out to be the precondition for the roadmap’s “writer abstraction” — see §6, which is the part of this document worth reading even if the feature is obvious.


1. The form

s[a..b]        // half-open: a included, b excluded

s is &r [T] or &!r [T]; the result is a slice of the same element type, the same region and the same mode. len(s[a..b]) is b - a.

Half-open because b - a is the length, s[a..a] is empty, and s[a..b] followed by s[b..c] is s[a..c] with nothing counted twice or missed. Every off-by-one this language can avoid, it should.

There is no s[a..] or s[..b] yet (§7): len(s) is one call and an abbreviation is not a design.

2. Bounds trap

0 <= a <= b <= len(s), checked at run time, and a violation traps.

That is defined-behaviour.md §8’s rule for indexing, applied to the operation that produces a range rather than an element — and it has to be, because the alternative is a slice that claims a length its allocation does not have, which is a buffer overrun with a type on it.

a > b traps rather than yielding empty. An inverted range is a bug in the program that wrote it, and silently returning nothing is the kind of defined-but-wrong answer defined-behaviour.md §2.1 refuses: an operation with no right answer stops rather than inventing one.

There is no unchecked form. The check is two comparisons against a length that is already in the second register of the slice.

3. It is an ordinary reference

The result carries s’s region, so:

  • it cannot escape the block that region belongs to — the same occurs-check, over the same type, with nothing added;
  • it may be used where &r [T] is wanted, by the coercion §6 of linearity-and-effects.md already has;
  • it is val, so it copies and discards like any other reference.

None of that needed a rule. A subslice is a reference, and references already have all of these.

4. Why a unique slice stays unique

This is the one decision, and the answer is already in linearity-and-effects.md §5:

Two copies of one &!r are two copies of one pointer, so writes through them alias correctly rather than racing to be last writer.

So &! here does not mean what &mut means in Rust. It is not a no-aliasing invariant over references; it is a lock on the binding for the block. (This paragraph was right and README.md’s performance section was wrong; what changing it would cost is measured in aliasing.md, and the answer is no.) Within the block the referent is spilled to exactly one buffer, nothing else may touch the value at all, and every reference derived from the borrow points into that one buffer. The write-back at block exit restores the whole of it.

s[0..3] and s[1..4] overlap. Under Rust’s rules that is the thing split_at_mut exists to avoid; here it is the same situation as two copies of one &!r, generalised from “the same address” to “overlapping addresses”, and the argument that made the first sound makes the second sound. One buffer, one write-back, one locked binding.

The consequence worth stating: a split_at that hands back two disjoint halves is not a safety feature here. It would be a convenience, and §7 leaves it open on those terms rather than as a hole.

5. What it costs

A slice is a pointer and a length, in two registers. s[a..b] is

(ptr + a * stride, b - a)

— one multiply-add and one subtract, after two comparisons. Nothing is copied, nothing is allocated, and there is no descriptor: the same two values a slice always was.

6. And this is the answer to “a writer abstraction”

The roadmap has carried a writer abstraction as the next thing for several slices. Working out what one would be is how this feature was found, and the conclusion is that there should not be one.

A Writer would be a value a function takes so it can send bytes somewhere without knowing where. Written as an enum over the destinations:

enum Writer { Console(..), File(..), Buffer(..) }

a function taking one must declare the union of what every arm could do — [io_write, fs_write(""), heap] — on every call, including the ones that only ever touched the console. That is precisely the inexactness §7.3 refuses: an exact row means what this function did, and a union row means what it might have done depending on a value the type cannot see. A language whose whole claim is that the row is the truth cannot buy convenience with a row that is not.

So the abstraction is not a writer. It is a buffer:

b = fmt.int(heap, b, 42);              // [heap], and nothing else
b = fmt.text(heap, b, " items");       // [heap]
borrow b as &r in {
    console.write_all(io, bytes(r));   // [io_write] — exact
    fs_write(f, "/tmp/out", bytes(r)); // [fs_write("/tmp")] — exact
}

Formatting touches the heap. Emitting touches the destination. Each row says exactly what its function did, “where does this go” is a fact about the call site rather than about a runtime value, and the intermediate buffer is the price — which is the same price the enum would have paid anyway, minus the dispatch.

That is what needed slicing. A Buffer holds more bytes than it uses, so handing its contents to anything taking a &r [byte] means handing over exactly used of them, and there was no operation that could say so. std.buffer.bytes is contents(b.held)[0..b.used], one line, and the writer question is closed with a document rather than a feature.

7. Open

QuestionWhy it waits
s[a..] and s[..b]len(s) is one call; an abbreviation is not a design, and two forms to parse is a cost with nothing behind it
split_at§4: not a safety feature here, so it is a library convenience and wants a reason beyond symmetry with languages where it is one
Slicing an owned runThere is no owned run to slice: a [T] is always behind a reference, because it is unsized
Patterns over slicesmatch on a length is a different feature (reading-references.md §6)

8. The suite

FixtureRule§
slice_of_a_non_slice.cho.. needs a run to range over1
slice_bound_is_not_an_int.choBoth bounds are int1
slice_escapes_its_region.choA subslice is an ordinary reference, so it cannot outlive the borrow3

§2’s two bounds rules are runtime traps, so they are conformance tests rather than reject fixtures — the reject harness runs check, and a program that traps is one that compiled:

TestRule
slicing_past_the_end_trapsb > len(s), and a negative bound read as unsigned
an_inverted_range_trapsa > b stops rather than yielding empty
AcceptingShows
slicing.choA subslice read, re-sliced, passed where &r [T] is wanted, and emitted to two destinations with an exact row each

Tuples

Status: settled, and asked for by a library rather than by a design.

docs/sharing.md §6 calls this “the single biggest ergonomic gap a linear language has”. That is not a judgement made in the abstract: examples/slab/ is sixty lines, and the lack of tuples shaped three of its decisions. This document is the smallest thing that closes the gap without adding a second aggregate to the language.


1. What asked for it

sharing.md §4 is a list of three things writing one library surfaced. The first:

No tuples. insert must answer with both a slab and a handle, so it returns a res struct Inserted { slab, handle } declared for the purpose. Every operation that threads the slab needs one.

examples/slab/slab.cho declares two types, Inserted and Looked, that exist only because a function may return one value. Neither is a concept in the library. Both have to be named, documented, kept in step with their function, and read by anyone reading the code.

That is the cost of the missing feature in one small library, and it scales with how linear the code is: a linear API threads its state, which means handing it back, which means handing back two things.

A second entry on that list turns out to be the same entry:

No renaming in a destructuring pattern. let Slab { entries, live } binds those names and no others, so two slabs cannot be taken apart in one scope.

A struct pattern binds field names because the fields have names. A tuple pattern has nothing to inherit, so it must name its bindings — and therefore may name them anything. Tuples close two of §4’s three gaps, and the second one for free.


2. What a tuple is

An anonymous struct with positional fields. Not a new kind of aggregate: everything below is a rule structs already have, restated for a type with no declaration.

fn insert(s: Slab, value: int) -> [] (Slab, Gen) {
    ...
    return (Slab { entries: entries, live: live + added }, Gen { index: at, generation: g });
}

let (slab, handle) = insert(fresh, 7);

2.1 Two components or more

(e) is grouping and stays grouping — parentheses are formatting, and the parser leaves no node behind for them. A one-tuple would therefore need a second spelling, (e,), which is a syntax whose only purpose is to disambiguate something nobody asked for.

There is no () either. The checker has a Unit type for “this expression yields nothing”, and it is deliberately not writable in source: making it writable is a decision about whether a function may return nothing, which is a different question from whether it may return two things. A tuple has two components or more, and that is the whole rule.

Note what that does and does not refuse. () is an error — it parses as a tuple and is rejected for having no components. (e,) is an error the same way — the comma makes it a tuple, and it is rejected for having one. (e) is not an error: it is grouping, and was grouping before tuples existed.

Corrected. This paragraph used to say there was no fixture for a one-tuple “because there is no one-tuple to write”. That was false: the parser has always accepted a trailing comma, so (e,) parsed as a one-part tuple, and the checker refused it with the rule above. Nobody had written it; the fuzzer did (docs/fuzzing.md), and found that the printer dropped the comma, turning a refused program into an accepted one on reprint. The printer now keeps it, and one_tuple.cho pins the refusal. The rule — two components or more — did not change.

2.2 Structural, so there is no declaration

(int, Gen) is the same type wherever it is written. This is the first structural type in the language: every other aggregate is a DefId into a table of declarations, and two declarations with identical fields are two types.

That matters more here than it would elsewhere, because of many-files.md. A program is the set of files named on the command line sharing one flat namespace, and until now two files could only agree on an aggregate if one of them declared it. They can now agree on (int, Gen) with neither declaring anything — the first type in this language that crosses a file boundary with no declaration site at all.

2.3 Mode is computed, not declared

A tuple is res if any component is res, and val otherwise.

Every other aggregate declares its mode, and res struct / val struct is checked against the members: docs/sharing.md §2.1’s third refusal is exactly that check. A tuple has no declaration site, so there is nowhere to write a mode and nothing to check the members against. The mode is read off the components instead.

This is not a loosening. The rule a struct is checked against — a val type may not hold a res — is a rule about declarations, and a tuple makes no declaration, so the rule is vacuous rather than violated. A tuple holding a Box[T] is res, and every obligation that follows from res follows here exactly as it does for a struct.

It does mean the mode of a tuple is not written anywhere in the source. That is the one thing this feature costs in legibility, and it is the same thing mode_of already computes for Pair[File] versus Pair[int], where the mode of a generic type has never been written either.


3. What you can do with one

WrittenMeans
(a, b)Construct. Components evaluate left to right, like every other argument list (defined-behaviour.md §3)
let (x, y) = t;Destructure. Consumes t, binds the components under the names written
t.0, t.1Positional field access. Exactly p.x with a number instead of a name
(A, B) in a typeA type, usable wherever a type is: a parameter, a return, a struct field, a type argument, a box’s referent

3.1 .0 obeys the rules .x already obeys

Three cases, none of them new:

  • On an owner, all components val. Copied out. The tuple is read.
  • On an owner, any component res. Refused: “a field cannot be read out of it; take the whole value apart”. The same refusal a res struct gives, for the same reason — reading one component would leave the others owed by nobody.
  • Through a reference. A val component is copied; a res component is refused by reading-references.md §2, nothing moves out of a reference, ever. That rule cost a double free to find last time it was not enforced somewhere it applied, so it is enforced here from the start rather than after.

3.2 A pattern names every component

let (a, b) = t; on a three-tuple is refused. Destructuring takes the whole value apart, which is what makes it the operation that discharges a linear obligation — a pattern that could leave a component unnamed would be a way to drop one.

There is no _ in a tuple pattern, because there is none in a struct pattern. For a val component it would be a discard and harmless; for a res one it would be a leak, and a rule that holds for one mode and not the other is two rules. §6.


4. What this is not

  • Not multiple return values. return (a, b) builds a value. The caller may destructure it, pass it on, store it in a struct, or put it in a box. A calling convention that returned two things would do none of that.
  • Not tuple structs. struct Pair(int, int) is a named type with positional fields, which is a third aggregate. A named type that wants positional fields can have fields called first and second.
  • Not nested patterns. let ((a, b), c) = t; is refused — this language has no nested patterns anywhere, and tuples are not the place to introduce them. Take the outer one apart, then the inner.
  • Not a match scrutinee. A tuple has one shape, so a match on one would have exactly one arm, which is a destructuring let written longer.

5. Layout and cost

A tuple’s leaves are its components’ leaves in order. That is a struct’s layout with the names removed, so there is no new layout decision, no tag, and no padding question that was not already answered.

A return of more than two leaves travels through memory rather than registers, which is a rule that already exists and that Inserted already hit: (Slab, Gen) is five leaves — a pointer, a length, and three ints — and returns exactly the way Inserted did.

Replacing a declared struct with a tuple changes no generated code at all. That is a claim about a compiler rather than about a design, so it is asserted rather than argued: a_tuple_emits_the_same_object_as_the_ struct_it_replaces compiles two programs differing only in whether the pair is a res struct or a tuple, and checks that the object files are byte-identical. Not similar, not the same size — the same bytes.

That is what makes this an ergonomic feature rather than a representation choice, and it is why examples/slab/ could drop two declared types without anyone having to ask what it cost.


6. Identity

canonical-ast.md hashes a type by what it is. A named type’s identity runs through its DefId, which is an index into a table of declarations in the order the program declared them. A tuple has no DefId and needs none: its identity is its constructor and its components, hashed in order.

So a tuple type is the first type here whose hash is a function of the type alone, with nothing in it that depends on what else the program declared. That is what §2.2’s “two files agree without declaring” is, stated as a hash.

The pattern is the mirror of it. A struct pattern encodes its field names, because which field each binder takes is what the pattern says. A tuple pattern encodes only its arity: the names are the pattern’s own invention (§1), so renaming them changes nothing any caller or any later reader of the value can observe, and the body hash does not move. That is the same rule that makes f[T](x: T) and f[U](x: U) one signature, applied one level down.


7. Open

QuestionWhy it waits
Shadowing within a blocksharing.md §4’s third gap, and the one tuples do not close. Still open, still small
_ in a pattern, struct or tuple§3.2. Worth having; a decision about discards, not about tuples
Nested patterns§4. A pattern language is its own document
One-tuples and a writable unit type§2.1. Both are decisions about nothing, which is harder than it sounds
Tuple structs§4. A third aggregate needs to earn its place

8. The suite

Stated before the code, the way linearity-and-effects.md §11 was.

FixtureRule§
tuple_arity_mismatch.choA pattern names every component3.2
tuple_index_past_the_end.chot.2 on a two-tuple is not a field3.1
res_tuple_component_read.choA res tuple is taken apart, not read3.1
res_tuple_component_through_reference.choNothing moves out of a reference3.1
tuple_leaked.choA res tuple is consumed exactly once2.3
empty_tuple_type.choA tuple has two components or more2.1
one_tuple.choA tuple has two components or more, (e,) included2.1
tuple_type_mismatch.cho(int, bool) and (bool, int) are different types2.2
nested_tuple_pattern.choNo nested patterns4
AcceptingShows
tuple_roundtrip.choConstruct, return, destructure, and .0 on a val tuple
tuple_of_res.choA res tuple threaded through a function and ended
examples/slab/Rewritten: Inserted and Looked deleted, and the generated code unchanged

Shadowing

Status: settled. The last item on sharing.md §4’s list.

Three things made examples/slab/ more verbose than it should have been. docs/tuples.md closed two. This is the third, and it is the one where the restriction turned out to be a real rule stated too bluntly rather than an omission.


1. What asked for it

sharing.md §4, third entry:

No shadowing within a block. Threading a value through several steps means fresh, live, emptied, stale — four names for one slab — because let slab = ... twice in a block is refused.

examples/slab/main.cho still reads that way after the tuple slice: fresh, filled, live, emptied, checked, refilled, reused, stale. Eight names for one slab, none of which is a different thing from the last.

This is the shape a linear language pushes every program into. A value that is threaded is consumed and returned, so the returned one needs a name, so the next step needs another. The verbosity is not incidental to linearity — it is what linearity looks like without shadowing.


2. Why it was refused, which was not arbitrary

The obvious reading is that the rule was conservatism. It was not.

let held = box(h, 1);
let held = box(h, 2);   // and the first allocation?

If this were allowed, the first held would be unreachable with its obligation undischarged. Nothing could ever unbox it. That is a leak, and it is precisely the case §4 of linearity-and-effects.md says the system exists to prevent:

a value that reaches the end of its scope unconsumed is an error, because the case affine drops silently is the one the system exists to prevent.

So the blanket refusal was the correct first answer to a real hazard. It was simply broader than the hazard.

2.1 The language already shadows, where it is safe by construction

let in an inner block shadows an outer binding today, and always has. So does a let over a function parameter — the body is a block, so a parameter is always in an enclosing scope:

fn f(n: int) -> [] int {
    let n = n + 1;     // legal, and always was
    return n;
}

The rule was never “no shadowing”. It was “not within one block”, and the same-block case is the only one where a live obligation could be stranded — because in every other case the shadowed binding is still reachable when its own block closes, and the block-close check catches it there.


3. The rule

Shadowing is allowed exactly when the shadowed binding is dead.

A binding is dead when it holds no outstanding obligation: either it is val, so it never had one, or it is res and has been consumed.

That is not a new rule. It is the rule assignment already has:

x = e;          // refused if `x` still holds a live `res` value
let x = e;      // refused if `x` still holds a live `res` value

tests/reject/assign_over_live_res.cho has existed since M2. One rule, two syntaxes, and the second one now says so.

3.1 val shadows freely

Nothing is owed, so there is nothing to strand:

let n = read_digit(s, 0);
let n = n * 10 + read_digit(s, 1);

3.2 res shadows after it is consumed

Which is the threading case, and it works for a reason that is not a special case:

let slab = new_slab(heap, 4);
let (slab, handle) = insert(slab, 7);
let slab = remove(slab, handle);

A let’s initialiser is lowered before the binding exists — the rule that makes let x = x; read the outer x or fail, never itself. So by the time the new slab is declared, insert has already consumed the old one and it is dead. Shadowing needs nothing added for this; it falls out of an ordering that was already there for a different reason.

3.3 And the leak is still a leak

let held = box(h, 1);
let held = box(h, 2);

“held still holds a res value; shadowing it here would put that value out of reach with its obligation undischarged. Consume it first.”


4. Where it is checked, and why not in the parser

At replay, not during lowering.

Whether a binding is dead is a fact about the trace, and lowering cannot know it: a value may be consumed by a call whose argument types are still inference variables at the moment the call is lowered, and settle only later. That is the same reason modes are decided at replay rather than when a type is first written (linear.rs, the module comment).

So lowering records the link — this declaration shadows that slot — and the checker reads the liveness. Which is why this could not have been a one-line change in the parser, and why it is a slice rather than a patch.

4.1 It also moves a diagnostic to where the mistake is

Shadowing a live res parameter was already caught, because the value stayed live to the end of the function. But the refusal landed on the return:

fn f(t: Ticket) -> [] int {
    let t = 1;
    return t;        // error: `t` is still live here
}

t on that line is an int. The message was true of a binding the line does not mention. It now lands on the let, which is the line that is wrong.


5. What this does not change

  • A pattern still binds each name once. let Pair { a, a } = p; and Shape::Rect(w, w) are still refused: one pattern, one binding per name. Shadowing is between statements, never within one.
  • A borrow region name is not a binding. Regions live in their own namespace (§5 of linearity-and-effects.md), and nothing here touches it.
  • A frozen binding cannot be shadowed out from under a reference — but not because of a new check: a borrow block is a block, so its body is an inner scope, and there is no way to write a let in the same block as the borrow that is also inside it.
  • var is still assignment. var x = 1; x = 2; reassigns one binding; let x = 1; let x = 2; makes two. The difference matters when a reference is involved, and both obey the same liveness rule.

6. Open

QuestionWhy it waits
A warning for shadowing that is probably a mistakeA lint, not a rule, and this language has no lint pass
_ as a binding that discards a val§5 of tuples.md’s open list; still a decision about discards

7. The suite

FixtureRule§
shadow_over_live_res.choA shadowed binding must be dead3.3
shadow_over_live_res_destructured.choThe same, through a destructuring pattern3
shadow_a_live_parameter.choThe same, and now reported at the let4.1
binding_repeated_in_pattern.cho (exists)One pattern, one binding per name5
assign_over_live_res.cho (exists)The same rule, the other syntax3

One fixture was removed: rebind_same_block.cho, which was

let x = 1;
let x = 2;

and which asserted the old rule directly. It is the program this slice exists to allow, so it could not be kept — and a must-reject fixture disappearing is worth naming rather than leaving to a diff. What replaces it is the three above: the same shape, with a value that is actually owed something.

AcceptingShows
shadowing.choval shadowed freely, res shadowed after consumption, and an inner block shadowing an outer live binding
examples/slab/Rewritten: one slab threaded through, rather than eight names for it

Mode polymorphism

Status: settled, and it began by finding a leak and a double free.

§12 of linearity-and-effects.md lists mode polymorphism as open and half-answered: monomorphisation already makes fn id[T](x: T) -> T work at both modes. Checking that claim turned up a soundness hole underneath it, and the fix and the missing feature turn out to be one mechanism.


1. What was already true

More than the documents said. A generic container works at both modes in one program today:

res struct Holder[T] { held: T, tag: int }

fn wrap[T](value: T, tag: int) -> [] Holder[T] { ... }
fn unwrap[T](h: Holder[T]) -> [] T { ... }

let boxed  = wrap(Ticket { serial: 5 }, 1);   // res
let number = wrap(41, 0);                     // val

Both instantiations compile, and each copy is checked at the type it was instantiated at. So Option[T] and Result[T] over a resource type are expressible, and have been since M2.

A first version of this paragraph said Vec[T] too, and that was wrong — the third wrong answer in a row about the same question, each one looking at the generics. A Vec keeps its elements in a boxed slice and a boxed slice holds val data only, for reasons that have nothing to do with mode polymorphism. docs/collections.md §2.

docs/standard-library.md §4 said otherwise — that a Vec[T] “wants generics over a mode” and would “promise more than it delivers”. The diagnosis was wrong, written from §12’s open list rather than from a test; the conclusion about Vec happened to be right, for a reason neither document had found yet. They are all in std now except the one that cannot be — §5.


2. The hole

A generic aggregate declared val is trusted rather than checked:

val struct Wrap[T] { held: T }

let w = Wrap { held: box(h, 41) };   // and nothing has to consume it

That compiles. Under valgrind: 8 bytes in 1 blocks are definitely lost.

It gets worse, because val means copyable:

let a = unwrap(w);
let b = unwrap(w);        // `Wrap[Box[int]]` is `val`, so `w` copies
unbox(h, a); unbox(h, b); // one allocation, two frees

Invalid free() / delete / delete[] / realloc(). Ordinary code, no unsafe anywhere — which this language does not have. The same shape as the double free in boxed-slices.md, found the same way: by testing a claim instead of believing it.

2.1 Why

mode_of returns a declared mode without looking at the members:

#![allow(unused)]
fn main() {
if let Some(declared) = defs[index].declared_mode {
    return declared;           // `val` stops here
}
// members are substituted and walked only below
}

And the declaration-time check that refuses “Rc is declared val, but it holds Box[Cell], which is res” runs against the members as written, where T is Param(0) — which mode_of calls val. So the declaration passes, the instantiation is never re-checked, and Wrap[Box[int]] is val by assertion.

The boundary is exactly the val keyword. Without it the mode is computed from the substituted members and comes out res, correctly; res struct Wrap[T] is correct too. Only the explicit val is believed.


3. The fix is the missing feature

val struct Wrap[T] is a claim about every T. Written out, it means:

val struct Wrap[T: val] { held: T }

So the fix is to have that bound, and to check it where a type argument is supplied. Which is also what §12 was asking for — “a signature that says so and is checked once”.

3.1 The rule

A type parameter is bounded val, or it is unbounded. An unbounded parameter is checked as though it were res.

res is the stronger obligation: a body that satisfies linearity for a linear T satisfies it for a copyable one. So a function checked once under T: res is safe at every instantiation, and its errors land on the definition, where the mistake is.

[T: val] is the opposite promise — this only works for copyable types — and an instantiation at a resource type is refused at the call site, naming the bound.

3.2 There is no [T: res]

It would mean “checked assuming res”, which is what unbounded already means. A bound that changes nothing is a keyword to explain and never reach for, so it does not exist.


4. What that moves

The error for a generic that drops its parameter moves from the instantiation to the definition:

fn sink[T](x: T) -> [] int { return 0; }

Before: accepted here, refused at sink(open(5)) as “instantiated at File”. After: refused here, because it is wrong for some T and the definition is where that is true. Writing [T: val] is how a program says it meant only copyable types, and then the refusal is at the call site instead.

That is the whole trade, and it is the right way round: a library ships its definitions, and an error inside std/ pointing at code the caller cannot change is the worst place for it.

4.1 What it costs in this repository

Three functions need [T: val], and each is genuinely val-only:

unwrap_or in examples/tour.cho, examples/rational.cho, tests/accept/generics.choDrops fallback on the Some path
is_ok in examples/rational.choMatches and ignores the payload

They are not workarounds. unwrap_or over a resource type would have to drop one of two values, which is the thing this language refuses — and until now nothing in the signature said so.


5. What went into std

Not held up by mode polymorphism, which §1 shows was never the blocker. std.option, std.result, std.list and std.vec are docs/collections.md, and the bound did two things for them.

unwrap_or says [T: val], because it drops one of two values — which is the “place to put the value that is not returned” this section first asked about, answered by saying out loud that there isn’t one for a resource.

And res struct Vec[T: val] needed the bound on a type declaration, which §6 had listed as harmless-but-unnecessary. It is neither.


6. Open

QuestionWhy it waits
Option[T] / Result[T] in stdDone — docs/collections.md, along with List and Vec
Bounds on a generic type’s parametersDone, and the reasoning here was wrong. It is true that val struct X[T] implies T: val, and that is the only case where it does: a res aggregate promises nothing about its parameters, and res struct Vec[T: val] is exactly what a vector needs — it owns an allocation, its elements are copyable. Writing the bound is refused on a val declaration and required on the others. collections.md §3
Effect polymorphismAnswered, no — effect-polymorphism.md. This row and mode-polymorphism.md §8 were the entire design, and counting by reading says the feature has nothing to quantify over: 537 functions here write a row and not one could be polymorphic in it. A row is fixed at the declaration (Signature.effects, read straight off it by every caller), and the two things a function is generic over — types and regions — cannot select a callee. The nearest asker, io.write_all against io.error_all, differs by the builtin as well as the row, so collapsing it needs a function value: row polymorphism is the shadow of higher-order code, and Rule::NoFunctionValues refuses that with a fixture

7. The suite

FixtureRule§
val_generic_holding_a_res.choThe leak — a val-declared generic at a resource argument2
val_generic_copied.choThe double free, from the same hole2
unbounded_generic_drops_its_parameter.choAn unbounded parameter is checked as res, at the definition3.1, 4
val_bound_violated_at_the_call_site.cho[T: val] refused where the argument is res, and reported there3.1
res_bound_is_not_a_thing.choThere is no [T: res]3.2
AcceptingShows
mode_polymorphism.choOne generic used at both modes, and a [T: val] one used at a copyable type
tests/accept/generics.choRewritten: unwrap_or declares [T: val], which is what it always meant

Function values: the shape, settled before anything forces it

Status: built. §4.2’s design shipped as written: Type::Fn, a captureless, non-generic, val value naming a top-level function’s own DefId, checked through exactly like a named call and costing no row variable, per this section’s own prediction below. The asker was not one of the “zero” this document counted in §2 — it was threads.md, which needs a function value as spawn’s own callback parameter before spawn itself can exist. Building it ahead of that asker, rather than waiting for it the way §6 says to, was a deliberate choice once the design was settled and reviewed: spawn/join still need docs/threads.md’s own further review before they are built on top of this.

The audit’s L1 asked for this document before anything forced it. Rule::NoFunctionValues refuses a generic function value now — narrowed from refusing every one — and a sort comparator, an iterator, a callback, pthread_create and a plugin interface all wanted one. The audit called this “the decision most likely to force changes to existing row semantics”, so it was settled while the vocabulary was still moving, and it turned out not to force one.

Counted by reading, at the time this was written no program here asked. There were 594 function bodies, and the only pair that differed by nothing but its callee was io.write_all against io.error_all, which effect-polymorphism.md §3 had already found. The one sort had one comparator. None of the 22 enums picked an operation. That count is history now, not a precondition — §2 is kept below for the reasoning, not as a gate this feature is still waiting behind.

The design question had a firm answer, and it was the useful part. Function values do not force row variables, and they change no existing row rule. A captureless function value whose type carries a fixed row is checked exactly as a named call is — confirmed, not just designed: tests/accept/function_value.cho passes a region-polymorphic, effectful function as a value and the row is checked at the call exactly like a named call’s. What would change the row semantics is abstracting over rows, and that is a separate feature that has already been answered no.


1. What was asked

The audit, L1:

Function values are refused (Rule::NoFunctionValues). […] Write the design doc before anything forces it. Decide whether closures exist, what mode a closure capturing a res has, and how a higher-order row is spelled. A captureless fn pointer with a fixed row may be enough and keeps the checker total.

That is three questions and one candidate. §4 answers the three, and it takes the candidate, with two conditions the audit did not name.


2. The askers, counted

Every function body in std/, examples/, benches/ and tests/accept/ was counted by printing each file canonically and splitting it at top-level fn: 594 bodies before this document’s fixtures were added. tests/reject/ is left out, since those programs are written to fail.

Exact near-duplicates. Each body was reduced to a shape in which every name and literal becomes ID, except a name in call position, which is kept. Six groups have the same shape but different callees:

GroupWhat it is
6 mainsThe split/release preamble, differing in the one function each calls
3 pairs across tour.cho and match_a_reference.choThe same recursive walks, differing only in their own names
show / emitTwo one-line printers in different files
io.write_all / io.error_allThe one real candidate (§3)

Loose near-duplicates. A second pass also made binary operators interchangeable and ignored main, looking for the same loop with a different operation inside it. That is where a function value usually earns its place. It found four groups, and every one is either a function copied between examples/ and std/ or a recursion that differs only in its own name.

Sorting. examples/sort/ is the only sort. It has one comparator, before, with one caller. It takes no flags, so nothing picks an ordering at run time.

Dispatch already written by hand. There are 22 enum declarations, and every one carries data (List, Option, Tree, Step, Arg, …). No match anywhere selects an operation, which is how a first-order language spells a function value (§3).

Callbacks into C. No program declares an extern fn that takes a function pointer. The API that reach.md §3.3 names, pthread_create, is refused on other grounds before a function value would matter (§5).

So by the bar CONTRIBUTING.md sets, two askers, the count is zero. There is one near-miss, and §3 shows that function values do not help it.


3. What the language writes instead, and what it costs

cancho compiles whole programs. So the set of functions a value could name is always known, and an enum with one variant per function plus a match that calls one can always say the same thing. This is Reynolds’ defunctionalization, and it needs nothing the language lacks. tests/accept/defunctionalized_stream.cho collapses write_all and error_all that way:

enum Stream { Out, Err }

fn write_to[&r, &i](io: &!i Io, which: Stream, s: &r [byte]) -> [io_write, err_write] int {
    match which {
        Stream::Out => { return write_bytes(io, s); }
        Stream::Err => { return write_err(io, s); }
    }
}

It compiles, runs and prints. The cost is in the row. write_to performs whatever any of its arms performs, so every caller has to declare both streams, including a caller that only ever passes Out. tests/reject/defunctionalized_row_is_the_union.cho is that caller with its row narrowed to [io_write], and it is refused (effect-not-declared). The authority report of such a program says it writes standard error when it never does.

This cost is specific. It appears only when the arms have different rows. A comparator is [] in every arm, so defunctionalizing a sort costs nothing: sort -r -n would take a bool or an enum, not a function value.

Would a function value recover the precision? That is the question the audit is really asking, and §4.3 answers it: not unless write_to can also be generic over the row.


4. The decisions

4.1 No closures

A closure is a function value that captures, and every hard question in L1 is about captures:

  • A captured res makes the closure itself res, callable once and obliged to be called or released. That is Rust’s FnOnce, and it would follow from linearity-and-effects.md’s rules without a new one.
  • A captured reference gives the closure that reference’s region, so it could not outlive the borrow. That is also a consequence of existing rules, not a new one.
  • A captured capability is the reason for the “no”. Today authority reaches a function only through its parameters, so a signature lists every capability the body can use. Every narrowing argument in reach.md reads a signature that way. A closure that captured &!i Io would carry authority that no parameter names. Its row would still say io_write, so the effect would stay visible, but the capability would have travelled without passing through a parameter list.

So the answer to “what mode does a closure capturing a res have” is written down (res, called once), and closures stay out. Nothing asks for them (§2), and the one thing they add over §4.2 is hidden authority.

4.2 Function values, built

This was the audit’s candidate, with its conditions made exact, and it is now exactly what is built — Type::Fn in crates/cancho-types, Expr::FnValue/Expr::CallIndirect in crates/cancho-ir, and codegen in both backends (Cranelift: func_addr and call_indirect; LLVM: a global symbol read directly as a ptr value, with the callee’s signature spelled explicitly at the call since an opaque pointer carries none of its own):

RuleWhy
What can be a valueA named, top-level function that declares no type parametersMonomorphisation needs every instantiation to be known where the value is made. A call takes its type arguments from its arguments and never from the expected type (“cannot tell what T is in this call […]; it is not determined by the arguments”), and a function value has no arguments to take them from
Its typefn(A, B) -> [row] R, with the row written and exactThe row stays where linearity-and-effects.md §7.2 put it: at a boundary and never inferred across one
Its modevalIt captures nothing, so copying it copies no obligation
Its regionNone of its ownThe code is static. Only the regions its type mentions matter
Regions in the typeOnly regions already in scope where the type is writtenSo sort_by[&t](xs: &t [T], less: fn(&t [byte], &t [byte]) -> [] bool) is fine, and a type quantified over its own regions (rank 2) is refused
A call through itPerforms exactly the row in its typeThe same check as a named call, with the row read from the type instead of the declaration
IdentityA new expression node naming the target’s DefId, hashed through the target’s SigIdThe same edge a call already has. A program that writes no function value hashes exactly as it does today

4.3 Row semantics do not change, and row variables do not arrive

linearity-and-effects.md §10 made a prediction: “When closures arrive they bring row variables with them.” For function values as §4.2 shapes them, they do not.

  • A call through a value is checked against a declared row, the one in the value’s type, exactly as a named call is checked against the callee’s declaration. The check stays one walk of the body.
  • A higher-order function writes down the row of what it calls, because that row is part of the parameter’s type. So its own row is still written, exact and constant.

What this buys is less than it looks, and §3’s example shows exactly how much. Try to recover write_to’s precision with a function value:

fn write_with[&r, &i](io: &!i Io, w: fn(&!i Io, &r [byte]) -> [io_write] int,
                      s: &r [byte]) -> [io_write] int

This accepts write_bytes and not write_err, because the row is fixed in the parameter’s type. Accepting both needs write_with to be generic over that row, which means a row variable. That is effect-polymorphism.md’s feature, and it found nothing to quantify over. Without it, fixed-row function types give one higher-order function per row, which is no better than today’s two wrappers.

So the dependency runs one way. Function values do not need row variables. The precision people expect from function values does, and that is a separate decision, already made.


5. Callbacks into C, the one thing no enum can write

Defunctionalization works because this program can see every function the enum might name. C cannot see the enum, so a function pointer handed to C is the one thing §3 cannot replace. Three conditions settle what is reachable:

  1. The row must be []. C calls the function with C’s arguments, and C cannot supply a capability, since there are no captures (§4.1) and no context pointer to carry one (condition 2). A callback that performed io_write could never have been handed the Io to do it with.
  2. Its parameters must be scalars. Most callback APIs pass void *, and a pointer from C carries no region. That is the same wall as reach.md §3.1, which blocks TLS and libpq. qsort and bsearch compare const void * arguments, and pthread_create’s start routine takes and returns one.
  3. The calling convention already matches. Every cancho function is emitted with the target’s default convention (module.isa().default_call_conv() in cancho-codegen/src/emit.rs), which is the C ABI for scalar arguments.

What is left is atexit (void (*)(void)) and signal (void (*)(int)). No program here asks for either. Threads are not unlocked by function values: pthread_create fails condition 2, and a thread also needs an answer about data races that this document does not give. reach.md §6 said that threads want “more than a pointer”, and this is the list of what more.


6. What would create an asker

Not a prediction. This is the list to check against, so the next reader does not have to re-derive it. The bar is two programs.

AskerWhy it would count
A real program that needs atexit or signal§5’s reachable remainder. A graceful SIGINT in examples/serve/ is the likeliest first one
A library compiled separately from its callersDefunctionalization needs the whole program in view. This language has never compiled anything else (many-files.md), so this would be a change to that first
Arms with different rows where the precision matters§3’s cost. But recovering it needs row variables as well (§4.3), so this asker is really an asker for effect-polymorphism.md

A second comparator, from sort -r or sort -n through flags.md, would not count. Comparators are pure, so an enum costs them nothing (§3).

Corrected: the asker that actually arrived was none of these three. threads.md needs a captureless function value as spawn’s own callback parameter — not a pthread_create callback (§5 already refuses that permanently, for a reason a function value cannot fix), but this language’s own spawn/join primitive, which never crosses the C ABI at all and so is never bound by §5’s row-[], scalars-only conditions. A fourth kind of asker this table did not name: a language primitive under active design that turned out to want one, rather than a real program in the existing corpus.


7. What this does not say

  • Not a claim about higher-order code in general. In a language with separate compilation, or with an effect system that infers rows, §3’s substitute would not be available and the arithmetic would be different.
  • Not that abstracting over rows is settled. §4.3 still holds: function values alone recover none of defunctionalized_stream.cho’s lost precision, because that needs a row-polymorphic higher-order function, and effect-polymorphism.md still answers that no.
  • Not that Rule::NoFunctionValues is gone. It now refuses exactly what §4.2’s table always said a function value could not be: generic (tests/reject/function_as_value.cho, updated to that case) or anything other than a named, top-level, captureless function (a builtin, an extern fn). The rule’s message still names “M1”, a milestone it has outlived, and this correction leaves that alone because reach.md §3.3 used to quote it word for word and no longer does — that quote is corrected there instead.

8. The suite

FixtureWhat it pins
tests/accept/defunctionalized_stream.cho§3: an enum and a match write what a function value would, today, with no new feature
tests/reject/defunctionalized_row_is_the_union.cho§3: the cost, since a caller of the enum version cannot declare less than the union of the arms’ rows (effect-not-declared)
tests/accept/function_value.cho§4.2, built: a captureless, region-polymorphic, effectful function taken as a value, called through it twice (val, no move), on both backends
tests/accept/function_value_operand.choA call through a value is an expression like any other: as either side of an operator, in a comparison, and on float. The LLVM backend refused it as an internal error (no CallIndirect arm in scalar_kind) until it was found; both backends agree now
tests/reject/function_as_value.cho§4.2’s own condition: a generic function has no address until its type arguments are known, so it cannot be a value (no-function-values)
tests/reject/function_value_wrong_arity.cho / function_value_wrong_type.choA call through a value is checked against the value’s own type exactly as a named call is
tests/reject/function_value_row_undeclared.choThe row travels with the type and is checked against the caller’s row the same way a named call’s is (effect-not-declared)
tests/reject/function_value_compared.cho§4.2 gives a function value no identity to compare; ==/!= stay exactly the scalar list they always were
tests/reject/call_a_local_binding.choThe mirror: a local binding whose type is not fn(...) still cannot be called (not-a-function)

Threads: why pthread_create cannot be the primitive, and what can be

Status: §2’s single-leaf slice is built. docs/reach.md §3.3 and docs/function-values.md §5 both point at the same wall and stop there: a thread needs “more than a pointer”, and neither document says what more. This document finishes that sentence. The answer is not “add function values and declare pthread_create” — §1 shows exactly why that specific shape cannot work in this language, for a reason no amount of FFI cleverness fixes. The way through is a compiler-provided spawn/join pair, the same kind of primitive fork, box and split already are, built on an insight the existing checker did not need to be extended to state: the aliasing rule that makes a borrow block sound was never a single-threaded rule. It forbids a second writer while a reference is live, full stop, and a spawned thread joined before its region closes is just a second writer the checker already refuses to admit exists. (That was not true when this was written: see the correction in §3. It is true now, because spawn lends the &! it is given until the handle is joined – aliasing.md §6.1.)

What is built, and what is deliberately narrower than §2’s own illustrative signature: spawn’s payload and body’s return type are each restricted to one pointer-width leaf — int, bool, c_ptr, a captureless function value, a single non-slice reference, or an owned zero-or-one-leaf capability (Io, File, Ffi, Fs, Args, Heap) — checked by Rule::ThreadPayloadType (crates/cancho-ir/src/lower/conc.rs). body’s own compiled entry point becomes pthread_create’s start routine directly; the reason a general struct payload is still refused is not a missing feature but a missing prerequisite feature — this language has no compiler-synthesised trampoline function yet (nothing builds a hand-crafted Func outside a parsed source body), which is what a multi-field struct payload would need. §5 step 3’s owned-capability case turned out to need none of that: File is already one leaf at the ABI level (the fd) and every zero-field capability (Io and the rest) is already zero, so both cross through the exact same paths this slice already built for int and () — the fix was widening the checker’s allowlist, not new codegen, confirmed by running real capability-moving programs on both backends before writing it down. res Thread[T, R] — two type parameters, not the one sketched below, because T rides along purely so its region is tracked, getting §3’s entire escape-check argument for free from Type::Named’s already-generic mentions/regions_into walk into its type arguments rather than needing any new checker code. Real threads (std::thread-adjacent: a genuine pthread_create/pthread_join pair on both backends), not a simulation — verified under §5 step 2, distinct thread IDs and real wall-clock parallelism, and under step 3, a real file read and a real console write each performed by a second OS thread holding the only reference to the capability that authorised it, all checked directly rather than trusted from the type checker alone. Step 4 is also built, folded into steps 2 and 3’s own fixtures rather than needing a separate one.

§5 steps 3’s struct-shaped case and 5, checked and closed as “not yet,” not left open. A genuinely multi-field payload needs the same compiler-synthesised trampoline (§4 below) whether or not it happens to be a capability, and Rc[h] T is not an implemented type in this compiler at all, so no fixture can even construct what step 5 would refuse. Rather than build the trampoline speculatively, this project’s own rule (AGENTS.md §7: a feature earns its way in when a program asks) was applied and checked directly: nothing across cancho, lex-os, lex-lang or lex-gpu asks for a multi-field spawn payload today — lex-os-guest’s own reasoning loop is single-threaded by design with no expressed want for concurrency, and lex-lang’s own conc.spawn_thread (a parallel, independent design for the same problem in a different language) sidesteps the question entirely with a zero-argument closure rather than an explicit payload, which is a mild signal that real background-task askers in this shape of problem tend to route around a fat payload rather than demand one. Decided, the same way self-hosting.md decided not yet: revisit when a concrete asker exists, not on a schedule.


1. Why pthread_create cannot be an extern fn

function-values.md §5 already worked out the three conditions a callback into C needs, checking pthread_create’s real signature against them:

int pthread_create(pthread_t *thread, const pthread_attr_t *attr,
                    void *(*start_routine)(void *), void *arg);
  1. The row must be []. C calls start_routine with exactly one void * and nothing else. There is no capability parameter for an authorised call to be checked against, and no context pointer reach.md §3.1’s rule would let carry one anyway.
  2. Its parameters must be scalars. start_routine’s void *arg is reach.md §3.1’s wall by another name: a pointer from C carries no region, and there is no cast, no from_raw, no transmute anywhere in this language to manufacture one (§3.1.1). c_ptr (opaque-pointers.md) does not help either — it is opaque by design, equal to itself and nothing else, never dereferenced. Smuggling a real capability inside one and reading it back out the other side is exactly the “smuggling one does not help” argument reach.md §3.1.1 already made about malloc, applied to a thread’s argument instead of a foreign result.
  3. The calling convention matches. True, and irrelevant, because (1) and (2) already stop the declaration.

So the honest reading of function-values.md §5’s own list is not “threads need function values plus one more thing.” It is: the void *arg C hands a thread’s start routine is the same un-crossable pointer reach.md §3.1 refuses everywhere else, and no amount of function-value design closes that gap, because the gap is not about calling a function — it is about the one argument C’s own ABI allows across, which is exactly the shape c_ptr was built to be safe about by refusing to be anything else. A c_ptr-typed start_routine argument would type-check and be useless: nothing can turn it back into the capability or reference the spawning side meant to send, for the same reason nothing can turn a smuggled int back into a reference (reach.md §3.1.1).

The conclusion function-values.md §5 already states is the right one: pthread_create is unreachable through the FFI mechanism this language has, permanently, not provisionally. A design for threads that starts from “declare pthread_create as an extern fn” is solving the wrong problem.


2. The primitive is spawn, not a declaration

fork (reach.md §3.3), box (heap.md), and split (§8.2 of several documents) all share a shape: each is a builtin the compiler implements directly in Rust, not a foreign function a program declares and calls through Ffi. None of them is bound by the C ABI a real extern fn call has to cross, because none of them makes a C call in the way an extern fn does — the compiler is free to implement each however it needs to, the same freedom that lets box hand back a real, checker-tracked pointer while malloc (declared by hand) cannot (reach.md §3.1).

A thread should be exactly this kind of primitive. Call it spawn:

fn spawn[T: res](payload: T, body: fn(T) -> [row] R) -> [] res Thread[R];
fn join[R](handle: res Thread[R]) -> [row] R;

(Illustrative signatures — spawn/join are builtins with call-site checked types, the same way split/release/box are, not written functions; [T: res]/[R] here mean “however the real type-checker already spells an unbounded generic parameter”, mode-polymorphism.md §2.)

Because spawn never goes through libc, none of §1’s three conditions apply to it. What replaces them:

  • body is a captureless function value (function-values.md §4.2, unchanged) — this is the asker §6 of that document said would justify building them. payload is its one argument, and unlike a C callback’s void *, it is an ordinary cancho value of any type the checker already understands, because the compiler runtime calls body’s own compiled entry point directly — the same lexs_<name> symbol a normal call already targets — rather than going through a C function pointer with a fixed, foreign signature.
  • payload is moved, not borrowed. spawn consumes it exactly the way any function call consumes an argument whose type is not val (linearity-and-effects.md’s existing rule, nothing new). After the call, the spawning code cannot read payload again — which is the whole soundness argument, in §3.
  • body’s row is whatever it needs to be, not []. If payload carries a capability, body’s row can perform whatever that capability authorises, checked exactly as a normal call’s row is checked against what its arguments carry (reach.md §8.4’s authorisation check, unmodified).
  • join is mandatory. Thread[R] is res: obliged to be consumed, exactly like a File handle or any other capability (linearity-and-effects.md §4). A program that spawns and never joins does not compile, for the reason file_handle_unclosed.cho already exists.

3. Why this needs no new soundness rule, only a new obligation

Corrected twice. First (atomics.md §2, measured): the argument below was true of a moved unique reference and false of &! in general, and the claim that the checker “already refuses to admit” a second writer was wrong. Then closed (aliasing.md §6.1): a &! given to spawn is now lent to that thread until its handle is joined, and a second spawn of a copy of it, or any use of it by the spawning thread meanwhile, is refused (tests/reject/spawn_two_copies_of_unique.cho, spawn_same_unique_twice.cho, spawn_unique_used_while_lent.cho; tests/accept/spawn_unique_join_first.cho is the control that stays accepted). The paragraph below is what was measured before that change. &!r T is val (aliasing.md §1): let a = r; let b = r; is two copies of one pointer, and spawn(a, ..) and spawn(b, ..) each take one. That compiles on both backends and loses updates: two threads adding 1 to one counter 200,000,000 times each ended short of 400,000,000 on every run, on arm64 macOS and x86-64 Linux (benches/atomics/race.cho; the control that joins the first thread before spawning the second is exact). So a data race on an ordinary value is expressible today; what is sound is a shared & (nothing writes through it) and a &! that is the only copy. The one-reference-per-thread tests in §5 do not exercise the copy.

The question function-values.md §4.1 raised about closures — “a captured capability is authority no parameter names” — does not apply here, because payload is a parameter, of spawn itself, checked by the ordinary rule that already governs every parameter. The real question is different, and it is the one this document’s status line opens with: can two OS threads touch the same region without the checker knowing anything about “threads” at all?

The existing rule is linearity-and-effects.md §5’s aliasing discipline: while a unique reference &!r T is live, no other reference to the same value exists; while a shared reference &r T is live, every other reference to it is also shared, and nothing through any of them writes. This rule is stated over lifetimes, not over control flow — it never says “and no other call frame touches it,” because in a single-threaded language every other call frame is either an ancestor (blocked on this one returning) or has already returned. A second OS thread is the first call frame that is neither: it can run while the spawning frame is also running.

That sounds like a gap. It is not one, provided exactly one thing holds: payload’s references cannot escape join. If a reference &r T inside payload is only valid because a borrow r block is open in the spawning function, and join on that thread’s handle must run before that borrow r block ends, then the two “threads” of execution are, from the aliasing rule’s point of view, just two readers of r whose lifetimes are both nested inside the same open block — indistinguishable from two ordinary calls each taking &r T, which the rule already allows and already forbids a concurrent unique reference to. The rule was never about time; it was about which reference exists when. Enforcing “the handle cannot outlive the region its payload mentions” is exactly linearity-and-effects.md §5 rule 4, the escape check Type::mentions already performs (crates/cancho-types/src/lib.rs) — applied to Thread[R]’s own type the same way it is applied to a borrow block’s result today. No new check; the existing one, asked about one more kind of value.

What this buys, precisely:

  • A shared reference (&r T) may cross into payload. Two readers, one on each OS thread, is what the shared-reference rule already permits — it is unsound only if a writer could run concurrently, and the rule already refuses to admit a unique reference exists while a shared one is live, regardless of which thread holds either.
  • A unique reference (&!r T) may cross into payload, and then the spawning function holds no reference to that value at all until join returns it back (if R carries it out) — this is not a new rule either; it is what “moved” already means for a unique borrow’s own referent once the borrow ends, applied to the borrow itself.
  • A capability may cross into payload, checked by the existing authorisation rule (reach.md §8.4): the spawned body’s row is whatever the capability inside payload authorises, and the spawning function no longer holds that capability (moved), so it cannot also use it — the same “authority is a resource, spent once” rule linearity-and-effects.md §4 already states for release.

4. What this does not solve

  • Rc[h] T must not cross — moot today, checked rather than assumed. linearity-and-effects.md §9 names it “non-atomic reference count” as a deliberate, single-threaded simplicity trade. Two threads each holding an Rc to the same value could race its increment/decrement, corrupting the count — the exact hazard the “non-atomic” word warns about, latent until something lets an Rc reach a second thread. The exclusion needs no new check at all right now: Rc is not an implemented type in this compiler (sharing.md), so crosses_to_a_thread’s allowlist already excludes anything shaped like it, the same way it excludes every other multi-field type. Worth a real, narrow check and a fixture the day Rc (or an atomic variant of it) exists — not before, and not a hatch this document opens in the meantime.
  • A wider payload is thread-payloads.md, which also records why a unique slice cannot simply be allowed (§3 there).
  • No shared mutable state. (Designed for one word at a time in atomics.md.) &!r T crossing means the unique reference moved, not that two threads can now both mutate the same memory – and, per the correction in §3, a copied &! is refused at the second spawn, so it cannot do exactly that either. A Mutex-shaped capability — lock, get a unique reference scoped to the lock, unlock — is a real, separate design, parallel to how Fs(prefix) turned “files” into a capability rather than a raw handle (filesystem.md). Not proposed here.
  • No cancellation, no thread-local state, no thread pool. join blocks until the thread returns; there is no way to ask it to stop early, and nothing about a pool of reusable threads is addressed. A pool is spawn/join called in a loop today, at whatever cost real OS thread creation has — measuring that cost is part of §5’s verification, not assumed here.
  • Does not make pthread_create reachable. A program that specifically wants to call real pthread_create (to interoperate with a C library that spawns its own threads, say) still cannot, for exactly §1’s reason. spawn is this language’s own primitive, not a wrapper around the libc one — the runtime backing it may well call std::thread::spawn (which itself calls pthread_create) in Rust, but that call is the compiler’s to make, never the program’s.

5. Verification plan

function-values.md §2’s bar — two real askers — is not yet met by counting, the way it was for a decision that already answered no. The verification here has to be earned by building the primitive and using it, not by counting existing programs against a feature that does not exist yet for any of them to ask for.

  1. Function values, minimal slice. Built (#127). Type::Fn, Expr::FnValue/Expr::CallIndirect, on both backends — tests/accept/function_value.cho.
  2. spawn/join, no shared data. Built. tests/accept/ spawn_join.cho: a thread that receives an owned int, computes on it with no capability, and returns an int through join. Verified by checking two things directly rather than trusting the type checker alone (tests/accept/spawn_thread_ids.cho, tests/accept/spawn_parallel_sleep.cho, checked on both backends in crates/cancho/tests/conformance/backends.rs): distinct OS thread IDs (pthread_self, declared the ordinary extern fn way — no gettid needed, since a thread ID here is only ever compared, never printed as the kernel’s own number) and wall-clock evidence of real parallelism (four independent 200ms sleeps, joined, finish in about one sleep’s time, asserted well under two, not four) — the same “measured, not argued” standard reach.md itself is held to. The same fixture doubles as §3’s “shared reference crosses into more than one spawn, and the spawning side still reads it after every join” case: one borrowed Ffi("libc") capability is payload for two threads and is read a third time by main once both are joined. tests/reject/spawn_handle_escapes_borrow.cho checks §3’s soundness argument itself: a Thread[&r int, int] handle returned out of the borrow r block that opened r, without joining first, is refused by the existing reference-escapes-region check — no new rule, exactly as §3 predicted. tests/reject/ spawn_payload_type_not_supported.cho checks this slice’s own wall: a multi-field struct payload is refused by Rule::ThreadPayloadType before it ever reaches codegen.
  3. spawn/join with a moved capability. Built — and this step’s own original text was wrong. It assumed an owned File/Io is a multi-field struct under the hood needing the same trampoline the struct-payload case does; checked against abi::leaves_into on both backends before writing any code, File turns out to be exactly one leaf (the fd, PRELUDE_FILE’s own dedicated arm) and every zero-field capability (Io, Ffi, Fs, Args, Heap) is exactly zero — the same shapes this slice’s codegen already handles for int and (), so the fix was a wider crosses_to_a_thread allowlist, not a trampoline. tests/accept/spawn_owned_io.cho moves an owned Io into a thread that writes to the real console (a zero-leaf capability, worker’s own row correctly [] since owning Io outright discharges io_write, defs.rs’s discharged_by); tests/accept/spawn_owned_file.cho moves an owned, already-open_read File into a thread that reads it and closes it there (one real leaf, the fd). Both checked on both backends (the_two_backends_agree_on_spawn_owned_io/_file, crates/cancho/tests/conformance/backends.rs). Left genuinely unbuilt: a capability shaped like a real multi-field struct — none of today’s capabilities are, so this step’s trampoline-free answer may not generalise past File/Io’s own accidentally-simple shape.
  4. spawn/join with a shared reference across the join. The shared-reference half is built, folded into step 2 above. Its companion — a tests/reject/ fixture proving a unique value cannot be read from the spawning side before join — is built as the owned-capability case’s own mirror rather than a separate reference fixture: tests/reject/spawn_owned_capability_reused.cho moves an owned Io into spawn and then releases it again from the spawning function, refused by the pre-existing linear-use-after-move rule with no thread-specific rule added, confirming §3’s “the existing rule, not a new one” claim by running it rather than only stating it.
  5. The Rc exclusion — measured, decided not yet, not left open. Cannot be built today: Rc[h] T is not an implemented type in this compiler at all, so no fixture can construct the payload this step needs to refuse, and crosses_to_a_thread already excludes anything of that shape structurally with no code written for it specifically. The step this document actually owes an answer to is broader — does a multi-field spawn payload (Rc-shaped or otherwise) have a real asker anywhere today — and that was checked directly rather than left as a standing TODO: a repo-wide search across all four reachable repositories (cancho, lex-os, lex-lang, lex-gpu) found none. lex-os-guest’s reasoning loop (the concrete production target §2’s own status note names) is single-threaded by design, with no comment or TODO anywhere in it wanting concurrency. lex-lang’s own, independently-designed conc.spawn_thread answers the same “how does a background task get its state” question with a captured, zero-argument closure rather than an explicit payload — evidence, not proof, that this problem shape does not actually want a fat payload even where a language’s design is free to offer one. AGENTS.md §7’s rule (a feature earns its way in when a program asks) applies as written: not built, and not because of missing infrastructure alone.

Corrected (parallelism.md §3.4): a multi-field payload does not need the trampoline when it is passed by reference. A unique reference to a struct, including one that owns a Box[[int]], is one pointer leaf and crosses today on both backends (tests/accept/spawn_struct_ref.cho); the trampoline is needed only for an owned struct. Two further findings from the same probes: each spawn needs its own function value (one work taken once is instantiated at the first borrow’s region, and the second reference “does not outlive” it); and Net(..) and Clock were refused as payloads although crosses_to_a_thread’s own comment lists Net among the zero-field capabilities, which was an allowlist omission, not a design position: both are admitted now (tests/accept/spawn_owned_net.cho, spawn_owned_clock.cho).

Steps 1 through 4 are built. Step 3’s struct-shaped-capability half and step 5 are measured and decided not yet, the same verdict self-hosting.md reached the same way: nothing found is a hard blocker (the trampoline is buildable, Rc is buildable), but nothing asks for either today. Revisit both together — the trampoline is one piece of infrastructure whether the payload that needs it is Rc-shaped or any other multi-field struct — the day a concrete asker exists, rather than on a schedule.

6. aarch64 Linux: the thread counter has to be aligned

spawn and join move a counter of unjoined threads up and down atomically (docs/signals.md section 3: a claim is refused while one runs). It is the second word of lexs_signal_state, and it is the only atomic in the compiler’s output. On aarch64 an atomic is an exclusive pair (ldaxr/stlxr), and an exclusive access to an address that is not a multiple of its size faults with SIGBUS regardless of the alignment-check bit that lets ordinary loads through.

Measured on Debian trixie aarch64 (colima, kernel 6.8, rustc 1.98.1, clang 19.1.7), on origin/main at 906ad24:

  • tests/accept/spawn_join.cho built with --backend cranelift exits 135 (SIGBUS) and prints nothing; with --backend llvm it prints 42 and exits 0. The fault is at the ldaxr of count_thread, on address 0x…60049.
  • nm -n on the Cranelift binary: glibc’s crtstuff.o puts a one-byte completed.0 at the start of .bss (0x20030), and the compiler’s four zeroed objects follow it with no padding: lexs_argc at 0x20031, lexs_argv at 0x20039, lexs_fd_epoch at 0x20041, lexs_signal_state at 0x60041. .bss itself is aligned 2**0. Cranelift’s DataDescription defaults to an alignment of one, and cancho-codegen never set one.
  • The same compiler on macOS arm64 emits the same ldaxr/stlxr loop, and the program prints 42: ld64 starts __bss on a page and puts nothing of its own ahead of these objects, so they land on 0x100008000, …8008, …8010 and …48010. Aligned by luck, not by request.
  • The LLVM backend was never affected: it declares the same object as internal global [2 x i64], which has i64’s alignment by type.
  • x86-64 Linux has the same completed.0 in front, so the counter is misaligned there too (inferred from the same crtstuff and the same unaligned .bss; not re-measured on an x86 host). lock xadd accepts an unaligned operand, so the bug is only latent there.

So this was not the LLVM backend’s thread entry, the ABI, clang 19 or pthread linking. Fourteen of the fifteen conformance failures reported on aarch64 Linux (backends::…spawn_*, …fork_*, …a_join_as_an_operand, spawn_and_join_run_concurrently_not_sequentially, and the two signals:: thread tests) are this one fault: each runs a Cranelift binary that spawns, and assert_backends_agree puts LLVM’s 42 on the left and the crashed Cranelift run’s empty output on the right. All fourteen fail before the change and pass after it, on the same container.

The fifteenth, differential::the_folder_agrees_with_the_backend, is a different thing and passes on the unchanged compiler. The reproduction ran under sh -c 'ulimit -c 0 && …', and dash’s ulimit lowers the hard limit too (ulimit -Hc reads 0). The test’s pre_exec then asks for RLIMIT_CORE = 1 (differential.md §3.2), which needs raising the hard limit, and gets EPERM, so spawn fails with “Operation not permitted” before the program runs. It passes with the hard limit left alone or with ulimit -Sc 0. The harness now leaves a hard limit of zero as it is, because skipping the dump is only a saving.

The rule: every data object the Cranelift backend defines states its alignment. Word data (the zeroed globals, and a static whose elements are 8-byte leaves) is aligned to 8, which is the size of a leaf (layout.md §1) and what LLVM already gives the same objects. Byte data (string literals, a static of byte) stays at 1, so nothing grows. cancho-codegen’s every_word_global_is_aligned_to_a_word reads the alignment back out of both the ELF and the Mach-O object, so the check runs on every host CI has, including the two where the misalignment never faults.


7. A region per unit of work does not scale with threads on macOS

Measured on an Apple M4 Max (16 cores), macOS 26, from a table reader (cancho-table, docs/numbers.md) that opens one region per cell: on a 1M-row file 1 thread took 1.09 s and 16 took 2.15 s, where Linux scaled.

Cause: the system malloc on a request over 32 KiB, not the region code. A region is one malloc(ARENA_CHUNK) and one free (region_stmt: “one malloc in, one free out”), and ARENA_CHUNK is 64 KiB. libmalloc serves requests up to 32 KiB from per-thread magazines; above that (measured: 32,768 bytes is fast, 33,000 is not) each malloc/free goes to the kernel’s VM allocator, which is cheap on one thread (about 0.5 µs a pair) and, with the same loop on every thread, serialises: the time is almost all system time. That a lock inside the VM allocator is what they queue on is an inference from the sys time and from mmap/munmap behaving the same, not something profiled below that.

what1 thread8 threads16 threads
C, malloc(64 KiB); free, 100,000 per thread, wall0.05 s1.28 s (sys 6.9 s)
the same, 200,000 per thread0.11 s5.74 s (user 16 s, sys 62 s)
the same at 32,768 bytes0.00 s0.00 s
the same at 33,000 bytes0.05 s1.34 s (sys 3.8 s)
C, mmap/munmap 64 KiB, 100,000 per thread0.15 s2.56 s (sys 13 s)
C, one chunk per thread kept and reused0.00 s
cancho build --backend cranelift, 300,000 regions, 80 ints each, per thread0.40 s4.69 s (sys 20.5 s)8.59 s (user 20.7 s, sys 41.0 s)

Same work per thread, so a flat row would be linear scaling: it is 21 times slower at 16 threads. The cliff sits between 32,768 and 33,000 bytes and the size does not matter above it (49 KiB, 64 KiB, 128 KiB, 1 MiB are alike), so mmap is not a way out either. With --backend llvm (the default) a toy loop whose arena never escapes does not show it, because LLVM deletes a malloc/free pair it can see through (0.22 s at 1 and at 16 threads); a region whose chunk is passed to a call it cannot see through, which is any real reader, pays in full.

Not fixed here, and why. Both ways to fix it change what a program means or need machinery this repository does not have: a chunk of 32 KiB or less makes every program that allocates between 32 and 64 KiB in one region trap (allocating_past_an_arenas_chunk_traps_with_sigill is the trap), and a per-thread chunk that a region takes and gives back needs thread-local storage in both backends, a thread-exit hook to free it, and a rule for nested regions, which is a design and not a patch. What a program can do today is open one region per batch and not one per item (a row’s cells, a block of 1,000 rows), which turns the cost into one lock acquisition per batch; on macOS --threads with a region per cell should be left off until a chunk cache exists. The reproduction is in scripts/region_scaling.c.

Fixed (region-chunk-cache.md, #375): each thread keeps the chunks its regions give back (at most 16). The same shape of program, 300,000 regions a thread, Cranelift, on this M4: 0.03 s at 1 thread, 0.03 s at 8 and 0.04 s at 16, where it was 0.18, 3.82 and 8.76 s (§8.4 there). One region per batch is no longer needed for speed.

Threads that carry more than one value

Status: design. threads.md §2’s single-leaf slice is built; this is what it left, and the second program that needs it.

threads.md restricts spawn’s payload and join’s result to one pointer-width leaf (int, bool, c_ptr, a captureless function value, one non-slice reference, or one owned zero-or-one-leaf capability), and says why a general struct is refused: “not a missing feature but a missing prerequisite — this language has no compiler-synthesised trampoline function yet.” cancho-gpu (cancho#251) and cancho-cache (docs/design.md §2: “a cache that shares one store across threads needs the communication primitive that cancho does not have”) are the programs that now ask.

1. What each asker needs

programwhat a thread must be givenwhat it must hand back
cancho-gpu runtimea region of the weights ([byte] view, offset, length), a range of rows of an activation matrix, a handle to a capability (File)a count or a status; the result is written into a buffer it was lent
cancho-cachethe shared store, and a queue of connectionsnothing until shutdown
the examples’ pools (threads.md §4)a job description: a few ints and a slicean int

The shape is the same everywhere: a handful of leaves, one of them a slice, and a result that is mostly an acknowledgement because the real output went into lent memory. That is a narrower need than “arbitrary structs”.

2. Why a slice is the hard leaf

threads.md §3’s argument for soundness is that spawn’s borrowed payload cannot outlive join, using the region the borrow opened. That is unchanged for a slice, and a &r [byte] payload is the same proof as a &r int. What is missing is only the ABI: a slice is pointer and length, and pthread_create hands the start routine one pointer. So the compiler has to build a small record on the spawner’s stack, pass its address, and have the entry point unpack it: the trampoline threads.md names. Nothing about the language is unsound; it is a codegen slice.

3. The proposal: a tuple payload, packed by the compiler

let t = spawn(body, (xs[lo..hi], row0, row1));   // payload: any tuple of the allowed leaves
...
let n = join(t);                                  // result: still one leaf
  • payload may be a tuple of leaves from §2’s list, plus slices (&r [T] and &!r [T]), whose region is tracked exactly as the single non-slice reference is today. Nested tuples and structs follow only when a program needs them; the allowlist grows by one shape at a time.

  • A unique slice is the point, and it is where this document found a real problem. Disjoint row ranges of one output buffer, lent to different threads, is the data-parallel pattern. threads.md §3 makes a moved unique reference sound: the spawner holds no reference to the value until join. That covers one thread per buffer and nothing more. To give two threads two halves, the spawner must derive two sub-slices from one borrow, and slicing.md §4 says that is exactly what the language does not police: &! is a lock on the binding, not Rust’s no-aliasing invariant, so xs[0..6] and xs[3..9] are two live &! views of overlapping memory by design, and split_at “is not a safety feature here”. Single-threaded that is sound (one buffer, one write-back, writes alias instead of racing). Across threads it is a data race, and nothing in the current checker refuses it. A tuple payload that accepted &!r [T] leaves would therefore be unsound, and this document does not propose it as it stood in its first draft.

    What would make it sound is a new rule, not a wider allowlist: a disjointness-tracked split (split_at(xs, k) returning two views the checker records as non-overlapping for the borrow’s lifetime, and only such views may cross to a thread). That is the one place where Rust’s split_at_mut is a safety feature here, and it reopens slicing.md §7’s deferred question on a stronger footing than convenience. It is its own design (do two views of one split cross independently? is a split of a split tracked?) and T-P3 below is blocked on it. Until then, shared slices cross freely (readers cannot race) and a unique slice crosses only as the whole borrow, to one thread.

  • The result stays one leaf. Results travel through lent memory; widening join is not needed by any asker, and removing the need is the cheaper design.

  • A rule tag per refusal: a payload leaf not in the list keeps ThreadPayloadType; a unique slice whose region overlaps another live borrow keeps the aliasing rule’s tag; a tuple whose elements escape the region is the escape check of threads.md §3.

4. What this still does not give cancho-cache

Sharing a store across threads needs two things that are not a wider payload: a way for two threads to touch one mutable region at all (threads.md §4, T4/T5), and an ordered communication primitive (a bounded queue needs an atomic load and store with ordering). The payload slice lets a main thread lend disjoint pieces of a buffer to workers and take them back at join; it does not let two running threads exchange messages. That is the communication primitive of parallelism.md T5, and it is not proposed here. The cache’s one-core-per-process design (cancho-cache §5) stays correct in the meantime.

cancho-gpu does not need it: a matrix product is fork-join over disjoint output rows, which the payload slice and join express completely.

5. Plan, each step with its own gate

stepwhatgate
T-P1the trampoline and tuple payloads of leaves, no slices; both backendstests/accept: a tuple of three ints and an Io crosses and returns; a tuple with an unlisted leaf is refused with ThreadPayloadType; the existing thread fixtures (tests/accept/spawn_*.cho) unchanged
T-P2shared slices in the tuplea four-thread sum over disjoint views of one [int] matches the sequential sum; a payload view that escapes join is refused (spawn_handle_escapes_borrow’s sibling)
T-P3unique slices through the disjoint split of §3, its own design firstfour threads write four disjoint quarters of one buffer, result byte-identical to one thread’s; two threads given overlapping &! views are refused at compile time (today they would not be: this is the test that shows the rule is new); a mutant that drops the disjointness tracking is killed
T-P4cancho-gpu matrix product over rowsa measured speedup on the interpreter’s gemm at 128³ against the one-thread time on a machine with that many cores, reported with the core count; the claim is parallel efficiency, and a number below ~70% at four threads is written up as the result

6. Open

QuestionWhy it waits
The disjoint splitneeded by T-P3; slicing.md §7 leaves split_at open as a convenience, and this is the argument for promoting it. It does not exist as a checked guarantee today
Thread count and poolsthreads.md §4 says a pool is spawn/join in a loop; with cheap payloads this becomes measurable, not before
join returning a tupleonly if a program shows a result that cannot be lent memory

Atomics, and a channel built on them

Status: design, with the measurements it needed. No compiler change. threads.md §4 and parallelism.md T5 say atomics and channels are “not started until a program asks”. A program now asks: a parallel CSV scan in cancho-table (a dynamic work counter between workers, and results delivered in order) and a possible network broker. The question that came with the request was is there a hard constraint, or did nobody ask? §1 answers it with evidence, and the answer is nobody asked, plus four real constraints that shape the design but block nothing. The most important thing this document found is not about atomics: the checker does not stop two threads writing one value today (§2, measured), and threads.md §3 said it did. That is corrected in place.

What this decides (each with its trade-off in the section that makes it):

decision§
the typeone res type Atomic: a heap block of n 64-bit words, reached through &Atomic; no plain access exists3
operationssequentially consistent only: load, store, fetch_add (wrapping), then exchange and cas4
effectsthe existing label conc; creation is [heap]; no new capability5
blockinga separate, later step (wait/wake), and the channel ships without it first6
the channela library over atomics, messages are ints; not a primitive6
the gatelitmus tests, a stress counter, nine lowering mutants and a model check, fixed before any code7
the costedition 8; four stages A0-A3, each with its own pass/fail line8

Measured on: Apple M-series arm64 macOS (compiler built from origin/main at f7e7307) and an Intel i7-1260P x86-64 Linux box, 16 threads, cores 0-5 only, niced (the Linux compiler there is an older rev, 4c27593, built before editions 7; the programs used are edition 6). Every number says which. A claim that was read and not run says read, one that was neither says assumed.


1. Hard constraint, or nobody asked?

1.1 It is not a hard constraint: what already exists

facthow known
The compiler already emits an atomic. spawn/join keep a count of unjoined threads with one atomic add (count_thread, cancho-codegen/src/body/signals.rs; cancho-codegen-llvm/src/body/signals.rs). It has been in every threaded binary since #128measured: cancho build tests/accept/spawn_join.cho --emit obj disassembled: Cranelift on arm64 emits ldaxr/stlxr; LLVM --target x86_64-unknown-linux-gnu emits lock xadd, --target aarch64-unknown-linux-gnu emits ldaxr/stlxr
Both backends have all five operations, sequentially consistent. Cranelift 0.121.2: atomic_load, atomic_store, atomic_rmw (with Add, Xchg and the bitwise ops), atomic_cas, fence; its own documentation says each is “sequentially consistent and creates happens-before edges that order normal (non-atomic) loads and stores”. LLVM: load atomic, store atomic, atomicrmw, cmpxchg, all seq_cstCranelift: read (cranelift-codegen-meta 0.121.2 instructions.rs, and the x64 and aarch64 lower.isle). LLVM: measured on a hand-written module, §4.2
A shared reference already crosses to several threads at once. One &r Ffi is the payload of two spawns and read a third time by main after both joins (tests/accept/spawn_thread_ids.cho); crosses_to_a_thread admits any non-slice Type::Ref, so &Atomic needs no change to the payload ruleread, and spawn_thread_ids.cho is a conformance test
A struct with a Box[[byte]] and an Ffi field crosses by &r to four threads, which read through it.measured, benches/atomics/libatomic_counter.cho, §1.3
Atomic’s memory is one malloc, so it is aligned. The one atomic the compiler emits had a latent alignment bug (a zeroed global, threads.md §6, SIGBUS on aarch64 Linux); a malloced cell does not have itread

Nothing in the language, the ABI, either backend or either OS forbids an atomic. The wall threads.md §1 found for pthread_create (a void * that cannot carry a region) does not apply: an atomic operation has no callback and no foreign pointer.

1.2 What is real: four constraints that shape the design

These are not reasons to refuse atomics. Each changes what the design has to be.

  1. A borrow copies. borrow x as &r in { .. } spills x to a buffer and points r at the buffer; a unique borrow writes it back at the close, a shared one writes nothing back (ir.rs, Stmt::Borrow, read). Put an atomic value in a local and borrow it shared, and each borrow gets its own copy: two threads would increment two cells, and the owner would never see either. So the cell cannot be the value; it has to live at a stable address, and the value that is borrowed is a handle to it (§3).
  2. A struct cannot name a region (aliasing.md §3, route 2: “a type declaration takes no region parameters”). So a per-worker struct cannot hold a &r Atomic, and Atomic is res, so one cannot be handed to each worker by value. Together with the one-leaf payload (threads.md §2), a worker gets one pointer. That is enough for the counter and the ordered slots (§9.2) because everything shared goes in one res struct Shared passed as &r Shared. It is not enough for a worker that needs both a shared &r Shared and its own &!r Job: that is thread-payloads.md’s tuple payload (T-P1), and this document does not pretend atomics replace it.
  3. &! does not mean unique, so the checker is no help with plain shared memory (§2).
  4. A new type and new builtins cost an edition (editions.md), and a hash check (§8).

1.3 “Write it in your program first” (AGENTS.md §7): possible for a counter, on Linux, and no further

AGENTS.md says a feature earns its way in when a program asks, and that you write it in your program first. For atomics that is nearly impossible, and how nearly is worth a measurement: a program has no way to write an atomic operation, because __atomic_* are compiler intrinsics, not libc functions. The one exception found: libatomic (GCC’s runtime, on Linux) exports __atomic_fetch_add_8. Only a [byte] slice crosses a foreign boundary (the checker’s own message: “the only references that cross a foreign boundary are a borrowed capability and a [byte] slice”), and it crosses as a pointer and a length, which are the first two C arguments. So a slice of 8 bytes is (p, 8): an __atomic_fetch_add_8(p, 8, order).

benches/atomics/libatomic_counter.cho: four threads each add 8 to one 8-byte heap cell a million times through a shared &r Shared, with Ffi("atomic") and -l atomic. Measured on x86-64 Linux, both backends: the answer is exactly 32,000,000 on every run, in 0.05-0.08 s for the four million contended increments (about 12-20 ns each, thread start included). It works, so no hard constraint exists even today. It is not an answer: it is Linux only (macOS has no libatomic), it needs Ffi, which reach.md says is every authority at once, it adds only v = len, it cannot load, store or compare, and the “increment” is a call through the PLT. It is a stopgap for cancho-table’s one counter and nothing more, and it is the reason a second asker cannot be satisfied by writing it first.

1.4 What the askers need

askerneedsatomics alone cover it?
cancho-table: workers claim chunks of a CSV from a shared counterfetch_add on one cellyes (A0)
cancho-table: results come back in chunk orderone ready word and one result word per chunk, a consumer that reads them in orderyes for a small result (a count, an aggregate), A0; no for rows (§9.2)
a broker: handlers fed by an acceptoran MPSC queue, blocking, and a way to wake a thread that is blocked in the Pollerqueue yes (A3); waking a poller no, §6.4
cancho-cache (thread-payloads.md §4)a shared storeno: needs shared mutable memory, not a word

cancho-table’s repository holds no threaded code and no design for it today (checked: nothing in its docs/ or README.md mentions a thread or an atomic); the ask is the request that came with this document, not a program. AGENTS.md’s bar of a second program is met in the sense that cancho-cache named the same absence in writing.


2. The hole: nothing stops two threads writing one value

Closed for spawn since this was written (aliasing.md §6.1). spawn lends the &! it is given until join, so the program below is refused (tests/reject/spawn_two_copies_of_unique.cho); the join-first control is accepted (tests/accept/spawn_unique_join_first.cho). benches/atomics/race.cho and sb.cho no longer compile: they are kept as the record of what was measured. Everything below describes the checker before that change, and stands as the measurement. It also means §7.1’s plain-access litmus test (sb.cho) cannot be rerun without a reference the checker still lets two threads share (§1.3’s atomics are exactly that, once they exist).

threads.md §3 argues that no new soundness rule is needed because “the aliasing rule … forbids a second writer while a reference is live … and a spawned thread joined before its region closes is just a second writer the checker already refuses to admit exists”. aliasing.md says the rule is not that: &! is a lock on the binding, &!r T is val and copies, and two copies are two copies of one pointer.

Put the two together and it is a program the checker accepts (benches/atomics/race.cho):

borrow mut c as &!r in {
    let a = r;  let b = r;               // two copies of one pointer
    let ta = spawn(a, fa);  let tb = spawn(b, fb);   // each adds 1 to c.n, 200,000,000 times
    ...
}

Measured: it compiles on both backends and loses updates. The answer is 400,000,000 and the program printed L (wrong) on every run, three runs of each backend on arm64 macOS and one of each on x86-64 Linux. The control that joins the first thread before spawning the second prints E on both backends. So this is a data race, in a language whose documents say it has none, reachable in four lines.

What this means for atomics:

  • It is not caused by atomics and atomics do not fix it. A program that wants safe shared mutation of an ordinary value still cannot have it. &! copies are the root, aliasing.md routes 2 and 3.
  • An Atomic is sound without the aliasing rule (§3.3). That is the point of making the cell opaque and every operation atomic: the type does not lean on the one rule that does not hold.
  • aliasing.md §6 named this: “Parallel lanes … the correctness argument, which today is nil … Two lanes writing through aliasing references is the race a checker should catch, and that is when this stops being about speed”. Threads arrived in #128; the condition is met and the third row of that table (provenance in signatures) is what a sound &! across threads needs. Not proposed here. A narrower fix that only spawn needs is possible (refuse to spawn with a &! that was copied: route 2 closed for the one call), and thread-payloads.md T-P3’s disjoint split is the same family. Both are separate designs.
  • threads.md §3, parallelism.md §2 and aliasing.md §6 are corrected in place by this change.

Why the hole is also a test fixture for this design: it is the cheapest way to show the litmus tests of §7 can fail (plain accesses, no atomics): benches/atomics/sb.cho is the store-buffering test written with plain accesses through that hole, and §7.1 reports what it measured.


3. The type

3.1 The decision

One new res type, Atomic: a heap block of n 64-bit words, each readable and writable only through the atomic operations.

atomic_new(h: &!x Heap, n: int, fill: int) -> [heap] Atomic      // n >= 1, or a trap; every word starts at fill
atomic_free(h: &!x Heap, a: Atomic) -> [heap] int                // the only consumer; answers n
atomic_len(a: &r Atomic) -> [] int                               // the length never changes
atomic_load(a: &r Atomic, i: int) -> [conc] int                  // i is bounds-checked, like a slice index
atomic_store(a: &r Atomic, i: int, v: int) -> [conc] ()
atomic_fetch_add(a: &r Atomic, i: int, d: int) -> [conc] int     // answers the OLD value; wraps

(Illustrative signatures, as threads.md §2’s were: builtins checked at the call site, like box_slice.) A cell is atomic_load(a, 0). Atomic is released like a Box: it is res, a program that never frees it does not compile, and atomic_free is its only consumer (the same unboxed_only pattern defs.rs already has for Box).

3.2 Why this shape, and the alternatives

verdict
AAtomic[int] / AtomicBool as a value type, borrowed &Atomicrejected on measurement of the compiler, not taste: a borrow spills the value and a shared borrow does not write back (§1.2.1), so every borrow would be its own cell. Fixing it means a rule “a type that is interior-mutable is borrowed by its own address”, which needs every such local to live in memory, a new lowering for locals and for struct fields, and a write-back on a shared borrow. That is real compiler work in both backends for a type whose first user (a counter and some slots) does not need it
B (chosen)a handle to heap memorythe handle is one pointer leaf; spilling it copies the pointer, so every borrow reaches the same cell. No change to borrow, to struct layout or to the spill. Cost: one malloc, one extra load per operation (the handle is read from the spill, then the cell), and a Heap to create it (so creation is [heap], which is honest: it allocates)
CAtomic as an array of words (chosen) against a scalar cellthe channel and the ordered slots both need many words, and one allocation of n words is cheaper than n handles (§9.2’s chunk table would be thousands of mallocs). A scalar is n = 1. Cost: every call takes an index (atomic_load(a, 0)), and a bounds check (one compare, the same as a slice index). The bounds check is not optional: an out-of-range atomic store is memory corruption
DAtomic[T] generic over int/bool/bytenot now. One width, 64 bits, int. A bool is 0 or 1, a flag is a word. A narrower atomic is a second layout and a second set of lowering arms for no asker. Atomic[T] can come later as an edition-gated widening (it is additive)
Ea plain static atomic (a process-wide counter)no. static data is read-only (compile-time-data.md); a global mutable word is ambient authority by another name, which the capability design exists to refuse. A program that wants one makes it in main and passes it

The trade-off to name: B costs an indirection on every operation that a scalar-in-place design would not, and needs a Heap where a counter in a struct would not. In exchange nothing in the checker’s borrow machinery or either backend’s layout changes. If a program shows the extra load matters, A is the thing to build, and §3.1’s signatures are unchanged by it: that is the reason to write the operations over &Atomic and not over &Box[..].

False sharing is the library’s problem, not the type’s: a channel puts head and tail sixteen words (128 bytes) apart (§6.3).

3.3 Why it is sound, and what it does to the rules

The argument does not use the aliasing rule (§2), which is the one that does not hold.

  1. No plain access exists. Atomic has no fields, cannot be destructured, and has no contents: nothing reads or writes a word except the operations above. Every one is atomic, so no data race on an Atomic is expressible whoever holds a &Atomic, however many copies of it there are, on however many threads.
  2. No use after free. atomic_free consumes the handle, and consuming needs the binding not to be borrowed. The borrow’s block cannot close before join (the existing escape check: a Thread[&r Atomic, R] that outlived r is reference-escapes-region, tests/reject/spawn_handle_escapes_borrow.cho, an existing rule applied to one more type). So no thread is running when the cell is freed. Nothing here is new.
  3. No out-of-bounds. The index is checked on every operation.
  4. No tear. Cells are 8-byte aligned (they come from malloc, which gives at least 16), operations are 64-bit, and MemFlags::trusted() in Cranelift (which asserts alignment) is therefore true. A test makes it a checked fact: §7.3, mutant 9.
  5. &Atomic copying is harmless by (1).

What it does to the existing rules:

  • It is the first interior-mutable type: the first thing a function can change through a shared reference. The convention “a function that takes only & references cannot change what it was given” stops being true for any function that receives a &Atomic. Two places use that convention, and both were checked rather than assumed:
    • The optimizer: is_pure (ir.rs, docs/purity.md) calls a function pure if its row is empty and no parameter reaches a unique reference. A &Atomic is not unique, so condition 2 would not catch fn get(a: &Atomic) -> [] int { atomic_load(a, 0) }, and the constant folder’s pure-call elimination could then merge two calls, turning while get(a) == 0 { } into a loop that never ends. The row is what prevents it: atomic_load carries conc, so the function’s performs is not empty and is_pure is false. This is the reason §5 chooses a label and not []. It is a gate (§7.3, mutant 7).
    • The backends: neither emits noalias, readonly or invariant for a reference (read: grep of cancho-codegen-llvm/src, and aliasing.md §5 for Cranelift), so no backend assumes a shared reference’s pointee is stable during a borrow.
  • It does not touch uniqueness. &! stays what aliasing.md says.
  • Linearity: Atomic is res, so a val struct cannot hold one (the existing “a val type may not hold a res” rule), and a res struct Shared that holds one is shared as &r Shared, whose s.cell is a &r Atomic.
  • Rc still does not exist. An atomic Rc needs a copyable pointer, and sharing.md §2 says none exists. This design adds no copyable pointer: a &Atomic is bounded by its region, like every reference.

3.4 Hash identities

hash-stability.md: a new node kind moves tags; a new name does not. Atomic and the builtins are names (like File in #65, which “added three prelude type names … no node kind, no tag moved”). Expected: no existing hash moves, because the whole feature is edition 8, absent from older files. Unknown until built whether adding names to the interner’s PRELUDE list (cancho-syntax/src/ast.rs) shifts any DefId an existing encoding reads. The gate for it is mechanical (§8, A0): the 35 golden fixtures of crates/cancho-id/tests/golden.rs unchanged, and cancho ids over every file in examples/ and tests/accept/ byte-identical before and after. If either moves, the change is withdrawn, because that is exactly the “rate” hash-stability.md measured.


4. Operations and memory ordering

4.1 Sequentially consistent only, first

Decision: every operation is seq_cst, with no ordering parameter. Reasons, with the numbers where there are some:

  1. One thing to get right and to test. Under SC every litmus outcome is decided by “is there an interleaving?”, which is exactly what §7 can enumerate. Weak orderings need a memory-model checker.

  2. Cranelift has no other kind. Its atomic instructions take no ordering and are all documented SC (read). A relaxed or acquire-release API would have to lower to SC there (stronger is always correct) and to something weaker on LLVM. Then the two backends differ in speed and, worse, the differential test between them loses its power: a weak-ordering bug that LLVM’s weak code exposes cannot be seen in Cranelift’s strong code, and the repository’s method (differential.md: two backends must agree) has nothing to compare.

  3. On the machines measured, SC costs almost nothing except one thing. One thread, 200 million operations, C (gcc 15.2 -O1 on x86-64 Linux; clang -O1 on arm64 macOS), per operation:

    x86-64 Linuxarm64 macOS
    store, seq_cst6.08 ns0.81 ns
    store, release0.34 ns1.15 ns
    store, relaxed0.37 ns0.83 ns
    fetch_add, seq_cst6.39 ns5.73 ns
    fetch_add, relaxed6.49 ns5.28 ns
    load, seq_cst0.37 ns1.92 ns

    So the only place weaker ordering buys anything on these two machines is an x86 store (an xchg instead of a mov: about 5.7 ns). Loads and read-modify-writes cost the same either way, and on arm64 nothing differs. (One thread, uncontended, a tight loop: this is the instruction’s cost, not a contended queue’s; the figures are not claimed for other CPUs, and ldar/stlr on other ARM cores may differ.) A channel does about two stores per message; 12 ns is far below the cost of the work a message carries.

  4. Weakening is additive. atomic_store_release and friends can be added later without changing the meaning of anything written now, which is why SC-first loses nothing.

What relaxed and acquire/release would add, and why not yet: (a) an x86 store 18 times cheaper (above); (b) counters and statistics that need no ordering at all (fetch_add relaxed on arm64 skips the barrier, saving under 10% in the table above); (c) a fence. Not yet, because no asker has a measured hot path where (a) matters, the differential argument in (2) is lost, and a wrong weak ordering is the kind of bug §7’s tests find least reliably (§7.1 on IRIW). The trigger to revisit: a program whose profile has a seq_cst store on its critical path above a few percent. That is a measurement a program can make with A0 in hand.

fetch_add wraps. The language’s + traps on overflow; wrapping_add is how a program asks for wraparound. An atomic read-modify-write that trapped would need a compare-and-swap loop plus a trap, and a trap in the middle of another thread’s observation of the cell is a half-done state. So atomic_fetch_add is the wrapping operation, and its name should say so in the documentation exactly as wrapping_add’s does (counters modulo 2^64 are the intended use). A trapping add is a library loop over cas (A1). Trade-off: a program that forgets and adds past 2^63 gets a negative number, not a trap.

4.2 How each lowers

opCranelift (0.121.2)LLVM
loadatomic_load.i64, aligned flags; x64: a plain mov (“x86-TSO … without the need for any fence”); aarch64: ldarload atomic i64 ... seq_cst, align 8 -> x64 movq (%rdi), %rax; aarch64 ldar
storeatomic_store.i64; x64: mov then mfence; aarch64: stlrstore atomic ... seq_cst -> x64 xchgq; aarch64 stlr
fetch_addatomic_rmw.i64 add; x64: lock xadd; aarch64: an ldaxr/stlxr loop, or the LSE instruction where the target allowsatomicrmw add ... seq_cst -> x64 lock xaddq; aarch64 ldaxr/stlxr loop
exchangeatomic_rmw.i64 xchgatomicrmw xchg -> x64 xchgq; aarch64 ldaxr/stlxr
casatomic_cas.i64: answers the old value, x64 lock cmpxchg, aarch64 a loopcmpxchg ... seq_cst seq_cst -> x64 lock cmpxchgq; aarch64 ldaxr/cmp/stlxr

What was verified and what was assumed:

  • Measured: the existing fetch_add the compiler already emits, on Cranelift (arm64 host: ldaxr/stlxr) and on LLVM (x86-64 and aarch64 targets: lock xadd, ldaxr/stlxr), by disassembling spawn_join.cho. The whole LLVM column, including the x64 and aarch64 instructions, by compiling a module of the five operations with clang -O2 for both triples (baseline CPUs, no LSE).
  • Read, not run: Cranelift’s x64 column (the lower.isle rules and their comments) and aarch64’s ldar/stlr rules. The Cranelift x86-64 object was not disassembled: --target needs LLVM (Cranelift emits for the host), and the Linux box’s compiler is the older rev. A0’s first task is to disassemble it.
  • Assumed: that the LSE instruction forms are used when a target enables them (default off here), and that Cranelift’s alias-analysis pass (redundant-load elimination) treats an atomic_load as a barrier. This is the one that matters: if it does not, two atomic loads of one cell in a loop could be merged. §7.3 mutant 8 is that test.
  • A hazard visible in the table: on x86 an SC load and a plain load are the same instruction (movq). So a mutant that lowers atomic_load as a plain load is invisible to a litmus test on x86 and invisible in the object code; only the optimizer’s behaviour distinguishes them. That is why §7.3 has an emission test and a spin-loop test and does not rely on litmus tests alone.
  • fetch_add on aarch64 without LSE is a loop and can fail to make progress under extreme contention in theory (LL/SC); the stress test (§7.2) measures, it does not prove.

cas answers the old value, not a bool: success is old == expected. A tuple (bool, int) is available (tuples.md) and costs nothing, but then the builtin has two results; one answer keeps the signature -> [conc] int and the library writes cas(..) == expected.


5. Effects, authority and determinism

5.1 The label: reuse conc

Decision: every operation on a &Atomic carries the existing conc label; creation and freeing carry heap; no new label and no new capability.

  • Why a label at all (and not []): §3.3. [] means pure to the optimizer; an atomic read is not.
  • Why conc and not a new atomic label. conc is “concurrency entered the program’s authority surface … and no capability carries it” (wasi_imports.rs). An atomic is meaningless without a second thread and the pair is always used together. Reuse costs nothing in vocabulary and gets a refusal for free: conc is in REFUSED_LABELS for wasm32-wasip1, so a program that uses an atomic is refused on WASI with the located refusal threads already get (tests/reject/spawn_on_wasi.cho), with no new code. A single-threaded atomic on WASI would work but is pointless. The cost: conc now also tells a reader “shares mutable cells”, and a supervisor cannot distinguish “may spawn” from “may share state”. Nothing needs the difference today (a grant of threads without shared state has no meaning: a thread with no way to share is shared-nothing already). An atomic label is additive and can be split out in a later edition; the cost then is one migration of every library that returns [conc].
  • No capability. The authority to touch a cell is holding a reference to it, the same as for a &! buffer. A thread given no &Atomic cannot reach one; no operation names a cell by anything but the reference.

5.2 What cancho authority should say

Today a threaded program reports performs conc, bounded: true. Unchanged, with one text change: the line should say what the label now means. Suggested wording for the report: conc: runs threads, and may share cells between them. The JSON ("effects": ["conc"]) does not change, so no supervisor parses anything new. Unknown: whether lex-os grants key on conc today and what an Atomic’s [heap] creation does to a grant that allows conc but not heap (it would have to allow both). lex-os was not read for this document.

5.3 Determinism

Shared mutable state is what defeats a byte-stable output: which worker takes which chunk, and when, depends on the scheduler. The discipline, stated as a rule the library can obey and a test can check:

An atomic is for coordination, never for content. What a program emits must be a function of the input alone. Work is claimed in whatever order the scheduler gives; results are merged in a fixed order (by chunk index, not by completion time).

The ordered slots of §9.2 are that rule: any worker may compute chunk k, the consumer reads chunk 0, then 1, then 2, so the output is the same bytes for 1 worker and for 8. It is checkable (§8, A3: byte-identical output across worker counts and 200 runs each). What is not deterministic and cannot be made so: the claim order, timing, and which thread ran what. A program that prints a thread number or uses the value a fetch_add returned as data (not as an index into slots) has opted out, and nothing will say so.


6. Blocking, and a channel as a library

6.1 A channel needs no kernel support to be correct

A bounded queue over atomics (§6.3) is correct without blocking: try_send and try_recv never wait, and a caller that must wait retries with a backoff. So the channel (A3) can ship before any wait/wake builtin (A2), and A2 becomes a measurement (“how much latency and CPU does the retry loop cost?”) and not a prerequisite. This is the order chosen below; the user’s suggestion that blocking is needed for a bounded channel is true only for a channel that must not burn CPU while empty.

What a retry loop needs that does not exist as a builtin: a sleep. Read: grep of builtin.rs finds no sleep or yield; spawn_parallel_sleep.cho reaches usleep through Ffi("libc"). A Poller wait with a timeout and no descriptors might serve as a sleep; not tried. A pure spin works and burns a core.

6.2 wait/wake (A2), what it costs

atomic_wait(a: &r Atomic, i: int, expected: int, timeout_ms: int) -> [conc] int
atomic_wake(a: &r Atomic, i: int, n: int) -> [conc] int           // wakes at most n waiters, answers how many
  • Linux: futex(FUTEX_WAIT_PRIVATE / FUTEX_WAKE_PRIVATE) through syscall(2), called by the compiler (as pthread_create is: the program never names it, threads.md §1). The syscall number differs per architecture (x86-64 202, aarch64 98; from memory, to verify). A futex word is 32 bits. The cell is 64. The wait compares the low 32 bits only (little-endian, same address): a value that differs from expected only in the high half would sleep when it should return. The contract: states a waiter distinguishes differ in their low 32 bits; the channel’s sequence words are chosen so (and wrap in the low half at 2^32 messages, which the library must tolerate).
  • macOS: __ulock_wait/__ulock_wake (private but long-stable: used by the C++ and Rust runtimes; assumed, not checked here) or os_sync_wait_on_address (public, macOS 14.4 and later; assumed). Which one, and the minimum macOS the compiler’s output may then require, is A2’s first question; a private symbol in generated code is a policy decision, not a technical one.
  • A portable fallback needs nothing from the kernel: sleep-with-backoff in the library (§6.1). It costs latency (the sleep quantum) and is the right answer if A2’s measurement says the futex is not worth a private symbol.
  • Spurious wakeups are normal and the contract says so: atomic_wait may return when the cell has not changed. It answers a code (0 woken or spurious, 1 value already differed, 2 timed out); the caller always re-checks, in a loop, and a library that does not is wrong.
  • EINTR is the same case: it returns as “re-check”. Signals are blocked in the thread by the signal claim (signals.md §5: pthread_sigmask), so it should be rare; not measured.
  • No cancellation, as threads.md §4 says of join. The only way to ask a blocked thread to stop is to wake it with a shutdown word set, and a timeout so a thread that is not woken still looks. A wait with no timeout and no waker is a deadlock the type system does not see.

6.3 The channel, as a library

Not a primitive: a std module (std.chan) once a second program needs it (AGENTS.md §7), written in cancho-table first. Messages are ints (a pointer-width leaf, like a thread’s payload): the int is an index, a handle, a count. A message that is a buffer is an index into memory the receiver’s side already owns and lent to the sender; moving a Box through a queue needs join-style Box crossing (parallelism.md §8.6, “not expressible yet”).

A bounded multi-producer, single-consumer ring, after Vyukov’s bounded queue (each slot has a sequence word; a producer claims a position with a CAS, publishes with a store). Sketch, not compiled; the syntax is illustrative:

// one Atomic block: [0] head, [16] tail (128 bytes apart, against false sharing), [32] closed, then
// slots: slot k is two words at 48 + 2k: [seq, value].  seq starts at k.
res struct Chan { cells: Atomic, cap: int }

fn try_send[&r](c: &r Chan, v: int) -> [conc] bool {
    loop {
        let pos = atomic_load(c.cells, 0);
        let at  = 48 + 2 * (pos % c.cap);
        let seq = atomic_load(c.cells, at);
        if seq == pos {                                          // the slot is free for this lap
            if atomic_cas(c.cells, 0, pos, pos + 1) == pos {     // claimed
                atomic_store(c.cells, at + 1, v);
                atomic_store(c.cells, at, pos + 1);              // publish
                return true;
            }
        } else if seq < pos { return false; }                    // full (a lap behind)
        // else another producer is ahead: retry
    }
}

fn try_recv[&r](c: &r Chan) -> Recv {                            // Got(int) | Empty | Closed
    let pos = atomic_load(c.cells, 16);                          // one consumer: no CAS needed
    let at  = 48 + 2 * (pos % c.cap);
    if atomic_load(c.cells, at) == pos + 1 {
        let v = atomic_load(c.cells, at + 1);
        atomic_store(c.cells, at, pos + c.cap);                  // free the slot for the next lap
        atomic_store(c.cells, 16, pos + 1);
        return Got(v);
    }
    if atomic_load(c.cells, 32) == 1 { return Closed; }          // closed AND empty
    return Empty;
}

send/recv are loop { try_*; backoff } first and atomic_wait later. SPSC is the same without the CAS.

What this shows about the primitive: the channel needs load, store, cas and nothing else; no fence, no weaker ordering, no fetch_add. Under SC the publish order (value before seq) is enough.

What a channel library cannot do (limits of the design, not bugs): carry an owned value (the paragraph before the sketch), carry more than a word, select over several channels, or be waited on together with a socket (§6.4).

6.4 The broker’s real problem is not the queue

A broker’s handler threads block in a Poller on sockets. A producer thread that enqueues a message must wake a thread blocked in poller_wait, and a futex on a cell does nothing for that thread: it is not waiting on the cell. The usual answer is a descriptor the poller also watches (an eventfd, or a pipe: processes.md has Pipe) that the producer writes to. Not designed, not measured; it is a use of Net and Pipe, not of atomics, and it is named so that A2 is not mistaken for the broker’s blocker.


7. The verification gate, fixed before the build

Everything here is written before any code, so a result cannot be argued into passing. Each item says how it fails.

7.1 Litmus tests

Each runs on Cranelift and LLVM, on macOS arm64, Linux x86-64 and Linux aarch64 (the third is the Docker arm64 image the project already uses on the Mac, native, not emulated). A trial resets the cells, passes a start barrier built from Atomic itself (a counter both threads increment and spin on), runs the two code sequences, and records the pair.

testcode (every access an Atomic operation)forbidden under SCtrials
SB store bufferingA: x=1; r1=y B: y=1; r2=xr1 = 0 and r2 = 0100 M
MP message passingA: data=1; flag=1 B: r1=flag; r2=datar1 = 1 and r2 = 0100 M
MP-plainas MP but data is non-atomic memory written by A before flag=1 and read by B after seeing flag (the channel’s lent-buffer case, which depends on “creates happens-before edges that order normal loads and stores”)r1 = 1 and r2 = 0100 M
LB load bufferingA: r1=x; y=1 B: r2=y; x=1r1 = 1 and r2 = 1100 M
CoRR coherenceA: x=1 B: r1=x; r2=xr1 = 1 and r2 = 0100 M
IRIWA: x=1 B: y=1 C: r1=x; r2=y D: r3=y; r4=xr1=1,r2=0 and r3=1,r4=010 M
2+2WA: x=1; y=2 B: y=1; x=2final x=1 and y=1100 M

The pass line: zero forbidden outcomes in every cell of the matrix. What zero means, honestly: 100 million trials with none observed bound the per-trial probability by 3e-8 at 95% (the rule of three), and say nothing about schedules the machine did not produce. It is evidence, not a proof, and the document will not call it one.

Honest about IRIW: x86-TSO and ARMv8 are both multi-copy atomic, so IRIW’s forbidden outcome cannot occur on any of the three platforms even with weak lowering; the test has near-zero power there and is kept for POWER and RISC-V and as a compile-time sanity check, not as evidence. The same is true of LB on x86. The tests with real power on these machines are SB (x86 and arm), MP and MP-plain (arm; x86 only through compiler reordering), CoRR and 2+2W (compiler).

Power check, which is the point of the gate (the tests must be able to fail). The whole matrix is re-run with the accesses written plain, and it has to see the forbidden outcome. Measured so far, with plain accesses through the hole of §2 (benches/atomics/sb.cho: 3,000,000 trials per run, no barrier, three runs each):

CraneliftLLVM
arm64 macOS87,632 / 92,939 / 94,205166,509 / 202,293 / 236,141
x86-64 Linux128,808 / 0 / 307,986242,583 / 97,792 / 59,147

Later reruns on arm64 gave 185,344 (Cranelift) and 254,134 and 293,499 (LLVM). So SB’s forbidden outcome shows on both machines and both backends, in 2-10% of trials, and with no barrier it is noisy: one x86 Cranelift run saw none (the threads happened not to overlap). That is the argument for the barrier, and for the pass line “the plain version shows at least one forbidden outcome in its first 10 million trials on every cell, or the test is declared too weak and fixed before the atomic version counts”. This table measures the race, not any atomic: there are no atomics yet. It is what the gate will be compared against.

7.2 Stress: a counter

threads x 10 M fetch_add(1) on one cell; the final value must be exactly threads * 10 M. 2, 4, 8 and 16 threads (cores 0-5 on the Linux box), repeated 50 times, both backends, all three platforms. Then the same with cas in a retry loop (A1), and with the two cells on the same and on different cache lines (the stress finds nothing about false sharing; the timing does, and is reported, not gated). Pass: exact, every run. Control, measured: the same counter with plain adds loses updates (race.cho, 400 M expected, L on every run).

7.3 Mutants: each must be killed

A mutant is the lowering deliberately wrong in one way. Each is a change to the backend run against the suite; the suite must fail. A mutant that survives means a test is missing, and the stage is not done.

#mutantkilled bycaveat
1atomic_load lowered as a plain loadthe emission test (the LLVM text must contain load atomic; the Cranelift IR/object must contain atomic_load/ldar) and the spin test (while atomic_load(flag, 0) == 0 {} must terminate; LLVM at -O2 hoists a plain load and loops forever)invisible to litmus tests and to the object code on x86, where both are movq (§4.2). The emission test is the only thing that sees it there
2atomic_store as a plain storeSB (x86: mov instead of xchg/mfence; arm64 str instead of stlr)
3fetch_add as load; add; storethe stress counterthe loss rate is in race.cho’s control
4cas that stores even when the compare failsthe CAS stress and a single-thread unit test
5exchange as a store (drops the old value)a single-thread unit test: the old value is returned
6seq_cst weakened to monotonic in the LLVM textSB on x86 and arm64 (plain-shaped stores)detectable only if SB sees it, §7.1’s power check
7the folder treats an atomic call as pure (performs ignores conc on an atomic builtin)the spin test through a fn get(a: &Atomic) -> [conc] int wrapper, and a fold/purity unit test§3.3
8two atomic_loads of one cell merged by redundant-load elimination (Cranelift’s alias analysis)the spin test on Craneliftthe one assumed fact of §4.2
9a cell not 8-byte aligned (an offset allocation)a unit test on atomic_new plus an aarch64 run (SIGBUS, threads.md §6)

7.4 A model check of the channel

SC-only makes an exhaustive check feasible, which is a reason for §4.1: with SC the behaviours of a program are exactly its interleavings of atomic operations, so a depth-first search over them is complete, where loom must also model weak orderings. The channel’s functions (§6.3) are loops of atomic operations over a few cells; a model checker (a throwaway script under scripts/) enumerates every interleaving of 1 and 2 producers and 1 consumer, capacity 1 and 2, three messages each, and checks: no message lost or duplicated, per-producer order preserved, occupancy never above cap, Empty/Full never returned wrongly, no state where every thread is blocked and not finished.

Honest limits. It checks a model, and a model drifts from the code. Two mitigations, both part of the gate: the model is extracted mechanically from std/chan.cho (the functions are restricted to straight-line atomic operations, loops and arithmetic, so this is a small parser, not an interpreter), and the real compiled code runs under a test-only scheduling mode that yields at every atomic operation with a seeded choice, replaying 10,000 seeds, whose observable results must be a subset of the model’s. Whether the mechanical extraction is as small as claimed is unknown until A3; if it is not, the fallback is a hand transliteration plus the replay check, and the document says the check is weaker. The scheduling mode is a compiler flag, so it is itself A3 work.


8. Edition, and the stages

8.1 What it costs

Edition 8. Atomic and the builtins are additive names, absent from an older file (editions.md‘s table of additive and refining changes: “the new thing is absent in older editions. Exact, with no tool and no second meaning”), like fork_clock (edition 5) and exec (edition 7). Costs, all one-off: the parser’s range (1..=7 and its message in parser/items.rs), the PRELUDE names, the builtin table, both backends’ arms, docs/editions.md’s list, cancho introspect. A file that declares edition 8 hashes differently from its edition-7 text (editions.md §6.3), which touches only the files that adopt it: std/chan.cho and the programs that use it. Rejected: shipping A0 inside edition 7: edition 7 is closed (editions.md §6.4, the freeze: new names go into the next edition) and a name added to it could shadow a user’s own Atomic.

8.2 The stages

stagewhatgate (can fail)
A0the type, atomic_new/free/len, load, store, fetch_add; both backends; edition 8; the reject fixtures; the litmus harness itself (it needs only these three)all of: (1) tests/accept: the work counter and the ordered slots of §9.2 agree on both backends; (2) SB, MP, MP-plain, LB, CoRR, 2+2W zero forbidden, in 100 M trials, every cell of the matrix (IRIW 10 M); (3) the power check: plain versions see the forbidden outcome in 10 M trials on every cell; (4) stress: 2/4/8/16 threads x 10 M exact, 50 runs; (5) mutants 1, 2, 3, 6, 7, 8, 9 killed; (6) rejects: a plain read or write of an Atomic’s word (none exists: a fixture that tries unbox/contents/destructuring), atomic_free while borrowed (linear), a Thread[&r Atomic, R] escaping its borrow (reference-escapes-region), a val struct holding one, an out-of-range index (trap); (7) the 35 golden hashes and cancho ids over examples/ and tests/accept/ unchanged; (8) cancho authority reports conc, and a WASI build is refused; (9) the Cranelift x86-64 object disassembled (§4.2’s read). Performance: uncontended fetch_add within 1.3x of C’s __atomic_fetch_add seq_cst in the same loop, on both platforms; if not, say so and say whether the indirection of §3.2 is why
A1atomic_exchange, atomic_cas (answers the old value)CAS-increment stress, 2-16 threads x 10 M, exact; mutants 4 and 5 killed; a lock-free stack or the ticket of §9.2 built on it passes its own model check (§7.4)
A2atomic_wait/atomic_wakeonly if A3’s retry-loop measurement asks for it. A ping-pong of 1 M handoffs between two threads over wait/wake beats a sleep-backoff loop’s latency by a measured margin, never loses a wakeup in 100 M handoffs (a hang is the failure, a timeout is the detector), and survives 10 M spurious wakeups injected by a test mode. The macOS symbol question (§6.2) is decided and recorded before any code. If it is not decided, A2 is not built and the channel stays on backoff
A3std.chan (or the package), written first in cancho-tablethe §7.4 model check of the channel passes on all configurations, and the 10,000-seed replay agrees; 8 producers x 1 consumer, 100 M messages, none lost, none duplicated, per-producer order kept; the parallel CSV scan’s output is byte-identical to the single-thread scan’s for 1, 2, 4 and 8 workers, 200 runs each, and 8 workers are at least 2.5x faster than one on at least 6 cores (a measurement, reported with the core count as parallelism.md §3.4 does; below that, it is written up as the result and the design is questioned)

A0-A1 need no second program (the counter is cancho-table’s). A3 moves to std only when a second program wants it (the broker would be the second). The order A0 -> A1 -> A3 -> (A2 if measured) is deliberate: A2 is the only stage that adds a kernel dependency, so it waits for a number.


9. What this does not solve, and what to do instead

9.1 Not solved

  • The hole of §2. Plain shared mutation across threads remains unchecked. Atomics are a safe way to share one word, not a safe way to share data.
  • Shared mutable data structures (a hash map two threads insert into, cancho-cache’s store). A word is not a table. That needs either a lock (threads.md §4’s “Mutex-shaped capability”, not designed) or an algorithm built from CAS, which is a library (and a hard one) and is not scheduled.
  • Messages that own memory. join refuses a Box result, so a channel carries an int and the buffer it names belongs to someone who stayed put.
  • A worker that needs its own mutable state and a shared one. One pointer per thread (§1.2.2). Per-worker scratch can come from regions opened inside the thread (a region’s arena is private to the thread that opens it, parallelism.md §8.1; not tried with a spawned thread here). A worker that must return structured state to its parent needs thread-payloads.md’s tuple payload or a wider join.
  • Disjoint writes into one buffer (thread-payloads.md T-P3): atomics do not make two &! slices safe.
  • Poller wake-up, select, cancellation, a thread pool, async. §6.4, threads.md §4.
  • Ordering weaker than SC, and atomics narrower than a word, and atomic operations on non-int data.
  • Deadlock and livelock freedom. A wait loop that never ends is a program the type system accepts.
  • Windows, and 32-bit targets (no 64-bit atomics guarantee). Not supported targets today.

9.2 What a user should do now

Shared-nothing workers merged in order, which needs no atomic and works today: threads.md §5 step 2, T0 (parallelism.md §4). Give each worker a static slice of the input, one &!r Job struct each (one function value per spawn, parallelism.md §3.4), join the workers in order and merge. The cost is load imbalance: a slow chunk holds one worker while the others finish. Over-partition (more, smaller jobs than workers) and spawn per job: thread start is a pthread_create, whose cost is not recorded in this repository (threads.md §4 says measuring it is part of §5 and it was not done); measure it before choosing the chunk size. Processes (parallelism.md §3.5) share nothing and have been measured to scale (25,180 a second for three processes against 15,638 for one, cancho-web). For the single work counter on Linux only, the libatomic stopgap of §1.3 works today.

The CSV scan with A0, as a design to try (not built; cancho-table writes it first):

res struct Shared { data: Box[[byte]], next: Atomic /* word 0: next chunk */, ready: Atomic /* word k: chunk k done */,
                    result: Atomic /* word k: its aggregate */, nchunks: int }

worker(s: &r Shared):    // the only payload: one pointer
    loop { k = atomic_fetch_add(s.next, 0, 1); if k >= s.nchunks { return 0 }
           v = scan(contents(s.data), chunk_bounds(k));     // scratch in a region opened here
           atomic_store(s.result, k, v); atomic_store(s.ready, k, 1) }

main: spawn N workers; for k in 0..nchunks { spin/backoff until atomic_load(s.ready, k) == 1;
                                              merge(atomic_load(s.result, k)) }; join all

No cas, no wait, no channel: A0 alone covers a scan whose per-chunk result is a number. The merge is in chunk order, so §5.3’s discipline holds. A scan whose result is rows needs a per-chunk output buffer written by one worker and read by main: that is a unique buffer lent to a thread, which thread-payloads.md T-P3 blocks. Until that exists, the rows version has no safe design, and the aggregate version is what to build.


10. Open questions

questionwhy it is open
Does adding names to PRELUDE move any existing hash?Expected not (§3.4); only building tells. A0’s gate (7) decides, and withdraws the change if so
Does Cranelift’s alias analysis treat atomic_load as a barrier?Assumed (§4.2). Mutant 8 is the test; if it fails the lowering must wrap each atomic load in something the pass respects
Cranelift on x86-64: the exact instructionsRead, not disassembled (§4.2). A0’s first task
macOS wait/wake: __ulock_* or os_sync_*Assumed, not checked (§6.2). A2’s first task, and a policy question (a private symbol)
What does lex-os do with conc and with [heap] at creation?Not read (§5.2)
Does a per-thread region work inside a spawned thread?parallelism.md §8.1 says the arena is private; no fixture
pthread_create’s costNot recorded anywhere here; it decides the chunk size of §9.2’s shared-nothing fallback
Is Atomic’s extra indirection (§3.2) ever worth removing?Only a program that measures it can say; A is the answer if so
Should the aliasing hole of §2 be closed for spawn first?Done, aliasing.md §6.1. Atomics were unaffected by it
Is x86 SC-store cost (6 ns, §4.1) ever on a program’s critical path?Unmeasured outside the C microbenchmark; the trigger for weaker orderings
Could the work counter be a Clock-style capability, so a program must be granted shared state?Considered and not taken (§5.1: the reference is the authority); revisit if lex-os wants to deny it

Files

benches/atomics/race.chothe data race of §2 that the checker accepts
benches/atomics/sb.chostore buffering with plain accesses: what the litmus gate must be able to see (§7.1)
benches/atomics/libatomic_counter.chothe Linux-only counter stopgap of §1.3

Effect polymorphism: answered, and the answer is that there is nothing to be polymorphic over

Status: a documented no, and the shortest one yet.

ROADMAP.md’s last row says “a function generic over the row it performs. Named nowhere yet, and much larger than anything above”, and mode-polymorphism.md §8 repeats it word for word. Those two lines are the entire design, which made it the only language row with no document behind it.

Counted by reading, the way line-reading.md §1 counted its askers: 537 functions in this repository write a row, and not one of them could be polymorphic in it. Not “does not need to be” — could not, because a row here is fixed at the declaration and nothing a function is generic over can change which callee it reaches.

Effect polymorphism is a feature about higher-order code. This language refuses function values by rule (§2), so the thing it would abstract over does not exist.


1. What the corpus writes

Every fn in std/, examples/ and tests/accept/ with a written row:

functions with a written row537
of which declare []298
distinct non-empty rows19
generic functions carrying a non-empty row239

And the nineteen are not spread out:

 146  [io_write]
  37  [heap]
  19  [heap, io_write]
  15  [ffi("libc")]
   3  [io_read, io_write]
   2  [err_write]
   …

Four rows account for 217 of the 239 non-empty ones — 91%. A feature that lets a function be generic over its row would, in this repository, be generic over a set of four things that every caller already knows.

That alone is not an argument: slicing.md was worth building on one program’s need. §2 is the argument.


2. A row cannot vary, and that is by construction

Signature.effects in crates/cancho-ir/src/defs.rs carries its own reason:

The declared effect row (§7.2): written at the boundary, never inferred across one. A caller is checked against this and never against the body that justifies it.

and a caller reads it straight off the declaration — Resolved::Fn(index) => f.signatures[index].effects.clone(). Not off an instantiation. So for a row to vary, something a function is generic over would have to change which callee its body reaches. The two things a cancho function is generic over are:

  • type parameters, which monomorphisation substitutes into types. They do not select a name: a generic body calls the functions it names and no others.
  • region parameters, which are lifetimes and reach nothing at all.

There is no third. No traits, no dispatch on a type, no function values — README.md’s non-goals rule out the first two by name, and the third is a rule with a fixture:

`f` is a function; M1 has no function values, so it can only be called

(Rule::NoFunctionValues, tests/reject/function_as_value.cho.)

So there is no expression in this language whose effect row depends on anything. A feature for writing down that dependence has nothing to write down.


3. The nearest thing to an asker, and why it is not one

std.io has this pair:

pub fn write_all[&r, &i](io: &!i Io, s: &r [byte]) -> [io_write] int {
    return write_bytes(io, s);
}

pub fn error_all[&r, &i](io: &!i Io, s: &r [byte]) -> [err_write] int {
    return write_err(io, s);
}

Two functions, same parameters, same body shape, different rows. If anything in this repository wanted to be row-polymorphic it is this.

It does not, and the reason is the useful part: they differ by the builtin they call as well as by the row. write_bytes and write_err are two functions, so collapsing these two wrappers needs a way to be generic over which function to call — a function value — and the row would then follow from it for free. Row polymorphism is the shadow of higher-order code, not a feature that stands without it.

The same is true of every other near-duplicate: a pair that differs in its row differs in its callee first.


4. What would create an asker

Not a prediction — a list of the things that would have to arrive first, so the next reader can check rather than re-derive.

and then
Function valuesThe whole motivation. Excluded by Rule::NoFunctionValues and by README.md’s non-goals, which rule out the trait-system machinery that usually carries them
A capability interface“Write to any sink” — the shape file-handles.md §6 would create if a file_write landed beside write_bytes and write_err. That is polymorphism over a capability, and the row follows; it is a different feature with a different name, and §3 is the evidence it is the one actually wanted
A third streamTwo rows over one capability was enough for standard-error.md to add a label rather than a capability. A fourth or fifth might change the arithmetic; two did not

Until one of those exists, the row stays what linearity-and-effects.md §7.2 made it: written at the boundary, exact, and constant.


5. What this does not say

  • Not that the feature is wrong. In a language with closures it is how map avoids being written once per effect. The claim here is local and checkable: in this language, at 537 functions, there is nothing for it to quantify over.
  • Not that the row system is finished. reach.md §5 is a real gap — ffi("libc") is every authority at once, and 15 functions here carry it. That is about how coarse a label can be, which no amount of polymorphism over rows would sharpen.
  • Not a claim about inference. A body’s effects are already computed and checked against the declaration; what is fixed is the declaration, deliberately, so that a caller never depends on a callee’s body.

Command-line arguments

Status: settled, and built in the same change. filesystem.md §6 listed this as waiting on “a capability question answered first”. This is that question and its answer.

It is also what turns examples/lines.cho from a demonstration into a tool. That file currently writes its own input because it has no way to be told a path, which §5 there admits is “the one place the example is a demonstration rather than a tool”.


1. The question

Arguments are the second thing the runtime hands a program, and the first was a World. §8.2 is emphatic that there is exactly one of those and no other way to obtain authority — so either arguments arrive through the World, or §8.2 acquires an exception.

Three shapes were possible:

  • a second parameter to main — main(world: World, args: Args). It costs §8.2 its “exactly one” and buys nothing the other shapes do not;
  • ambient — an arg(n) builtin reaching argv with no capability at all, because arguments are data rather than authority;
  • a capability in Split, like every other thing the runtime provides.

This document takes the third, and §2 is why.


2. Reading argv is an effect

The tempting argument for ambient is that arguments grant no power. A program that reads argv cannot damage anything by doing so: it learns something, and learning is not authority. Fs still governs what it may open, Io what it may print.

That argument is about containment, and it is correct as far as it goes. The reason it does not decide the question is that this language’s effect rows are about visibility, not only containment:

A function whose behaviour depends on the command line should say so in its type.

Ambient argv would mean a function eight frames down could branch on --force with nothing in its signature, and nothing in any caller’s signature, saying that it does. That is exactly the property §7 refuses for the console — putchar is not more dangerous than arg, it is more visible, and that visibility is the whole point of an exact row.

So:

res Args                      // the command line

The sixth capability, carrying nothing, like Io and Heap — there is one command line and no part of it to name, so there is nothing to narrow. It is borrowed shared, like Ffi and Fs and unlike Io and Heap: reading argv changes nothing, and two readers at once are the same as one.

let Split { io, ffi, fs, heap, args } = split(world);

Which breaks every program in the tree for the fourth time. The note in §8.1 has said what that costs since Ffi was added, and the answer has not changed: it is the price of there being no ambient authority, and it is paid rather than avoided.

2.1 What this does not claim

Args is not a sandbox and does not pretend to be, in the same way filesystem.md §2.1 says Fs is not. A program holding Ffi("libc") can declare extern fn getenv and learn plenty about its environment without asking anyone. What Args buys is that the language’s own way of reading the command line is one a reader can see in a signature.


3. Two operations

arg_count(a: &r Args) -> [args] int
arg(a: &r Args, n: int) -> [args] &static [byte]

arg_count is argc and arg(a, 0) is the program name, exactly as the runtime was handed them. No translation: hiding argv[0] would be a convenience the program cannot see through, and a tool that wants to print its own name should be able to.

An index outside 0 .. arg_count traps, like indexing past a slice. It is the same kind of mistake and gets the same answer.

3.1 Why the region is static

An argument comes back as &static [byte], which deserves a word because strings.md §4 introduced static as “the region of data in the object file” and argv is not in the object file.

What Region::Static means to the checker is outlives everything, and that is precisely true of argv: the strings live in the memory the process was started with, and they are alive from entry to exit. There is no region they could fail to outlive. Reusing the region rather than inventing a second one that behaves identically is the honest move, and this paragraph is the documentation of it.

They are shared, never unique. Nothing in the program may write through one — a program does not own its own command line, and &!static is already refused everywhere else for the same reason.

3.2 No encoding, again

An argument is a run of bytes and claims nothing about what they mean, exactly as strings.md §1 says of every other string here. The operating system does not promise UTF-8 and this language will not pretend it does.

The bytes also carry no NUL: C hands over NUL-terminated strings and arg computes the length, so what a program gets is the pointer-plus- length pair every other string in this language is. The terminator is an artifact of the C interface, not part of the value.


4. What this unlocks

examples/lines.cho becomes a real tool: given a path it reads that file, and given nothing it falls back to the sample it writes itself — which is how a command-line tool behaves anyway, and keeps the example runnable by a test harness that passes no arguments.

M3’s acceptance criterion asked for “a working CLI tool doing file IO and parsing”. It has had the file IO and the parsing since filesystem.md; this is the “command-line” part of it.


5. What this does not add

  • No flag parsing. -v, --output=x, clustering, -- — all of it is a library over these two operations, and this language has no module system to put a library in.
  • No environment variables. A second question with the same shape and a different answer available (getenv through Ffi("libc") already works). It deserves its own paragraph when someone needs it.
  • No standard input. Io is the console capability and reading from it is a third operation; the file operations are whole-file and stdin is not a file. It waits for the same handle design filesystem.md §3 defers.
  • No exit-code helpers. main returns int and that is the exit status; there is nothing to add.

6. Open

QuestionWhy it waits
Flag parsingA library, and there is nowhere to put one
Environment variablesSame shape, different answer; nobody needs it yet
Standard inputWants the file-handle design filesystem.md §3 defers
Arguments as an iteratorWants a trait or a closure; the language has neither

7. The must-reject suite

FixtureRule§
args_without_capability.choReading the command line requires an Args2
args_effect_undeclared.choA row that reads argv must declare args2
arg_written_through.choAn argument is shared; nothing writes through one3.1

And the accepting counterpart:

FixtureShows
arguments.choarg_count, arg(0), and a loop over the rest

Plus two conformance tests, because both are things only a running program with real arguments can show: the bytes a program is started with are the bytes it reads, and an index past arg_count traps.

The filesystem

Status: settled, and built. Shorter than strings.md because most of it is an application of decisions already made: Fs(prefix) narrows by §7.4’s prefix extension exactly as Ffi(library) does, and the file operations reach libc the way putchar and the arena already do. Two things here are genuinely new, and §2 and §4 are those two.

This is the last mile to M3’s acceptance criterion (#1): a real CLI tool doing file IO and parsing.


1. Fs(prefix) is a capability carrying a path

linearity-and-effects.md §8.1 listed it, and §7.4’s worked example is literally this one:

let logs = narrow(fs, "/var/log/app");   // Fs("/var") -> Fs("/var/log/app")

So it is the machinery Ffi already uses, pointed at a different kind of name. split hands out Fs("") — the unnarrowed root, authority over nothing until it names somewhere — and narrowing is prefix extension, one way only. A program cannot grant itself what it was not given.

The effect labels carry the prefix too: fs_read("/var/log") and fs_write("/tmp"). A function’s row therefore says which part of the filesystem it can touch, and a caller reads that without opening the body.

1.1 A path prefix is not a byte prefix

One place Fs is not the same as Ffi, found while building it and worth stating rather than leaving implicit.

libc is a byte prefix of libcrypto, and that is the right answer for a library name: attenuation there is textual because a library name has no structure. A path does have structure. /tmp is a byte prefix of /tmpevil, and /tmpevil is not in /tmp — it is the directory next door, and a purely textual check would hand it over.

A path prefix extends at a /, or not at all. target is inside prefix when it starts with those bytes and either is exactly the prefix, or continues from it at a separator.

Exactly the prefix is allowed on purpose: Fs("/tmp/app.log") naming one file is an ordinary and useful point in the lattice, and a rule that refused it would make single-file authority unexpressible.

The empty prefix contains everything, which is what makes the capability split hands out the root rather than a special case. The rule is checked twice, because the two halves are known at different times: narrow checks it at compile time against the literal, and the operations check it at run time against the path (§4).

The effect row uses the same rule. Owning an Fs("/tmp") discharges fs_read/fs_write labels inside /tmp, and Label::covers decides that with extends_path, the function narrow calls: /tmp covers /tmp, /tmp/x and /tmp/x/y, a trailing slash (/tmp/) covers what is beneath it and not /tmp itself, the empty path covers every path, and /tmpevil is covered by none of them. Exec’s exec(p) label follows the same rule (processes.md §4.1). . and .. are not interpreted: the label is its literal’s text, as for narrow, and .. is refused when a path reaches an operation (§4). Until the correcting change covers compared these by byte prefix, so a function owning Fs("/tmp") could declare [] for a body that read /tmpevil: an inexact row (narrowing-into-several.md §6; no accepted program relied on it). net_out/net_in keep a plain text prefix on their "host:port" bound, as narrow does for a Net (net.md §4).

More than one path. narrow(fs, "a", "b", ...) spends the Fs and answers a tuple, one Fs per literal, each checked as above and against each other (no two equal, none inside another). A program that holds /dev/urandom and a certificate directory at once reports fs_read("/dev/urandom") and fs_read("/etc/cancho/tls_echo"), and not fs_read(""). Each operation is still checked at run time against the prefix of the capability it borrowed, so a read of one child’s file through the other traps (narrowing-into-several.md, built in PR #364, conformance/narrow_many.rs).


2. Why the operations are builtins and not extern fn

This is the first of the two new decisions, and it is the one that decides whether Fs means anything at all.

File operations reach libc from the backend, the way putchar, malloc and free already do. They are not extern fn declarations.

If they were, they would be gated by Ffi("libc") — and then holding the FFI capability would let a program open any path, with Fs contributing nothing. The authority that guards the filesystem has to be the one that names the filesystem.

2.1 What Fs does not do

Fs is not a sandbox, and this document will not pretend otherwise.

A program that narrows its Ffi to "libc" can declare extern fn open(...) and reach any path it likes. Nothing in the type system stops that, because §8.4 deliberately makes C reachable and C has the whole filesystem in it.

What the design actually claims is narrower and still worth having:

  • a program’s authority is visible in its types. Reaching the filesystem through the language costs an Fs narrowed to a prefix; reaching it around the language costs an Ffi("libc") and an extern declaration, and both are in the signature of every frame that carries them;
  • main chooses. It is handed one World, and what it narrows and what it releases is the whole story — a main that releases its ffi and narrows its fs to /tmp has given the rest of the program exactly that, and the rest of the program cannot widen it.

Containment against a hostile program is a sandbox’s job — lex-os runs one, and this language is what runs inside it. Containment against an honest program’s mistakes is what types do, and that is what this is.

2.2 And they do not call open

A note from building it, because it cost a CI cycle and the reason is not obvious.

open is variadic — int open(const char *, int, ...) — and on Apple ARM64 a variadic argument is passed on the stack while a fixed one is passed in a register. Declaring it with three fixed arguments therefore put the mode somewhere the callee never looked: the write succeeded and returned the right byte count, and the file was created with whatever happened to be on the stack, usually unreadable. Linux x86-64 cannot show this, because there varargs and fixed arguments share the same registers — so it passed locally and failed on the other CI target, which is what the second target is for.

So the backend calls two non-variadic functions instead:

  • creat(path, mode) to write, which is exactly open(path, O_WRONLY|O_CREAT|O_TRUNC, mode) — and which also deletes the platform-dependent flag constants, the other thing here a portable language should not be guessing at;
  • open(path, O_RDONLY) to read, declared with two arguments. That is the non-variadic prefix, so no argument lands anywhere the callee is not looking, and O_RDONLY is zero on both platforms.

The rule this generalises to: the backend does not call variadic C functions, because their ABI differs from the fixed one and Cranelift signatures do not express the difference. A conformance test checks the mode of a file a program wrote, from outside the program, rather than inferring it from a read that happens to succeed.

Corrected (processes.md §4.5). The rule was overtaken twice, and both opens have changed. fcntl and then openat (directory-handles.md) needed variadic calls, and the backends learned to make them correctly: LLVM declares the callee variadic, and Cranelift shapes the call on Apple ARM64 the way the callee reads it, with the variadic argument in the first stack slot. Then every descriptor a builtin opens had to be close-on-exec, which creat cannot ask for. So both opens are now openat(AT_FDCWD, path, flags, mode) with O_CLOEXEC: O_WRONLY|O_CREAT|O_TRUNC and 0644 to write, O_RDONLY to read. The per-target flag values live once, in cancho_ir::open_flags. The mode test above still holds, and still checks the variadic mode from outside the program.


3. Two operations, whole-file

fs_read(fs: &f Fs(p), path: &a [byte], into: &!b [byte]) -> [fs_read(p)] int
fs_write(fs: &f Fs(p), path: &a [byte], bytes: &c [byte]) -> [fs_write(p)] int

The capability is borrowed shared, like Ffi and unlike Io: holding it is a key, not a conversation, and two frames holding the same key at once changes nothing about what either may open. into is unique because fs_read writes through it; bytes and path are shared because nothing here writes through them.

fs_read fills as much of into as the file has and returns the byte count; fs_write writes the whole slice and returns what it wrote. Both return -1 on failure rather than trapping: a missing file is an ordinary outcome a program should handle, not a bug in the program.

No handles, no streaming, no seek. A file handle is a linear resource — it is precisely the thing this language exists to track — and giving it a type means deciding what close consumes, what a half-read file is, and what happens to a handle at the end of a region. That is a milestone, not a paragraph, and whole-file operations are what M3 needs to read its own source and write its own output.

Built (#66), and the milestone was smaller than this paragraph says — file-handles.md. Two of the three questions were already answered by machinery that existed: close consumes the handle because that is what res means, and a handle at the end of a region is a compile error because a linear value that nothing consumes already is. Only the third — what a half-read file is — needed a decision, and the answer is a three-constructor Read rather than a fourth meaning for -1.

fs_read and fs_write are unchanged and stay the right call for a file whose size is known. What the handle adds is the case that could not be written correctly at all: reading a file whose size you do not know.


4. The path is checked at runtime, and .. is refused

The second new decision, and the one with the sharp edge.

The prefix lives in the type and is therefore known at compile time. The path is a runtime slice — it has to be, or a program could not open a file named on its command line. So:

The operation emits a check that the path starts with the capability’s prefix, and traps if it does not.

A trap rather than -1, because a path outside the granted prefix is not a missing file: it is a program doing something its type said it would not. That is the same distinction defined-behaviour.md draws everywhere — -1 for an outcome, a trap for a broken promise.

Corrected: the check was undefined behaviour in the LLVM backend. Both backends compared byte i of the path with byte i of the prefix for every i in the path, reading the prefix’s data past its end once the path was longer. Cranelift read whatever lay next to it, which was harmless by luck. LLVM folds a load past the end of a global to poison, and the mask that was meant to discard it does not: false && poison is still poison, and a branch on it is undefined behaviour. At -O2, where the check’s loop is unrolled for a short path, the optimiser therefore deleted every path through the call: fs_read("/tmp/q/f") on Fs("/tmp") trapped with SIGILL (measured: paths of 11 bytes and fewer, not 15 and more). connect’s host check had the same shape and answered garbage (48 for a connect that should have been refused). The prefix’s byte is now read at i only while i is inside it, and an empty prefix is not read at all; a_short_path_under_a_narrowed_prefix_is_read_on_both_backends and a_host_inside_a_shorter_bound_is_dialled_on_both_backends pin both.

4.1 .. is refused rather than normalised

A prefix check on bytes is defeated by /tmp/../etc/passwd. There are two honest responses and one dishonest one:

  • normalise the path before checking — which is a security function with a long history of being got wrong, and would need its own design, its own fixtures and a decision about symlinks;
  • refuse any path containing .. — a trap, like the prefix check;
  • pretend the prefix check is sufficient, which is the dishonest one.

M3 takes the second. It is a real restriction and it is stated rather than hidden: a program that needs .. is a program that needs path normalisation, and path normalisation arrives with a design document of its own.


5. What this unblocks

M3’s acceptance criterion. examples/lines.cho writes a log, reads it back off disk, counts and filters it, writes a report, and reads the report back to print it — four file operations, one capability narrowed once in main, and a row on every frame that carries it. The bytes come from disk; everything done to them is the slice machinery wordcount.cho already used.

There are no command-line arguments yet (§6), so the tool lays down its own input rather than being handed a path. That is the one place the example is a demonstration rather than a tool, and it is a missing runtime feature rather than a missing language one.


6. Open

QuestionWhy it waits
File handles as linear resourcesThe obvious next step, and the one this language is for. Needs close, partial reads, and what a region’s end does to a handle
Path normalisationA security function; needs symlinks decided too (§4.1)
Command-line argumentsmain takes a World and nothing else today; arguments are another thing the runtime hands over, and they need a capability question answered first
Directory listingAnother operation, and a second shape of result
-1 versus a result typeResult[T] exists (rational.cho uses one). Whether the filesystem should return one is a library-shape question

7. The must-reject suite

FixtureRule§
fs_without_capability.choReading a file requires an Fs1
fs_widened.choFs("/tmp/a") cannot become Fs("/tmp")1
fs_sibling_prefix.choFs("/tmp") cannot become Fs("/tmpevil")1.1
fs_effect_undeclared.choA row must name the prefix it reads1
fs_owned_prefix_covers_sibling.choOwning Fs("/tmp") does not cover fs_read("/tmpevil") in a row1.1
exec_owned_prefix_covers_sibling.choThe same for Exec("/bin") and exec("/binevil")1.1

And the accepting counterparts:

FixtureShows
file_roundtrip.choWrite a file, read it back, compare the bytes
fs_narrowed.choA capability narrowed once, threaded down, used at the bottom

Plus four conformance tests, because these are runtime rules and a fixture that only compiles would not reach them:

TestShows§
a_path_outside_the_granted_prefix_trapsThe prefix is enforced on the value, not just on the type4
a_path_containing_dot_dot_traps.. is refused rather than normalised4.1
a_sibling_of_the_granted_directory_traps§1.1 again, on the path this time1.1
a_missing_file_is_minus_one_rather_than_a_trapAn outcome is not a broken promise3
a_written_file_is_readable_by_its_ownerThe mode reaches the callee — no variadic C call2.2

Standard input

Status: settled. The one I/O direction the language did not have.

§7 adds the bulk read (read_bytes, edition 9): getchar is a libc call per byte, 75 MB/s on macOS, and §3.2’s “no buffer-filling read” is corrected there.


1. What is missing

examples/lines.cho is the M3 acceptance criterion: a real command-line tool that reads and writes files, counts and filters. It cannot be piped into.

Nothing here can. A program can write to the console (putchar), read and write files (fs_read, fs_write), and read its command line (arg). It cannot read the one input every tool in a pipeline gets. So wordcount.cho counts an embedded document, which is a demonstration of counting rather than a program anyone would run.


2. It is not a seventh capability

The obvious move is a Stdin capability, an eighth field on Split, and a break in every program that calls it. That is the wrong move, and the language already says why.

Fs is one capability with two effect labels:

fn read [&f](fs: &f Fs(p), ...)  -> [fs_read(p)]  int
fn write[&f](fs: &f Fs(p), ...)  -> [fs_write(p)] int

The capability is what you hold. The labels are what you did with it. Reading a file and writing one are the same authority used in two directions, and the row says which — so a caller knows from the signature, which is what arguments.md §2 means by the whole thing being about visibility rather than containment.

The console is the same shape. Io is the capability; reading from it and writing to it are two directions of one authority, and each gets a label.

standard-error.md applies this a third time and finds the rule holds without amendment: standard error is a third stream rather than a third direction, err_write is its label, and it is still not a new capability. §2.1 there says what that widens — a grant of Io reaches descriptor 2 now, where before it could not — and why the row is what keeps that honest.

2.1 Which means the existing label is renamed

putchar’s effect is spelled io today. Add reading and that becomes a vocabulary with a hole in it: [io] would mean write, [io_read] would mean read, and every reader would have to be told which way the bare one points.

So the labels are io_read and io_write, and putchar’s row changes from [io] to [io_write].

This is not a drive-by rename. There is no way to add a read label without deciding what the write label is called, and [io] alongside [io_read] is a decision too — the worse one. Doing it now costs 155 occurrences across 53 files; doing it later costs more, because there will be more programs by then, and the wart is in every signature that prints in the meantime.

2.2 What Io discharges

Owning an Io outright discharges both labels, the way owning an Fs discharges both of its. “Owning discharges, borrowing declares” is unchanged: main’s row stays [], and a function holding &!i Io still writes down exactly what it did.


3. The operation

getchar[&i](io: &!i Io) -> [io_read] int

One byte in, mirroring putchar’s one byte out, behind the capability that authorises it. -1 at end of input.

3.1 Why int and -1

A byte is 0..255, so -1 cannot be one and the sentinel is unambiguous — which is exactly why C’s getchar returns int and not char.

It also matches fs_read, which returns -1 when a file could not be read: filesystem.md §2 calls that “an ordinary outcome, not a broken promise”, and the end of input is the same kind of thing.

An honest caveat: an enum — End and Byte(byte) — would be better. A sentinel is a check you can forget; an exhaustive match is one you cannot, and “a value the program decides what to do about” is sharing.md §3’s whole argument for Gen. The reason it is int here is consistency with fs_read rather than conviction, and §6 keeps the question open for both of them at once.

3.2 Why only one operation

There is no read_line, no read_all, no buffer-filling read.

Corrected (§7). The buffer-filling read was asked for by a program that reads millions of lines from a pipe, and measured: getchar is 72 MB/s on macOS and 264 to 315 MB/s on Linux, against 2 to 5.5 GB/s for a bulk read. read_bytes is that read. The rest of this section stands: there is still no read_line and no read_all; those are policies.

boxed-slices.md §4 made this call already, for growing: “there is no grow, push or realloc; growing is allocate-copy-end, every part of which was already expressible”, so examples/buffer/ writes it down and the policy belongs to the program. A line is a policy too — where it ends, what to do with a carriage return, what happens when it is longer than a buffer — and none of those belong in a compiler.

examples/tally.cho is the demonstration: a real wc over standard input, built from getchar and nothing else.

Writing it found two things worth having found, which is the argument for building the example rather than asserting the feature:

  • The word-boundary policy was wrong twice. The first versions omitted vertical tab and form feed. Nothing in the fixture contained either; running it against GNU wc over a real file did. A policy in the program is a policy you can get wrong — and also one you can fix without touching a compiler, which is the trade §3.2 is making.

  • It disagrees with wc in the C locale, on purpose. Over this repository’s README — 758 lines, 42158 bytes — lines and bytes agree exactly, and words agree under a UTF-8 locale. Under the C locale wc reports 98 fewer, because it decodes each multi-byte sequence and skips the ones the locale calls invalid: an em dash between two spaces is not a word to it, and is one to tally.

    That is strings.md §1 showing up in a program. A string here is bytes, not an encoding, so a run of non-blank bytes is a word whatever those bytes mean. Matching the C locale’s answer would need a decoder, and nothing in this language has one.


4. What this is not

  • No promise about buffering. getchar is libc’s, so libc’s buffering applies, and a byte costs a call — the same cost putchar has always had. A program that wants fewer calls reads into a buffer it owns, which is §3.2 and, since §7, one call per buffer.
  • No seek, no rewind, no isatty. Standard input is a stream of bytes that ends. Anything that asks where it is in a file is a question about a file, and files have Fs. (§7.5 asks whether that still holds, and lists what a tool that wants to seek would need.)
  • No line discipline. Terminals do their own; this reads what arrives.
  • No second stream. There is one standard input. Standard error is a separate missing thing and is not this document.

5. The harness learns //~ STDIN

A fixture that reads input needs input to be tested with, and every harness here feeds a program nothing. So the directive vocabulary gains one:

//~ STDIN  the text fed to the program
//~ STDOUT what it must print
//~ EXIT   the status it must exit with

It generalises: the accept walker, the example walker and any later fixture get it at once, because they all read directives from the same header.


6. Open

QuestionWhy it waits
An enum instead of -1, for getchar and fs_read§3.1. It is the better design and it is a change to two operations, which makes it its own slice rather than a rider on this one
A buffer-filling readBuilt (§7). A library could not be one: the only fast way to standard input the language had was /dev/stdin through Fs, which hides the read from the authority report
Standard errorA second stream, and a question about what Io is. Probably a third label

7. The bulk read: read_bytes

read_bytes[&i, &b](io: &!i Io, into: &!b [byte]) -> [io_read] Read

Edition 9 (7 when the PR was opened; edition 7 has since been closed by editions.md §6.4 and the word-scan slice opened 9, so read_bytes joined it). Read is file_read’s own: Got(n), End, Failed(errno) (file-handles.md §3). It fills into from the front and says how many bytes it put there.

7.1 Why it is a builtin and not /dev/stdin

cancho-table’s reader design (PR 25, docs/readers.md §3.4) measured the cost of standard input byte by byte, on the 31.7 MB file of its benchmark and on this repository’s benches/stdin_read.cho (71.7 MB of JSON lines here):

macOS (arm64, load 10 to 20)Linux x86-64 (gram, cores 0 to 5, load 5)
getchar into the chunk’s room75 MB/s (0.96 s)438 MB/s (0.16 s)
read_bytes, 64 KiB at a time5,465 MB/s (0.013 s), 73x4,290 MB/s (0.017 s), 9.8x
/dev/stdin through file_read (not a way to read standard input)5,490 MB/s4,314 MB/s
the same three from a pipe (cat FILE into the program)75 / 4,244 / 5,129 MB/s410 / 3,961 / 3,859 MB/s

Best of 5, wall time, re-measured after the rebase onto edition 9 (the earlier run, best of 3, read 72 / 5,480 / 5,530 MB/s on macOS and 315 / 2,070 / 2,030 on Linux; the Linux figures moved a good deal between the two runs, so read the ratio’s order, not its digits); the program counts the bytes and newlines in 64 KiB chunks and keeps nothing (benches/stdin_read.py checks each mode’s count against Python’s before timing it). getchar is a libc call per byte (13 ns on this Mac) and nothing in the program; the bulk read is within noise of the file read on both machines, so the read is no longer what the time is spent on.

The fast route the language already had, opening the path /dev/stdin, reaches standard input through the file system: the authority report of the program that does it says fs_read("") and not io_read. That is bulk-io.md §2 again, from the other side: the cheap thing to grant was the expensive thing to audit, and a capability language that charges less authority-visibility for speed is teaching the wrong lesson. read_bytes is the same read behind the same capability and the same label, so a faster program is not a quieter one (conformance/stdin_bulk.rs: the row has io_read and no fs_*).

7.2 What it answers

AnswerWhen
Got(n), 1 <= n <= len(into)bytes arrived. n < len(into) means the input ended (or failed) while the buffer was being filled: read_bytes keeps reading until the buffer is full or something stops it
Got(0)into was empty. Nothing was asked for, so nothing ended
Endthe end of input, with a buffer to fill
Failed(errno)the read failed with no byte read: 9 (EBADF) for a closed descriptor, 21 (EISDIR) when standard input is a directory (both measured, both backends)

A failure that follows some bytes is not lost and not mixed in: the call answers Got(n) for the bytes, and the next call reads again and reports it if it persists. Failed is reported once; the stream’s error and end-of-file indicators are cleared after each call, so a program that carries on after Failed is judged by what the next read finds, not by what an earlier one left behind (glibc and macOS differ on whether an end-of-file on a stream is sticky, and this removes the difference; a terminal was not tried).

7.3 It is fread, not read(0), and what that costs

read_bytes is fread on the C stdin stream, the stream getchar reads. So the two mix: a program may take a header with getchar and the rest with read_bytes, and no byte getchar’s buffer already holds is skipped (it_shares_the_stream_with_getchar reads 5 bytes with getchar and the rest in 4 KiB, 7-byte and 1 MB buffers on both backends). read(0) would be a system call fewer and would silently drop up to 4 KiB.

The price is latency: fread returns when the buffer is full or the input ends, not when some bytes have arrived, so a tool that must act on each line of a slow producer (tail -f log | tool) sees nothing until 64 KiB have arrived with a 64 KiB buffer. Such a tool should keep getchar, or ask for a small buffer. A read_some that returns what is there is a different answer to Got(n)’s meaning and nothing has asked for it (§7.5).

7.4 Both backends, and WASI

Cranelift and LLVM both call fread (stdin on glibc, __stdinp on macOS, as flush_out finds stdout) and read errno before ferror. On wasm32-wasip1 the console is not libc’s stdio (wasm.md W2): read_bytes first hands out what getchar’s own 4 KiB buffer holds, then calls fd_read straight into the caller’s buffer until it is full or the input ends, and imports nothing getchar did not (a_wasm32_bulk_read_never_calls_libc_stdio). Run under wasmtime, the program of tests/programs/stdin_read_bytes.cho counts the same bytes, lines and hash as the native one for buffers of 0, 7 and 65,536 bytes and for a getchar header followed by read_bytes, on a closed descriptor and on a directory (the failures differ: wasmtime hands a program with no standard input an empty one, so the closed descriptor is End there).

7.5 What is not here

A query for what standard input is (a pipe, a file, a terminal; fstat/isatty)Not built, and not cheap as asked. The question is useful for a tool that wants to choose a strategy, but the use named for it, seeking, needs more than the answer: there is no way to hold a File for descriptor 0 (a File comes from open_read of a path), so file_pread cannot be pointed at standard input, and lseek has no builtin. A kind query alone would let a tool learn that it could seek and then be unable to. The slice that closes it is two builtins under io_read, stdin_kind(io) -> int and a positional read (stdin_pread(io, at, into) -> Read, EBADF/ESPIPE when it is a pipe), on three backends (WASI has fd_fdstat_get and fd_pread). The authority argument of §7.1 applies unchanged: it has to be behind Io, not Fs("") and /dev/stdin
A read that returns what has arrived§7.3. Nothing has asked
std.io.read_intoA wrapper like std.io.write_all. std.io is an edition-1 file and cannot name an edition-9 builtin without declaring the edition, which would move the identity of every function in it (editions.md §6.3); std.io gets a second module or an edition marker in a slice of its own
Standard input as a FileSame reason as the seek row

8. The suite

FixtureRule§
getchar_without_capability.choReading the console needs an Io2
io_read_undeclared.choAn effect performed is an effect declared2
io_write_does_not_cover_io_read.choThe two labels are distinct, and a row saying one does not permit the other2.1

The third is the one that matters. It is what makes the rename a real distinction rather than a spelling: a function declaring [io_write] may not call getchar, and the refusal names the label it is missing.

AcceptingShows
stdin_roundtrip.chogetchar to end of input, a row carrying both labels, and the first fixture with a //~ STDIN
examples/tally.choA real wc over standard input: the program that could not be written before, and the one that found the two things in §3.2
tests/programs/stdin_read_bytes.cho, conformance/stdin_bulk.rs§7: every byte once and in order for buffers of 0, 1, 7, 4,096, 65,536 and 1 MiB over inputs of 0 bytes to 24 MiB, input that arrives in pieces (3 to 500 writes, 2 ms apart), mixed with getchar, a closed descriptor (9), a directory (21) and /dev/null; the authority row; on both backends
tests/reject/read_bytes_is_edition_nine.choread_bytes is not a name before edition 9

Standard error

Status: measured, then designed.

Two documents in this repository say the gap below is “named in reach.md §6”. It is not. reach.md never mentions standard error at all — not in §6, not anywhere — so the citation pointed at a row that was never written, twice, and the second citation was copied from the first.

The gap is real. §1 measures it, and one of the three things measuring it found is a sentence from the previous slice that is false for exactly the reason this document exists.


1. What the absence costs, measured

Every program here fails silently. Not as a figure of speech — the failure paths produce zero bytes on every stream:

exitstdoutstderrGNU’s stderr
sort /nope/missing.txt20064 bytes
cut -d, -fx (malformed list)20068 bytes
base64 -d on a bad byte10022 bytes

Eleven of the nineteen programs under examples/ have at least one non-zero exit; examples/serve/ has four distinct ones. None of them says anything, and sort.cho carries a comment admitting it:

“GNU writes the failing path to standard error and exits 2. There is no standard error here yet, so this is the status alone.”

1.1 The workaround puts a diagnostic in the data

The only stream a program has is the one its output goes on, so being loud means being loud there. That is not a stylistic compromise; it corrupts the output. A cut that prints cut: invalid field list on stdout, in the pipeline a cut belongs in:

$ cut -d, -fx < data.csv | sort | wc -l     # GNU
0
$ ours -d, -fx < data.csv | sort | wc -l    # the workaround
1

The line sort sorted was the diagnostic. Exit status is still 2 and the consumer still gets a row, which is the failure mode a status code exists to prevent.

1.2 And it loses the message exactly when it matters

Worse, and this is the row that decides the design. stdout is fully buffered when it is not a terminal, and a trap does not flush it. A program that says what is wrong and then dies says nothing:

$ lost > out.txt ; echo $?     # writes "about to fail", then traps
132
$ wc -c < out.txt
0

Zero bytes into a file, and zero bytes through a pipe. The diagnostic is lost in precisely the case — the program died — where it is the only evidence there is. So the requirement is not merely a second stream: it is a stream that has arrived by the time the next instruction runs.

1.3 A silent path is a path nothing compares

examples/cut/cut.cho says, above its parser:

“Answers -1 in from_open on a malformed list, which main turns into the exit status GNU uses.”

GNU uses 1. It used 2. Measured on the same input, one slice after the comment was written, by the same hand.

The first version of this section said nothing caught it because the failure path was untested. That was wrong, and it is worth correcting here rather than quietly, because the true version is sharper. cut_agrees_with_gnu_cut did run the malformed spec, and asserted:

#![allow(unused)]
fn main() {
assert_eq!(bad.status.code(), Some(2), "a malformed `-f` list should exit 2");
}

So the path was tested. It was tested against me rather than against GNU — while, eleven lines above, the same test ran eight valid specs against the real /usr/bin/cut and compared them byte for byte.

Nothing forced that asymmetry except what there was to compare. On a valid spec GNU produces output and so do we, so the obvious thing to do is diff them. On a malformed one GNU produces a message, we produced silence, and the only comparable artefact left was a number — which promptly got written down from the same belief the comment came from.

A stream nobody has is a comparison nobody makes. That is the argument for this slice with the accident taken out of it, and the fix is not only the message: cut_reports_a_bad_field_list_like_gnu_cut now asks the reference for its status and its wording, the way the valid specs always did.


2. It is not a seventh capability

standard-input.md §2 settled this shape when it added reading, and the argument transfers without change:

The capability is what you hold. The labels are what you did with it.

Io is the console. Standard error is part of the console — the same process, handed the same three descriptors by the same parent, redirected by the same shell on the same command line. A StdErr capability would be an eighth field on Split, a break in every program, and a second name for authority nobody grants separately.

So: one capability, a third label.

2.1 What it does widen, said plainly

This is not free and the honest version is worth writing down. Before this change, an Io holder could not reach descriptor 2 from cancho at all — only through Ffi("libc"), which reach.md §5 establishes is every authority at once. After it, an Io holder can. A grant of Io is worth more than it was.

That is acceptable for one reason and it is not “it is only stderr”:

The grant is the capability; the gate is the row. authority.md is built on reading rows, not on counting capabilities, and a program that writes diagnostics now says err_write in its own signature and in its report. Anything mediating this — a supervisor, lex-os, a reviewer — gates on the label, which is finer after this change than before it, because before it the same program said ffi("libc") and meant everything.

The alternative fails on its own terms. A separate capability would make every main that wants to be loud thread a second value through to the failure path, and the failure path is the one place a program is already unwinding, holding the least, and least able to afford ceremony.

2.2 What Io discharges

Owning an Io outright discharges io_read, io_write and err_write, the way it already discharges the first two. Borrowing still declares. Owning a World discharges all three, because split is reachable from one.


3. The operation

write_err[&i, &b](io: &!i Io, bytes: &b [byte]) -> [err_write] int

The same shape as write_bytes (bulk-io.md §3), the same borrowed capability, the same shared slice, the byte count back.

3.1 Why the label is err_write and not io_err

Every other label takes its domain as a prefix: fs_read, fs_write, io_read, io_write. io_err would keep that pattern and read as an error in io, which is the one thing it does not mean.

err_write names the stream first, and the stream is the right unit because it is the unit a reader can act on: 1> and 2> are two different redirections, and a row that distinguishes them tells someone holding a shell something they can use. It also leaves err_read un-spelled and unreachable, which is correct — there is no reading from standard error.

3.2 Why there is no per-byte twin

Standard output has two primitives: putchar for a byte and write_bytes for a slice. bulk-io.md §1 measured why — 12.8×, and a byte at a time through stdio is the difference between a program that is usable and one that is not.

None of that applies here. A diagnostic is short and stderr is never the hot path, so the per-byte primitive would be the redundant one rather than the fast one. putchar exists because it came first, not because a second primitive was wanted; repeating that here would be repeating the history rather than the design.

A program that needs a number on standard error formats it into a buffer and writes once — std.buffer and std.fmt already do exactly that, and no program here has asked. When one does, it will ask for a function in std.io, not for a builtin.

3.3 What it reaches

fwrite(ptr, 1, len, stderr).

bulk-io.md’s reason for choosing fwrite over POSIX write does not carry here and it is worth saying so rather than borrowing it: there write_bytes had to share a stream with putchar or the two would interleave wrongly, and nothing else writes to this stream at all. A write(2) on descriptor 2 would be correct too.

The reason is the guarantee. C says stderr is not fully buffered, and that is a promise about the FILE * — so taking the FILE * is taking the promise rather than re-deriving it, and it keeps one mechanism for both streams instead of two.

It is what makes §1.2’s measurement come out the other way: a diagnostic written before a trap has already left. A property to test rather than assume, so a_diagnostic_survives_a_trap asserts both halves — the message on standard error arrives, and the same message on standard output does not.


4. What the report must not say

cancho authority prints a negative half — “never touches” — and authority.md §2.2 is why it is the interesting half: an absent label is a proof.

A new label with no entry in that table produces a specific lie. A program that writes only diagnostics would perform err_write, hold no other console label, and be reported as

never touches
    the console

which is false about a program whose entire output is on the console. So err_write joins io_read and io_write under the console, and the test that fixes it is a_diagnostic_only_program_touches_the_console.

The positive half needs nothing: it prints the labels it finds, and err_write is one.


5. Ordering, and what is not promised

Standard output is buffered and standard error is not, so a diagnostic can appear before output the program produced earlier. That is C’s behaviour, it is what every coreutil does, and this does not correct it: flushing stdout inside write_err would make a diagnostic depend on the state of an unrelated stream, and would make the two streams’ interleaving a language promise that no other program in the pipeline keeps.

What is promised is per-stream order, which is the same promise the output stream already makes for mixing putchar and write_bytes — both go through one stdio stream, and tests/accept/bulk_write.cho checks the interleaving. Every byte written to standard error appears in the order it was written, and tests/accept/standard_error.cho checks that. Across the two streams, nothing, and nothing in either fixture asserts one.


6. What this does not do

Not a Diagnostic type, not an error-formatting convention, not perror and not errno. GNU’s message for a missing file names the errno string; here the path is known and the reason is not, so the programs in §7 say what they know:

sort: cannot read: /nope/missing.txt

Reading the reason back out of the operating system is errno’s job, and filesystem.md’s -1 does not carry one. That is a real gap, it is the same gap file-handles.md §3’s Failed(int) was designed to fill, and it is that document’s to close rather than this one’s.

Not a logging facility, not levels, not a --quiet convention. One stream, one operation.


7. What became loud

now saysexitagainst GNU
sort on a file it cannot readsort: cannot read: <path>2same status, same wording, less the errno string (§6)
sort on a file too large to holdsort: file too large: <path>3GNU has no such case — it spills to disk
cut on a malformed field listcut: invalid field value '<spec>'1, per §1.3byte for byte
cut on an unrecognised argumentcut: usage: cut -d<c> -f<list>1same status; GNU names the option and this does not
base64 -d on a byte outside the alphabetbase64: invalid input1byte for byte

Five diagnostics, three programs, eleven calls to error_all.

Four of the five are in the conformance suite, run against the system’s own GNU tool where there is one. The fifth is not, and the reason is worth stating rather than hiding: reaching sort’s ceiling needs a file past 1 GiB, and the run takes 9.3 s and 1,025 MB of resident memory — measured, on a 1.1 GB sparse file, which is where the wording in the table comes from. That is a fixture two CI runners would feel, so it stays a hand check, and read_file’s other failure — the one that shares its exit status with nothing — is the one the suite runs.

7.1 Comparing a diagnostic has a locale in it

The first version of those tests passed here and failed on CI, on one character:

left:  invalid field value 'zzz'
right: invalid field value ‘zzz’

GNU quotes a bad argument with ' under POSIX and with ‘’ — U+2018 and U+2019 — under a UTF-8 locale. This box defaults to POSIX and the runner does not, so an unpinned comparison of wording is a coin toss across machines in a way a comparison of output never was.

sort_agrees_with_gnu_sort had already met the general version of this and pinned LC_ALL=C, with the reason stated as ordering: “there is no locale anywhere in this language to implement anything else” (ROADMAP.md excludes locale outright). The same pin fixes this, and the reason turns out to be broader than ordering — a diagnostic is text, and GNU’s text moves with the environment where these programs’ does not. Every reference invocation in these tests now goes through one locale-pinned runner, ours included, which ignores it.

Worth having hit at the moment a program here first had something to say, rather than the first time somebody compared it on a different machine.


8. Open

QuestionWhy it waits
Why a file could not be read§6. fs_read answers -1 and the reason is gone. file-handles.md §3’s Failed(int) is the designed shape and this document does not pre-empt it
A number on standard error§3.2. Writable today through std.buffer; it wants a std.io function, and no program has asked
Whether write_err should flush stdout§5 says no, and says why. It is the sort of decision that looks obvious in the other direction the first time two streams interleave confusingly in a terminal, so it is recorded rather than closed
A diagnostic convention, and where the name in it comes fromThe program’s own name is written out at all eleven call sites in §7, and the shape <name>: <what>: <detail> is the same in four of the five — a library function forming, on the same evidence the last five arrived on. What stops it being obvious is the name. GNU takes its prefix from argv[0]: invoked as /usr/bin/cut it calls itself /usr/bin/cut, which cut_reports_a_bad_field_list_like_gnu_cut had to compare around. A literal is the other answer and it is not plainly worse. arg(g, 0) needs the Args capability at the failure site, which base64 has already released by then — so the convention and the capability are one question, not two

The authority surface

Status: settled and built, and §12’s question is answered no.

“Capability release at main. Four release calls in §8.3 is ceremony. Letting the runtime reclaim World’s parts is convenient and is exactly the affine hole §4 refuses elsewhere.”

The affine-hole argument is correct and it is not the interesting one. Even if reclaiming were safe it would be wrong, because those four lines are the only place a program’s authority surface is written down. §1 is that argument; §2 is the tool that follows from it.


1. The ceremony is the declaration

Measured first. Across this repository there are 290 release calls, and all 290 are inside main. Not one appears anywhere else. So the cost does not compound with the size of a program — it is a fixed five lines in one function, paid once per program rather than once per abstraction.

And those lines say something. release(fs) on the third line of main is a program stating “I will never touch the filesystem” — checked, not claimed, because the capability is gone and nothing can conjure another. The four releases of unused capabilities are not ceremony around the program’s authority; they are the program’s authority declaration.

Deleting them to save four lines deletes the declaration. The runtime would reclaim the capabilities silently, every main would look alike, and the one fact a reader of a capability-typed program most wants — what can this thing reach? — would be nowhere.

1.1 Why main cannot say it in its type

The natural objection is that the row should carry this. It cannot, and the reason is structural rather than an oversight.

§8.2: a row lists what a function borrows. Owning a capability is strictly stronger and strictly more visible, so an effect a function has outright does not appear in its row. That is why main prints while declaring [].

Which means every entry point in this repository has the same signature:

fn main(world: World) -> [] int

Fourteen examples, one signature, zero information. The type of main is the one place the row model has nothing to say, because main is the one function that owns rather than borrows. So the surface lives in the body, as the pattern of release calls, and §1 is why that is the right place for it rather than a consolation.


2. So compute it

If the declaration is in the body, a tool should read it:

$ cancho authority examples/tally.cho --std
performs
    io_read
    io_write
never touches
    the filesystem
    the heap
    the command line
    foreign code

The surface is the union of what every function main reaches performs, and that needed no new analysis. Pass 2 already emits exactly what main reaches (standard-library.md §5.2), so Program::funcs is the reachable set. The same reachability that decides what goes in the binary decides what the binary can do — one fact, not two.

Rows are exact in both directions (§7.3), so this is precise rather than conservative: a label here is an effect the program performs on some path, not one it might.

2.1 What building it found

The first version unioned the declared rows, and it was wrong in exactly the way §1.1 predicts. examples/lines.cho reads argv in main’s own body, main declares [], and the report said never touches the command line about the repository’s command-line tool.

So a function now carries what it performs as well as what it declares — the set before ownership discharges it, which the checker computed anyway and threw away. For every function that borrows its authority the two are equal; for the one that owns, they are not, and that one is the entry point. The bug and the section are the same observation arriving twice.

2.2 The negative half

A capability language is for the question what can this not do, so the report answers it. An absent label is a proof, not an absence of evidence: the capability was released, and nothing in the language creates another.

Both forms answer it, and from one table. The prose report says never touches and lists the domains; --output json carries the same domains as never_touches, so a consumer reading only data can tell “performs no exec” from “does not model exec” — silence is not how absence is spelled. The domains are the eight the prose report lists (the console, the filesystem, the network, the heap, the command line, signals, other programs, foreign code), computed by one function both outputs share, so the two cannot drift apart.

2.3 It shows narrowing

examples/tour.cho     fs_read("/tmp")  fs_write("/tmp")
examples/lines.cho    fs_read("")      fs_write("")

Both touch the filesystem; only one is confined to a directory. lines.cho is unnarrowed for a reason it explains — a tool reading a path the user chose has no literal to narrow to — and the point here is that the difference is legible from outside the program, without opening either.


3. What is still open

QuestionWhy it waits
release(a, b, c)Would make the five lines two while keeping every name, so it loses nothing §1 defends. It is also a variadic form in a language with none, for a saving of three lines once per program
A machine-readable formDone — --output json, in the shape lex-os-check’s CheckReport already uses. docs/budget.md §5 is why it was the half actually wanted
What a foreign call can reachDone – foreign-authority.md: unbounded_by names every reachable foreign symbol with the scope its declaration claims; bounded keeps its meaning
Authority of a libraryWith no main there is no program, so there is no surface — only per-function rows, which cancho ids already lists
foreign_symbols is not reachability-prunedFixed. Found building examples/fetch/fetch.cho (docs/package-system.md §6): Program::funcs is already the reachable set from main (§2 above, cancho-ir’s pass 2), but Program::externs was collected once, unconditionally, before that pass ran, and was never intersected with it. cancho-ir::reachable_externs (fold::collect_extern_refs, the same exhaustive-match shape collect_static_refs already used for docs/crypto.md §4’s identical problem with statics) walks Program::funcs for every Callee::Extern a call site actually reaches; main.rs’s print_authority filters program.externs by it before building foreign_symbols. Program::externs itself is untouched — nothing renumbers it, so Callee::Extern’s index and every codegen backend are unaffected. crates/cancho/tests/conformance/authority.rs’s foreign_symbols_names_only_what_the_program_calls and net.rs’s the_client_and_the_server_differ_only_in_their_symbols both check it

4. The suite

TestClaim
the_authority_report_names_what_a_program_performs§2, over three examples whose surfaces differ
the_authority_report_sees_what_main_does_itself§2.1 — the bug: lines.cho reads argv in main
an_unused_capability_never_appears§2.2, the negative half

Narrowing one capability into several

Status: built (PR #364); the covers fix of section 6 was built first (PR #359). Found by the PostgreSQL TLS work (alpibrusl/cancho-pg#14, its docs/tls.md section 5 and section 11.3): a program that reads two unrelated files holds Fs(""), and its authority report says fs_read(""), which is every file. Every claim below about the compiler before this change was checked against the code or a probe at c0ad830; section 10 lists which, and what was not. Section 11 is what building it found, and corrects in place what it showed wrong.

1. The problem, as a program and its report

A TLS client needs two things from the filesystem: entropy (/dev/urandom) and a trust store (a CA bundle, for example /etc/ssl/certs/ca-certificates.crt). Written today, the only way to read both is through one Fs that covers both paths, and the only prefix that covers /dev/urandom and /etc/... is the root:

fn entropy[&f, &o](fs: &f Fs(""), out: &!o [byte]) -> [fs_read("")] int {
    return fs_read(fs, "/dev/urandom", out);
}

fn roots[&f, &o](fs: &f Fs(""), out: &!o [byte]) -> [fs_read("")] int {
    return fs_read(fs, "/etc/ssl/certs/ca-certificates.crt", out);
}

fn main(world: World) -> [] int {
    let Split { io, ffi, fs, heap, args } = split(world);
    release(io); release(ffi); release(heap); release(args);
    var got = 0;
    region r {
        let seed = alloc_slice[r](32, byte_of(0));
        let pem = alloc_slice[r](4096, byte_of(0));
        borrow fs as &f in {
            got = entropy(f, seed) + roots(f, pem);
        }
    }
    release(fs);
    return 0;
}

cancho authority on it (run, at c0ad830):

performs
    fs_read("")
never touches
    the console
    ...

and in JSON, "labels": [ { "name": "fs_read", "argument": "", "bounded": true } ]. The report is not wrong: the program can read any file, because every function on the path holds the root. It is wider than the program needs, and a supervisor that pins it (docs/under-a-grant.md: the filesystem is the one dimension a grant can already decide from the report) has to grant the whole filesystem to a program that reads two files.

The toolbox’s programs rely on the report being tight. cancho-pg’s TLS design wanted the report to name the two files and could not have it; its tests/narrow_tls.cho reads the bundle from standard input instead, to keep fs_read("/dev/urandom"). That is a workaround that changes how the program is deployed in order to keep its report honest.

2. Why narrowing is once-only today

The rule is not about the effect row. It is linearity applied to narrow, and the reason is stated in the code.

  • narrow consumes its capability. FnLowering::narrow (crates/cancho-ir/src/lower/mod.rs) lowers the capability argument as an ordinary owned use and returns the same value with a narrower type: Fs is a res value, so after let rng = narrow(fs, "/dev/urandom"); the name fs is moved. A second narrow(fs, ...) is refused linear-use-after-move (probe: `fs` has already been consumed; a `res` value is used exactly once).
  • Narrowing goes one way. The narrowed Fs("/dev/urandom") can only be narrowed to a path inside it: extends_path(current, target) (crates/cancho-ir/src/defs.rs) must hold, so narrow(rng, "/etc/ssl/...") is refused capability-not-narrowable (“a capability is attenuated, never widened”).
  • A borrow cannot be narrowed. narrow refuses a Type::Ref argument with capability-not-narrowable: “narrowing consumes what it attenuates; narrow the capability itself, before lending it”. So there is no way to mint a narrowed child from a lent root and keep the root.
  • One Fs exists per program. split hands out one Fs(""), and nothing else creates one (linearity-and-effects.md §8.1: a program may not construct a capability; fork_heap and fork_clock exist for Heap and Clock only, parallelism.md §8 and §9).

The reason narrowing consumes is linearity-and-effects.md §7.4: “after narrowing there is no way back to it, because linearity says there is no second use.” That is the property that makes main’s narrowing lines a declaration (authority.md §1): once main has written narrow(fs, "/tmp"), nothing in the program can reach outside /tmp. The consequence nobody needed until now is that the lattice has one chain per program: Fs("") to Fs("/a") to Fs("/a/b"), never two siblings.

The effect row is not the obstacle. A row may already hold two fs_read labels with different arguments: Effects is a sorted, deduplicated set of Label { name, argument } (crates/cancho-ir/src/ir.rs), compared by equality in Effects::missing_from. Probe: a function taking &a Fs("/dev/urandom") and &b Fs("/etc/ssl"), reading through both and declaring [fs_read("/dev/urandom"), fs_read("/etc/ssl")], is accepted today. Nothing can call it with real capabilities, because no program can hold both.

3. What the authority report computes, and what it promises

print_authority (crates/cancho/src/main.rs) unions func.performs over program.funcs, which pass 2 has already pruned to what main reaches. performs is kept before ownership discharges anything (the row check in lower_function, crates/cancho-ir/src/function.rs: let performs = performed.clone(); precedes performed.discharge(&authority)). So the report is a union of labels actually performed, each with the argument the capability’s type carried at the operation (granted_prefix in lower/memory.rs reads it from the Fs type at the call). It promises (authority.md §2): a label present is performed on some path, and an absent label is a proof.

Two consequences matter for this design:

  1. The report needs no new format to name two paths. If two capabilities Fs("/dev/urandom") and Fs("/etc/pg/ca.crt") can exist, reading through each performs a label with its own argument, and the report lists both: "effects": ["fs_read"] (the kinds are deduplicated) and two entries under "labels".
  2. The report is about what is performed, not what is held. A root that is held and only released never appears. This is why some options below (5 and 6) would make the report tight while leaving main’s text wide.

4. The options

what the program writesrowsreportcompiler costverdict
1. Status quoread the CA bundle from stdin or an argument-named fd, or hold Fs("")unchangedfs_read(""), or tight at the cost of deploymentnonewhat pg does now; a workaround, not a fix
2. Several children from one narrowlet (rng, ca) = narrow(fs, "/dev/urandom", "/etc/pg/ca.crt");two labels, one per capabilitytwo fs_read labels, no format changeone branch in narrow, no backend change expectedrecommended
3. A set of paths in one capabilityFs("/dev/urandom", "/etc/pg/ca.crt")-like, one capabilityone label whose argument is a listthe argument becomes a list a consumer must parsetype-literal syntax, Label::covers, every backend’s path check, a lending coercionno
4. A narrowing that yields a capability and the remainderlet (rng, rest) = carve(fs, "/dev/urandom");rest is Fs("") againtight if rest is only releasedthe remainder cannot be expressed in the prefix latticeno
5. Narrow from a borrow (a fork)fork_narrow(&!fs, "/dev/urandom"), the root keptas 2tightthe fork_heap exception extended to Fsno
6. Lend a root as a narrower &Fsentropy(f, ...) where f: &Fs("") and the parameter is &Fs("/dev/urandom")as 2tightffi_scope_attenuates’ coercion extended to Fs and the rootnot for this; a possible follow-up from a narrowed parent
7. Directory handlesopen_dir twicedir_read plus the prefix open_dir spentfs_read("") plus dir_readnonedoes not apply

narrow with more than one literal consumes the capability and answers a tuple of capabilities, one per literal, in the order written:

let (rng, ca) = narrow(fs, "/dev/urandom", "/etc/pg/ca.crt");
// rng: Fs("/dev/urandom"), ca: Fs("/etc/pg/ca.crt"); `fs` is spent.

Effect rows. Each child is an ordinary Fs(p). A helper that needs one borrows one and declares one label; a helper that needs both borrows two and declares both, which section 2 shows the checker accepts today:

fn setup[&r, &c, &o](rng: &r Fs("/dev/urandom"), ca: &c Fs("/etc/pg/ca.crt"), out: &!o [byte])
    -> [fs_read("/dev/urandom"), fs_read("/etc/pg/ca.crt")] int

The report. Unchanged code, two labels: fs_read("/dev/urandom") and fs_read("/etc/pg/ca.crt"), and no fs_read(""). The JSON shape, the text shape, bounded and under-a-grant.md’s reading of the filesystem dimension are the same.

Soundness. Every child is strictly inside the parent (each literal is checked exactly as a single narrow checks it: extends_path, and not equal to the parent), so the children together reach nothing the parent did not. The parent is consumed, so main’s text still says everything: one narrow line names every path the program will ever touch, and there is still no way back to the root. That keeps linearity-and-effects.md §7.4 and authority.md §1 as they are, which no other option that solves the problem does.

Code. The capability is zero-sized: the backends emit nothing for Builtin::Narrow (crates/cancho-codegen/src/body/expr.rs and crates/cancho-codegen-llvm/src/body/expr.rs, the Split | Narrow | ForkHeap | ForkClock arm returns no values). A tuple of zero-sized capabilities has no leaves, and a tuple carrying an Fs already checks, builds and runs on both backends (probe: a function fn pass(fs: Fs("/dev/urandom")) -> [] (Fs("/dev/urandom"), int) destructured with let (back, n) = pass(rng);). (Corrected in PR #364, section 11: the Narrow arm is never reached, because the checker answers an Expr::Tuple rather than a call, and neither backend needed a change.) The run-time path check is per operation and reads the prefix of the capability that operation borrowed (Expr::FileOp’s prefix, checked_path in both backends), so it does not change: each child is checked against its own literal. Probe at c0ad830: fs_read of /etc/hosts through Fs("/dev/urandom") traps (SIGILL, exit 132).

Cost to programs. A helper that wants two files takes two parameters. No library can be generic over a program’s path (there is no prefix polymorphism: effect-polymorphism.md), so a library that reads a path the program chooses still has to take the bytes, as cancho-pg’s TLS library already does, or take Fs(""). This option fixes programs, not that.

4.2 Option 3: a set of paths in one capability

Ffi and Signals already narrow to a set (foreign-authority.md §5.2, signals.md §2.1), so this is the shape with a precedent. It is the wrong one for paths:

  • A path has no separator to spare. Ffi’s set is comma separated because a library name is [A-Za-z0-9_.+-] (parse_scope, crates/cancho-ir/src/foreign.rs). A path may contain a comma, a space, a newline; only NUL is excluded. The set needs an escape or a new literal form, in types, in narrow and in rows.
  • Every path check becomes a loop over prefixes. checked_path compares the path with one prefix in one pass, in both backends (crates/cancho-codegen/src/body/memory.rs, crates/cancho-codegen-llvm/src/body/fs.rs) and for every user: FileOp, OpenFile, PathOp, ExecSpawn (checked_path is also called from body/net.rs, body/files.rs and body/process.rs). Each would take a set and trap only when no member matches.
  • Label::covers needs a set-of-path-prefixes rule, and a coercion for lending a set where a member is wanted (ffi_scope_attenuates for Fs), so a helper can declare one path.
  • The report’s argument stops being a path. fs_read("/dev/urandom,/etc/pg/ca.crt") is a string a supervisor must parse by this design’s escaping rule to know what was granted; under option 2 every argument stays one path.
  • The self-hosted checker’s declarations half would have to validate the set in a type, as pass1.cho does for Ffi.

What it buys over option 2 is one parameter instead of two for a helper that needs both paths. That is not worth a new literal syntax and a change in three code generators.

4.3 Option 4: a capability plus the remainder

“Everything except /dev/urandom” is not a point in the prefix lattice. The remainder would have to be Fs("") again (then this is option 5 with a different spelling) or a new type form for exclusions, with its own covers, its own run-time check and its own report syntax. And the program does not need the remainder: it needs a second named path, which option 2 gives directly.

4.4 Option 5: narrow from a unique borrow

fork_heap(&!Heap) -> Heap is the precedent (parallelism.md §8): mint an owned child from a unique borrow, the parent kept. For Fs it would be sound (the child is inside the parent) and the report would be tight, because the root would be held and released but never performed (section 3). It costs the thing §7.4 defends: after it, main still owns the root, and release(fs) at the end of main stops being “I will never touch the filesystem” beyond what was named. parallelism.md §9 records that fork_heap and fork_clock already ended “one holder per capability” for two capabilities that carry no value and so cannot be narrowed; extending that to the capability whose whole point is the value it carries is a larger step for no gain over option 2. It is also awkward to write: the child is minted inside a borrow mut fs as &!f in { ... } block and has to leave it through a var that cannot be initialised beforehand.

4.5 Option 6: lending a root as a narrower reference

Ffi has one coercion between capability types: a reference to a set may stand where a reference to a subset is wanted (FnLowering::ffi_scope_attenuates, lower/foreign.rs, called from the reference arm of expect_type in lower/mod.rs). The same for Fs would be sound in the same way, since the callee’s operations are checked against the callee’s literal. Allowed from the root, it would make section 1’s report tight without any change to narrow: main lends &Fs("") to entropy(fs: &Fs("/dev/urandom")) and roots(fs: &Fs("/etc/pg/ca.crt")), and only those labels are performed.

But ffi_scope_attenuates excludes the root on purpose (“a program that holds it must narrow before it calls out, which is where its libraries are written down”), and the same reason holds for paths: with the root allowed, main reads Fs("") and the paths live only in callees’ signatures. From a narrowed parent (lend &Fs("/etc/pg") where &Fs("/etc/pg/ca.crt") is wanted) it is a separate, smaller convenience and a possible follow-up; it does not solve this problem, because /dev/urandom and /etc/... share no prefix but the root.

4.6 Option 7: directory handles

open_dir(fs: &Fs(p), path) spends fs_read(p) and hands back a Dir whose operations perform the path-free dir_read (directory-handles.md §2). Opening two directories still needs one Fs covering both, so the report still says fs_read(""), now with dir_read beside it, and /dev/urandom is a file, not a directory to be beneath. Directory handles answer a different question (a link inside a prefix), and they compose with option 2: narrow(fs, "/dev/urandom", "/etc/pg") and then open_dir beneath the second child.

5. The recommendation

Option 2: narrow(cap, "a", "b", ...) consumes cap and answers a tuple of cap’s type narrowed to each literal, for the two path capabilities, Fs and Exec. Concretely:

  1. Arity. Two arguments is today’s narrow, unchanged, answering one capability. Three or more answer a tuple of n - 1 capabilities, in the order written. No edition: a call with three or more arguments is refused arity-mismatch by every edition today, so no accepted program changes meaning, and narrow is not a new name that could shadow a program’s own function (the reason Builtin::since gates new builtins).
  2. Each literal is checked as a single narrow checks it: a literal (capability-not-narrowable otherwise), extends_path(current, target), and not equal to current.
  3. The literals are pairwise unrelated: no two equal, and none inside another by extends_path. Refused capability-not-narrowable, naming both. Section 9 asks whether nesting should be allowed.
  4. Only Fs and Exec. Both are path prefixes with extends_path (processes.md §4.1 gave Exec Fs’s rule), and both have the same one-chain limit. Ffi already narrows to a set and lends narrower; Signals is a run-time claim (a second live claim of one signal is EBUSY, signals.md §3, which leaves disjoint children to a fork_signals nobody has asked for) and its set form already serves; Net is bounded by a textual prefix and its askers so far pass run-time hosts and ports (cancho-pg’s pooler holds Net("") for that reason, not for this one). For those three, more than one literal is refused capability-not-narrowable with a message naming the set form or saying it is not supported.
  5. A borrowed capability still cannot be narrowed, by either form.
  6. Fix Label::covers for path labels first (section 6). It is a separate, small change and should land before or with this one.

The program from section 1, after:

fn main(world: World) -> [] int {
    let Split { io, ffi, fs, heap, args } = split(world);
    release(io); release(ffi); release(heap); release(args);
    let (rng, ca) = narrow(fs, "/dev/urandom", "/etc/ssl/certs/ca-certificates.crt");
    ...
    release(rng); release(ca);
    return 0;
}

and its report: fs_read("/dev/urandom"), fs_read("/etc/ssl/certs/ca-certificates.crt"), nothing else under the filesystem.

What it does not fix. A path chosen at run time (an operator’s --ca-file) cannot be a literal, and narrow takes only literals (§7.4). cancho-pg’s examples/psql_tls.cho and cancho-hooks’ tlsx.setup take the CA path at run time and keep fs_read("") under every option above. The narrowest honest form for them is a fixed directory the operator puts the bundle in: narrow(fs, "/dev/urandom", "/etc/cancho-pg") and a run-time file name beneath it, which reports fs_read("/etc/cancho-pg"). Section 9 asks whether the toolbox wants that convention.

6. A latent row bug that option 2 makes reachable

Fixed in PR #359. Label::covers now compares fs_read, fs_write and exec labels with extends_path, the rule narrow uses (filesystem.md §1.1). The probe below is refused effect-not-declared on owner’s row (tests/reject/fs_owned_prefix_covers_sibling.cho, and exec_owned_prefix_covers_sibling.cho for Exec), and crates/cancho-ir/src/tests/label_covers.rs pins the rule, including covers agreeing with extends_path on every pair of a path table. Every one of the 673 .cho files in the repository checks identically before and after, so no in-repo program relied on the byte prefix (the two new fixtures are the only difference); the claim “refuses nothing that is accepted today” below is now measured, not argued. net_out/net_in keep a text prefix, as narrow does for Net (section 5, item 4). The text below is as written when the bug was found.

Label::covers (crates/cancho-ir/src/ir.rs) decides discharge: owning a capability drops every label it covers from the performed set before the row is compared (Effects::discharge). It has set rules for signals and ffi, and for every other label it falls through to

#![allow(unused)]
fn main() {
(Some(mine), Some(theirs)) => theirs.starts_with(mine.as_str()),
}

a byte prefix. For fs_read, fs_write and exec that is the comparison filesystem.md §1.1 says is wrong: /tmp is a byte prefix of /tmpevil and does not contain it. narrow uses extends_path; covers does not.

Probe, accepted at c0ad830:

fn evil[&e, &o](x: &e Fs("/tmpevil"), out: &!o [byte]) -> [fs_read("/tmpevil")] int {
    return fs_read(x, "/tmpevil/a", out);
}
fn owner[&e, &o](mine: Fs("/tmp"), x: &e Fs("/tmpevil"), out: &!o [byte]) -> [] int {
    release(mine);
    return evil(x, out);
}

owner performs fs_read("/tmpevil") and declares [], because owning Fs("/tmp") “covers” it. The row is inexact, which §7.3 forbids. The report is still right (it reads performs, before discharge, section 3), so this is a row bug and not a report hole.

No program can reach it today: it needs one function to hold two Fs capabilities that are not on one chain, and section 2 is why no program can. Option 2 is exactly what makes them. So the fix belongs with it: covers uses extends_path for fs_read, fs_write and exec. It changes no accepted program today, by the same argument (a function holds at most one Fs and at most one Exec, all on one chain, and on one chain extends_path and the byte prefix agree because every link was checked with extends_path). The test is the probe above as a tests/reject/ fixture, refused effect-not-declared.

7. What changes, and for whom

The checker. FnLowering::narrow gains a branch for more than two arguments, before the single-literal path: lower the capability once, check each literal, check the literals against each other, and answer Type::Tuple of Type::Named(PRELUDE_FS or PRELUDE_EXEC, [Type::Lit(target)]). Label::covers gains the path rule. No new rule tag: every refusal is capability-not-narrowable or arity-mismatch, both existing.

The backends. Nothing is expected (section 4.1); to be confirmed by running the fixtures on both.

The authority report and its checker. No change to print_authority, the JSON or the text. What changes is what programs can make it say. under-a-grant.md’s filesystem dimension gets a list of exact paths instead of "".

Existing programs. Source compatible: every accepted program checks to the same rows and the same report, and the covers fix refuses nothing that is accepted today (section 6). cancho-pg, cancho-hooks and the planned cancho-mqtt and cancho-gateway change only where they choose to: pg’s narrow TLS pool can name its CA path instead of reading the bundle from standard input.

The self-hosted checker (epic #295). The port has the declarations half (examples/selfhost/pass1.cho, foreign.cho, checker.cho) and bodies for scalars, references and slices (stages 3c and 3d); a call to a builtin is SKIP in examples/selfhost/body.cho (the call path answers SKIP when pass1.builtin_name matches). So:

  • Nothing to change now. tables.cho carries the builtins’ names and editions only (builtin_starts, builtin_lens, builtin_sinces, generated by crates/cancho-ir/src/tests/selfhost_tables.rs); option 2 adds no name and no edition, so the generated file and its drift test are unchanged. discharged_by (generated into prelude_labels) is unchanged: owning Fs(p) still discharges the same labels.
  • What stage 3e will need when it ports narrow at all: the n-ary branch (the per-literal check, the pairwise check, the tuple answer) and the path rule in its covers. The pairwise check is quadratic in the number of literals, which is a handful, and needs only extends_path, which is byte comparison the port already does for Ffi’s scope.
  • Option 3, by contrast, would have needed the declarations half changed now: a set in an Fs("...") type literal would be validated where pass1.cho validates Ffi’s scope.

8. What would prove it

The gate (cargo fmt --all --check, cargo clippy --all-targets -- -D warnings, cargo test --workspace) on both CI targets, and:

  • The report names exactly the two paths. In crates/cancho/tests/conformance/authority.rs, a program that narrows to /dev/urandom and to a CA file the test writes into a temporary directory (its path written into the program text as the literal), reads both, and checks cancho authority --output json: "labels" is exactly those two fs_read entries, no fs_read(""), "bounded": true. The same program with one of the reads removed lists one label: the report follows what is performed, not what is held.
  • It runs, and each child is confined, on both backends: the program reads both files and the bytes are right; a read of a third path through either child traps; a read of the CA path through rng traps.
  • Accepts (tests/accept/): three children; Exec with two program prefixes; children moved into a struct and released elsewhere; a helper declaring two fs_read labels.
  • Rejects (tests/reject/, each with its rule): a literal outside the parent; a literal equal to the parent; two equal literals; one literal inside another; a non-literal; a borrowed capability; more than one literal on Ffi, Net and Signals; a child used after the tuple is consumed (linear-use-after-move); a child not released (linear-value-unconsumed); and section 6’s owner (effect-not-declared).
  • Mutants, each of which must turn a test red: skip the per-literal extends_path; skip the pairwise check; answer the parent’s prefix for the second child; answer the children in the wrong order; revert covers to the byte prefix; accept the n-ary form on Ffi.
  • cancho-pg’s tests/narrow_tls.cho, rewritten to name a CA file, with its authority assertion changed to the two labels, as the downstream check that the toolbox gets what it asked for.

Nothing here needs a measurement of time or size: the change emits no code.

9. Open questions, each with a proposed answer

  1. One name or a new one? narrow(fs, "a", "b") beside narrow(ffi, "a,b") means two capabilities in one case and one capability over a set in the other. Proposed: keep narrow, because it is the same operation (attenuate, consume) with more than one result, it needs no edition, and the n-ary form is refused on Ffi, Net and Signals with a message that names the set form, so the two never meet on one capability.
  2. May one literal be inside another (narrow(fs, "/var/lib/app", "/var/lib/app/cache"), to keep the wide one and lend the narrow one)? Proposed: no, for now. It grants nothing the wider child does not, and the narrow-lending case is option 6 from a narrowed parent, which can be designed when a program asks for it.
  3. Exec in the first slice, or Fs only? Proposed: both. They share extends_path and the one-chain limit, and the branch is the same code; leaving Exec out would be a second slice for one comparison.
  4. Is one asker enough to build it? The bar is two (CONTRIBUTING.md, standard-library.md). Today there is one with a fixed path, cancho-pg’s narrow TLS pool; cancho-mqtt and cancho-gateway will read /dev/urandom and a trust store and are not written yet. Proposed: land the covers fix and this design now; build option 2 when the second program reaches its TLS setup, or earlier if pg’s maintainer wants narrow_tls.cho to read a file.
  5. Should the toolbox adopt a fixed trust-store directory (for example /etc/cancho/<tool>/) so that programs whose CA file an operator chooses can still narrow? Proposed: yes, as a toolbox convention rather than a compiler feature: it is what turns fs_read("") into fs_read("/etc/cancho/pg") for every program that takes --ca-file, and it needs option 2 to sit beside /dev/urandom.
  6. Should the covers fix land on its own first? Proposed: yes. It is a correctness fix to a rule (filesystem.md §1.1’s) that covers does not follow, it changes no accepted program, and its fixture is the only test in this design that can be written today without option 2 (it needs no main that reaches it).

Decided as proposed (2026-10-07): all six questions above were decided as proposed. On question 4 the decision is: land the covers fix and this design now (the fix is PR #359), and build the multi-literal narrow when cancho-mqtt and cancho-gateway reach their TLS setup, which is the second asker.

10. What was verified, and how

Read in the code at c0ad830:

  • FnLowering::narrow (crates/cancho-ir/src/lower/mod.rs): two arguments only (arity-mismatch otherwise); a literal; a borrowed capability refused; Ffi and Signals narrowed as sets (narrow_ffi, narrow_signals); Fs and Exec by starts_with and extends_path; equal refused; the value returned unchanged with the narrower type.
  • extends_path (crates/cancho-ir/src/defs.rs), discharged_by for Fs (both directions, file_*, dir_*).
  • Label::covers, Effects::discharge, Effects::missing_from (crates/cancho-ir/src/ir.rs).
  • lower_function’s row check, performs kept before discharge (crates/cancho-ir/src/function.rs).
  • print_authority (crates/cancho/src/main.rs): the union of performs over reachable functions.
  • granted_prefix, file_op, open_file (crates/cancho-ir/src/lower/memory.rs); checked_path in both backends; the Narrow arm in both backends’ call lowering.
  • ffi_scope_attenuates (crates/cancho-ir/src/lower/foreign.rs) and its caller.
  • The self-hosted port: examples/selfhost/tables.cho and its generator, body.cho’s handling of builtin calls.

Probed with cancho built from c0ad830 (macOS, AArch64):

  • section 1’s program checks and reports fs_read("");
  • a second narrow of fs is linear-use-after-move; narrowing Fs("/dev/urandom") to /etc/... and narrowing a borrowed Fs are capability-not-narrowable;
  • a row with two fs_read labels is accepted;
  • section 6’s owner is accepted;
  • a tuple carrying an Fs checks, builds and runs on both backends;
  • fs_read outside the narrowed prefix traps.

Not verified at that commit: that the backends need no change for an n-tuple answer from narrow (section 4.1; measured when it was built, section 11), and anything on Linux or under WASI. cancho-pg and cancho-hooks were read, not built.

11. As built

Built in PR #364, as section 5 recommends and section 9 decided (2026-10-07). Question 4 said to wait for the second asker; it was built ahead of cancho-mqtt and cancho-gateway on the maintainer’s instruction, with examples/tls_echo_fixed (section 11.1) as the in-repository program that uses it.

The rule as implemented (crates/cancho-ir/src/lower/narrow.rs, called from FnLowering::narrow):

  1. narrow(cap, lit) is today’s form, byte for byte the same code path. narrow(cap) and narrow() are arity-mismatch (“takes a capability and one or more literals”).
  2. With two or more literals: every argument after the first must be a string literal (capability-not-narrowable otherwise, at the offending argument). A borrowed cap, a type that is not a capability, and one that carries no value (Heap, Io, …) are refused with the single form’s messages.
  3. The capability must be Fs or Exec. On Ffi and Signals the refusal names the set form (narrow(ffi, "libc,libm"), narrow(signals, "TERM,INT")); on Net it says it is not supported. All capability-not-narrowable, all before any other check of the literals.
  4. Each literal passes the single form’s three checks in one shared function (check_narrowing): it starts with the capability’s path, it extends it at a / (extends_path), and it is not equal to it.
  5. Then the literals are compared pairwise, in the order written, and the first pair that is equal, or in which one extends_path the other, is refused capability-not-narrowable at the later literal, naming both. A literal ending in / therefore contains everything below it.
  6. The answer is Type::Tuple of the capability’s type narrowed to each literal, in the order written. cap is lowered once, as an owned use, so it is consumed.

Nothing else changed in the checker: no new rule tag, no new builtin, no new label, no new capability. Label::covers was already path-aware (PR #359).

No edition. docs/editions.md section 5 sorts changes into additive (a new name, field or label, which an older edition must not see), refining and tightening, and section 6.4 closes the vocabulary of an edition against new labels, builtins and capabilities. This adds none of those. narrow is an existing name in every edition; the forms it now accepts (three or more arguments) were refused arity-mismatch before, so no program that compiled changes meaning, no name a program declares can collide with it, and a file in any edition reads exactly as before. The multi-literal Exec form is reachable only from edition 7, where Exec is nameable, by the same rule that already gates the single form.

The backends needed no change. The checker answers Expr::Tuple { parts } with the lowered capability as the first part and an empty tuple for each further child, so the tuple has the right number of zero-sized components and no leaf. Both backends already lower Expr::Tuple by concatenating its parts’ leaves, and a capability has none; the Builtin::Narrow arm is not reached at all (the single form returns the capability expression itself and always did). Measured by running the fixtures on Cranelift and LLVM (conformance/narrow_many.rs: two and three children, the order, the run-time confinement, the tuple passed whole and stored in a struct, Exec). The unmeasured part: WASI (docs/wasm.md) was not run; a capability is zero-sized there too.

What building found.

  • A capability inside a tuple or a struct parameter is not “owned” in the sense that discharges a label: fn whole(p: (Fs("/a"), Fs("/b")), ...) must declare fs_read("/a") and fs_read("/b") even though it releases both, where a function taking Fs("/a") directly declares []. That is how rows treat any aggregate, not something this adds, and it is the conservative direction; section 4.1’s helper (two borrowed parameters) is unaffected. tests/accept/narrow_three_paths_forms.cho writes it.
  • open_dir(fs, path) takes its path at run time and traps outside the capability’s prefix as every file operation does, so a program that narrows to a directory and opens that same literal is exactly confined (section 11.1, and conformance/narrow_many.rs’s TLS-setup case, which also checks open_dir of /dev through the directory’s child traps).
  • /tmp and /tmpevil are unrelated by extends_path, so narrow(fs, "/tmp", "/tmpevil") is accepted and each child is confined to its own tree; narrow(fs, "/tmp", "/tmp/a") is nested and refused.

Mutants (each turned a test red): skipping the per-literal check; skipping the pairwise check for nesting and, separately, for equality; answering the parent’s path for the children; answering the children in the wrong order. Allowing the multi-literal form on Ffi is killed by tests/reject/narrow_many_on_ffi.cho (see the PR for the run). Reverting Label::covers to the byte prefix was already pinned by label_covers.rs (PR #359).

The self-hosted checker (epic #295). examples/selfhost/ ports declarations and bodies for scalars, references and slices, and a call to a builtin is SKIP in body.cho. It does not port narrow, so it does not need the multi-argument branch now and is unchanged; tables.cho is unchanged (no name or edition was added) and its drift test passes. Stage 3e, when it ports narrow, will need the branch described in section 7: the per-literal check, the pairwise check (quadratic in a handful), the tuple answer.

11.1 The payoff: examples/tls_echo_fixed

examples/tls_echo reads its entropy from /dev/urandom and its certificates from a directory the operator names with --dir, so its main holds Fs("") and the pinned report says fs_read("") (conformance/tls_echo.rs). examples/tls_echo_fixed/tls_echo_fixed.cho is the same server with the directory fixed at build time as a literal, /etc/cancho/tls_echo (section 9, question 5’s convention): main is let (urandom, certs) = narrow(fs, "/dev/urandom", "/etc/cancho/tls_echo"); and the pinned report has fs_read("/dev/urandom") and fs_read("/etc/cancho/tls_echo") where it had fs_read(""), and every other label the same (conformance/tls_echo_fixed.rs). The server body (serve) moved, unchanged, from tls_echo.cho to echo.cho so that the two share it (the duplication test refuses a copy); front.cho gained parse_flags, parse without the --dir requirement, and parse is parse_flags plus that requirement, so tls_echo and https_hello behave as before.

What it costs. The directory is part of the build: to serve certificates from elsewhere, edit the literal and rebuild (a deployment that wants /etc/cancho/<name> per instance builds one binary per name). --dir is refused (usage error 2) because the program holds no more of the filesystem than the literal; --identity <subdirectory> still names a subdirectory beneath it at run time, through the directory handle. Nothing else about the server changes.

[budget]

Status: settled, and the answer is that it does not belong here.

§12 lists it as carried over from Lex and unspecified: “plainly a capability carrying an integer; what it costs at runtime, and whether it is checked or merely accounted, is unanswered.”

The epic mentions it once — “Carried from Lex: examples-as-tests, [budget], effect declarations as contract” — and nowhere else. It is a name, not a design. This settles what it would have to mean, and why the thing it means is already built somewhere else.


1. Three questions, not one

A capability language answers may this program reach X? A budget answers how much may it consume? Those are different questions, and lex-os — the first real target for this language — already separates them into three layers:

LayerQuestionWhere
Type checkMay this reach the network / the filesystem at all?lex-os-check, against the manifest’s Grant
PerimeterWhich host, which path?The kernel firewall and the sandbox policy
BudgetHow much?The supervisor, charged per mediated command

That is not a reading of mine; lex-os-check’s own header says it: “The type-check answers ‘may this box touch the network at all?’; the perimeter answers ‘which host?’. Two layers, one grant.” The budget is the third, and it is charged in lex-os-supervisor’s mediation loop — log → reversibility → perimeter → budget → charge → allow.

cancho owns the first layer, and owns it well: the effect row is the answer, cancho authority reports it, and docs/authority.md §2 shows it falls out of reachability the compiler already computes.

2. The units settle it

lex-os’s Budget is:

#![allow(unused)]
fn main() {
pub struct Budget {
    pub wall_clock_secs: u64,
    pub max_commands: u64,
    pub max_money_cents: u64,
    pub max_api_calls: u64,
}
}

None of those is a property of a program’s text. Wall-clock seconds depend on the machine; money in cents depends on what an API charges; commands and API calls are counted by the thing mediating them. A type system reads source, and no amount of reading source tells you how many cents an action will cost.

So a [budget] row could only ever carry a proxy — instruction counts, call counts, allocation counts — and a proxy for the thing you care about, checked in units nobody wrote the limit in, is worse than no answer.

3. And both implementations conflict with a stated commitment

Dynamic fuel — a counter decremented per operation, trapping at zero — is a hidden per-operation cost. The epic’s performance expectation says the one structural cost this language accepts is defining away UB, at “low single-digit percent”. A fuel counter is a second one, larger, and paid by every program including the ones that never asked for a budget.

A static bound — -> [budget(500)] T, summed by the compiler — needs a cost model. The epic commits to “Cranelift (dev) → LLVM (release)”: two backends, different generated code, different real costs. A bound in machine units cannot be stable across them, and a bound in abstract units is arbitrary — a number that means nothing in particular, checked exactly.

4. What a program does today, and why it is enough

examples/pipeline.cho runs jobs against a budget right now, with no language support:

fn admit[&t](job: Job, tally: &!t Tally, budget: int) -> [] int {
    if tally.spent + cost <= budget { ... }
}

An integer, threaded, checked where the program decides an action costs something. That is policy, and this language has put policy in programs every time the question has come up: no realloc, so the doubling policy is std.buffer’s; no destructor, so ending a resource is the owner’s; no generic drop, so collections.md §4 leaves ending a T to whoever knows what it means.

A budget is the same shape of decision. What counts as an “operation”, what it costs, and what happens at zero are all things the program knows and the language does not.

4.1 The one thing a capability would add

Unforgeability. Any program can write Tally { spent: 0 }; nothing can conjure an Io. So a Budget capability would mean you cannot grant yourself more, which is a real property and the reason Io is a capability rather than a struct.

It fails on where the number comes from. split(world) would have to hand out a Budget(n), and the runtime does not know n — it would have to come from an environment variable or the command line, which is the language making a policy choice, or be unlimited, in which case it is not a budget.

The number comes from whoever is doing the budgeting, and that is not the program. It is the embedder — which for Lex is its host, and for cancho is lex-os, where the number is in a signed manifest and the charge happens before the effect runs.


5. So the answer is no, and here is what was wanted instead

The useful half of the request is not enforcement, it is legibility: a supervisor deciding whether to run a program wants to know what the program can reach, in a form it can check against a grant.

That is cancho authority, and this slice gives it a machine-readable form:

$ cancho authority examples/tour.cho --std --output json
{
  "effects": ["args", "ffi", "fs_read", "fs_write", "heap", "io_write"],
  "labels": [
    { "name": "args", "argument": null },
    { "name": "ffi", "argument": "libc" },
    { "name": "fs_read", "argument": "/tmp" },
    ...
  ],
  "foreign_symbols": ["labs"]
}

The shape is deliberate: effects is the distinct kinds, which is the coarse question a grant is written against, and labels keeps the narrowing for the precise one. lex-os-check’s CheckReport draws exactly that line for Lex programs —

#![allow(unused)]
fn main() {
pub struct CheckReport {
    pub effects: Vec<String>,
    pub net_hosts: Vec<String>,
}
}

— so a cancho program can be checked against a manifest the same way, by the same wall, without cancho learning what a cent is.


6. Open

QuestionWhy it waits
Wiring this into lex-os-checkA change in that repository, against a Grant whose vocabulary is its own. §5 is the half cancho owes
An --output json for checkDiagnostics as data, which is a separate and larger surface than one report
A cost model, if a static bound is ever wanted§3. It needs one backend, or a unit that is honest about being abstract

What a program can reach

Status: measured, not argued.

The question this answers is the one every language gets asked and almost nobody answers honestly: can it do X? — where X is a REST API, a database client, a TLS connection, a thread pool.

The usual answer is a feature list. This document answers by building one of the X’s and reporting what happened. §1 is the program, §3 is the line it ran into, and §5 is the part of the story the effect row cannot tell.


1. A REST endpoint, and it works

examples/serve/ opens a TCP socket, binds a port, accepts a connection, routes the request line and answers with JSON:

$ cancho vcs fetch --lock examples/serve/net.lock \
      --store packages/net-sockets/.cancho-vcs -o /tmp/net-sockets
$ cancho build examples/serve/serve.cho /tmp/net-sockets/*.cho --std -o serve && ./serve 8080 &
$ curl -i http://127.0.0.1:8080/health
HTTP/1.1 200 OK
Content-Length: 11
Connection: close
Content-Type: application/json

{"ok":true}

The fetch line pulls in net.sockets (packages/net-sockets/), this repository’s first real cancho-vcs package (docs/package-system.md §6): the eight extern fns against libc it declares used to live in serve.cho itself, byte-for-byte duplicated in examples/results_stub/results_stub.cho, and now live in one published, locked, fetched file both import instead.

That is a real socket, a real accept, and a real client. The test harness makes the same request from Rust over loopback and checks the status line, the body and the declared length (an_http_server_written_in_cancho_answers_a_real_request), because a claim of this shape is worth exactly what it is tested with.

And it was worth it. The first version read the request with a single read and routed whatever arrived — which is wrong, because one read returns what has arrived rather than what was sent. On an idle machine it passed thirty runs in a row; under load it answered 404 to a request for /health about one run in six. Found while running the suite under load for an unrelated reason, and fixed with read_request (docs/porting.md §5). The socket is real, and so are the mistakes a real socket lets you make.

Nothing was added to the language for it. There is no socket type, no Net capability, no async runtime, no HTTP module in std. The program is 129 lines of code, of which eight are extern fn declarations against libc, and everything else is the slices, regions and capabilities that were already here.

Under valgrind, answering a live request:

total heap usage: 1 allocs, 1 frees, 65,536 bytes allocated
All heap blocks were freed -- no leaks are possible
ERROR SUMMARY: 0 errors from 0 contexts

One allocation, and it is the arena’s first chunk (ARENA_CHUNK). The request buffer, the response buffer and the sockaddr all come out of it, and it is released by leaving the region block. That is not tuning — it is what a program looks like when the allocation strategy is written in the source.

1.1 Why it works, stated as a rule

What decides whether a program is writable is not a feature list. It is whether the authority it needs has a name.

Sockets are libc. libc has a name — Ffi("libc") — and that capability has existed since M2. So the program exists, and it would have existed on the day the capability did. No feature had to be anticipated, because the thing that was designed was the authority, not the feature.

This cuts both ways, and §5 is the cut.


2. So the short answer

WantTodayBecause
A REST/HTTP serverYes — examples/serve/Sockets are libc, and every argument is a scalar or a byte slice
An HTTP clientYesconnect is the same shape as bind
Reading and writing filesYes, without FFI at allFs(prefix) builtins, narrowable to a directory (filesystem.md)
A command-line toolYesArgs, examples/lines.cho, examples/wordfreq/
Several processesYesfork returns an int, and an int is a value
ThreadsNoFunction values exist now, but pthread_create’s void *arg is still c_ptr-opaque and un-crossable (§3.3); threads.md’s own spawn/join is proposed, not yet built
TLSYes (corrected below, twice)SSL_CTX */SSL * cross (§3.1), and -lssl -lcrypto links (docs/foreign-linking.md) — examples/tls_client/, a real handshake
A Postgres clientNoPGconn * (§3.1) — the same handle-shaped gap TLS’s own correction closes, and now the same link-time gap too; not yet spent on this one
malloc-style allocationNo, and it is not wantedvoid * (§3.1); the heap is a capability with box (heap.md)
Floating-point arithmeticNoThere is no float type yet. Dated, not structural (§4)

Corrected: opaque-pointers.md. §3.1’s own “everything that hands back an opaque handle is therefore out” is no longer true as stated. c_ptr is a real, distinct type — an opaque handle a foreign call returns or takes, checked for equality and nullness only, never dereferenced — which is exactly the shape §3.1.1 already accepted for an “integer-shaped pointer” like a file descriptor, made a first-class type instead of a convention. TLS is marked Partial rather than Yes for a reason that has nothing to do with the type system: cancho build only ever links libc, and a real TLS client needs -lssl -lcrypto, which the build pipeline has no way to ask for yet (opaque-pointers.md §5). A Postgres client is unaffected by this correction only because nothing has spent the same effort grounding libpq’s real signatures and writing its own accept fixture — the type-level gap PGconn * cited is the same one c_ptr closed.

Corrected again: docs/foreign-linking.md. The link-time gap above is closed too — -l/-L on build/run, and examples/tls_client/ is a real handshake and encrypted round trip against openssl s_server, on both backends. TLS moves from Partial to Yes above. A Postgres client stays No: the type-level and link-time gaps are both closed in general now, but nothing has yet spent the effort grounding libpq’s own signatures the way §1 did for OpenSSL’s.

Read down the “because” column. Two of the remaining three flat no’s share one sentence.


3. What is out of reach, and the one rule underneath it

3.1 A pointer does not come back

extern fn getenv[&f, &n](ffi: &f Ffi("libc"), name: &n [byte])
    -> [ffi("libc")] &n [byte];
error: `getenv` returns `&r1 [byte]`, which has no agreed layout across a
foreign boundary; a foreign result is `int`, `bool` or `c_ptr`, and a C
function that returns nothing is declared `int` and its result discarded

A foreign result is a scalar. Not because a pointer is hard to return, but because of what this language promises about the ones it already has: every reference here carries a region, the region says how long it is valid, and the checker enforces that. A pointer arriving from C carries none of that. Accepting one would mean either inventing a region for it — a lie the checker would then enforce — or admitting a reference the checker does not track, which is the undefined behaviour this whole language is built to define away.

So the rule is:

A value crosses into a cancho program only if the checker can say where it came from.

An int qualifies: it is a number, and a number’s provenance is nothing. A &r [byte] going out qualifies: the region is the caller’s and the callee is handed a pointer and a length that cannot disagree (strings.md §6). A pointer coming in does not, and that is the whole of the list in §2’s right-hand column.

Everything that hands back an opaque handle was therefore out: OpenSSL, libpq, libcurl, FILE *, dlopen. Not one of them for a reason of its own. Corrected: opaque-pointers.md §3 opens exactly one shape back in — a handle that is returned, passed around and compared for nullness, never dereferenced — which does not weaken this rule; it satisfies it the same way accepting a plain int already does, by claiming nothing the checker cannot back up.

3.1.1 And smuggling one does not help

The declaration is trusted — nothing checks a cancho signature against the C header — so malloc can be declared to return int and the compiler will believe it:

extern fn malloc[&f](ffi: &f Ffi("libc"), n: int) -> [ffi("libc")] int;

That compiles. What it buys is nothing:

let p = malloc(f, 64);
let s: &static [byte] = p;     // error: expected `&static [byte]`, found `int`

There is no cast, no from_raw, no transmute. A reference is produced by alloc, alloc_slice, borrow, a literal, an index or a subslice, and every one of those knows its region by construction. The soundness boundary is not the FFI check — it is the absence of a raw-pointer constructor. The FFI check is there so a programmer meets the rule at the declaration rather than three hours later.

An integer-shaped pointer can be handed straight back to C, which is how fork’s pid and a file descriptor already work. It just cannot be dereferenced here.

3.2 A struct does not cross either

bind wants a struct sockaddr_in. The program builds one:

let addr = alloc_slice[scratch](16, byte_of(0));
addr[0] = byte_of(2);                                 // AF_INET
addr[2] = byte_of(number / 256);                      // port, network order
addr[3] = byte_of(number - (number / 256) * 256);

Four lines, and they are the least defensible four lines in the program: nothing checks that 16 is the size, that 2 is AF_INET, or that the port is where byte 2 says it is. A #[repr(C)] equivalent would check all three.

It is worth being clear about what this costs and what it does not. It costs correctness at the boundary, which the programmer supplies by reading a header. It does not cost safety: the bytes are a slice, the slice has a length, bind is told that length, and a wrong guess is a kernel EINVAL rather than a read off the end of the buffer. That is the same trade strings.md §6 makes for write — the length C is told is the length the bounds check enforces — applied to a struct instead of a string.

3.3 Function values exist now; that still does not unlock threads

let h = g;

Corrected: function-values.md §4.2. This used to be refused outright; a captureless, non-generic, top-level function is a real value now, and h(...) calls through it. Still a scope decision rather than a soundness claim, and still load-bearing for the question people actually ask — it is still why threads are out, for a reason that turned out to be sharper than “there is nothing to pass”: threads.md §1 shows that pthread_create’s one void *arg is exactly §3.1’s own un-crossable pointer, so even with function values a callback that takes and returns void * is unreachable for the same reason malloc is. What function values unlock instead is threads.md’s own spawn/join, a primitive that never crosses the C ABI at all.

So examples/serve/ still answers one request at a time. A pre-forking server is expressible today with the same eight declarations plus fork and waitpid; a thread pool needs threads.md’s own primitive, proposed and not yet built.


3.4 A return crosses at a width this document never checked

§2 states the rule as “everything else crosses at cancho’s own widths,” and that is true and safe for a parameter: this backend writes a full 64-bit register, C reads the low bits its own narrower parameter type declares, and the bits above that are never read, so a caller writing more than the callee asked for costs nothing. A return is not the same shape, because the callee is the one who fills the register, and this document had never asked what a real int-returning C function leaves in the bits above 31.

The answer, found building examples/vsock/ and confirmed against real close/connect calls, checked byte for byte against equivalent C: sometimes zero, not sign-extended. close(999) (an invalid fd) on this platform’s libc compiles to a 32-bit store into the low half of the return register; x86-64’s own rule for writing a 32-bit register clears the upper 32 bits of its 64-bit parent as a side effect, so the register holds 0x00000000FFFFFFFF where a signed 64-bit reading expects 0xFFFFFFFFFFFFFFFF. Declaring the call’s return type as this backend’s own 64-bit int (abi.rs’s leaves, matching every other int in the program) and reading the full register back is exactly the bug this produces: 4294967295 where the real, POSIX-documented answer is -1.

Some extern declarations never hit this. labs returns C’s long, which really is 64 bits on both of this project’s targets, so reading the full register is correct there – confirmed by extern_fn_labs_computes_the_real_answer, which is exactly why that test never caught the bug a socket call’s own < 0 check did. read/write return ssize_t, also genuinely 64 bits (a transfer can exceed 2^31 bytes, however rarely in practice), so they must stay wide too. There is no way to tell these apart from the declaration’s int alone — close, connect, labs and read are four different real C widths (int, int, long, ssize_t, the first two the same by coincidence) spelled identically at the cancho boundary, and reading uniformly at 64 bits is silently right for two of them and silently wrong for the other two whenever the value is negative.

c_int names the common case explicitly, in return position only:

extern fn close[&f](ffi: &f Ffi("libc"), fd: int) -> [ffi("libc")] c_int;

Inside the program close(...) is still exactly an int — every assignment, comparison and arithmetic operation sees the same 64-bit value it always would. The only difference is at the call itself: the backend declares the foreign function’s return as a real 32-bit int (matching the platform C ABI on every target this project builds for) and sign-extends the result back to 64 bits itself, reading only the bits the ABI actually promised were meaningful rather than trusting whatever the callee happened to leave above them. c_int is not a general type — writing it anywhere but an extern fn’s return position is an unresolved name, the same refusal any other undeclared type gets.

Every existing socket declaration was migrated, and it was not cosmetic. examples/serve/’s and examples/collect/’s own bind/ accept/socket/close checks, and examples/fetch/’s and examples/report/’s own socket/close checks, all compare a foreign result against 0 with < somewhere in their error handling. Every one of those checks had this exact bug, silently, the whole time — untested only because the shipped test suite exercises their success paths, not a real bind/accept failure. read/write are unchanged: their real return is ssize_t, not int, and belongs on the wide side of this split.

tests/accept/foreign_narrow_return.cho checks access’s real -1 directly, on both backends; crates/cancho/tests/conformance/backends. rs’s the_two_backends_agree_on_a_narrow_foreign_return is the same claim through the CLI.


4. What was merely missing

Two things this program ran into that were not design at all, only absence. Both are fixed in the commit that adds it, which is the point of writing the program rather than reasoning about it.

\r was not an escape. HTTP’s line ending is CRLF. The literal could not say so, so the first draft assembled byte_of(13) into a buffer by hand — a protocol’s own separator, spelled as a number. strings.md §4 listed five escapes because nothing had asked for a sixth, which was true right up until something did. It is a byte with a spelling, not an encoding claim, so it belongs with \t rather than with \u.

The foreign-result refusal named a type the grammar refuses. It said a foreign result is int, bool, or () — and tuples.md §4 keeps () out of the source grammar deliberately, so a reader who took the advice was told a tuple has two components or more; there is no (). Two correct rules and a loop between them. The message now names what can be written and says what to do about a void function.

Neither is interesting. Both were found the same way, and neither would have been found by reading the documents.

4.1 And a third, which this document was credited with naming

examples/serve/ has four distinct non-zero exits and says nothing on any of them, because there was no standard error to say it on. Two other documents — file-handles.md §1.2 and ROADMAP.md — cite “reach.md §6’s standard-error gap” as the blocker for their own work.

There was no such row. §6 has five and none of them is this; the second citation was written from the first. A gap two documents think is recorded, and no document records, is worse than one nobody has noticed: it looks tracked.

standard-error.md is where it is written down, with what the absence cost measured, and closed — one more label on Io, no new capability, for standard-input.md §2’s reason. It reached this document’s programs too: base64 now says base64: invalid input where it used to exit 1 in silence. examples/serve/’s four remain, because a server’s diagnostics want somewhere to go, and that is a question about logging rather than about a stream.


5. Where narrowing stops

Here is the price of §1.1, and it is the part worth arguing about.

examples/serve/ listens on a port. Its authority report says:

performs
    args
    ffi("libc")
never touches
    the console
    the filesystem
    the heap

The row does not mention the network, and it is not wrong. The row is exact about what the program was granted: it calls a C library. It is silent about the network because libc is not an authority domain — it is every authority at once. Ffi("libc") grants sockets, and it grants them in the same breath as abs.

This is the one place the narrowing story runs out. Everywhere else it is exact in both directions: fs_read("/tmp") is a directory and not the filesystem, io_write is not io_read, an absent label is a proof (authority.md §2.2). At the foreign boundary the finest thing that can be said is a library’s name, and a library’s name is coarse.

5.1 Would a Net capability fix it?

No, and it would make the report worse. A Net capability would sit next to Fs and Io and mean this program may use the network — and examples/serve/ would still not hold one, because it reaches the network through libc, which is where the network is. Adding Net without taking sockets out of libc produces a program that opens a socket while declaring no net, which is a row that lies.

Taking sockets out of libc means builtins — socket, bind, listen, accept as prelude operations gated by Net(host), the way filesystem.md §2 does files. That is a real design and it is the right one if the network is a domain this language wants to mediate itself. It is also strictly larger than this document, it is the same shape as the filesystem’s, and the argument for it is not “the row should be finer” — it is “a host is a thing worth narrowing to”, which is exactly what lex-os’s perimeter says when it answers which host?.

Filed in §6 rather than answered here.

Promoted (#73): under-a-grant.md §5. The argument above frames this as a question of taste — whether a host is worth narrowing to — and that framing is now incomplete. Measured against lex-os’s real grant, two of its three dimensions cannot be decided without it: a cancho program that runs a network server reports args and ffi, so a grant saying network: None has nothing to refuse it with. The row is the prerequisite for cancho code running under a grant at all, not a refinement of the report.

And corrected (#74): net.md §1. “A host is a thing worth narrowing to” is the outbound question, and it describes neither side. examples/serve/ — the only network program here — declares bind, listen and accept and no connect: it is pure ingress. A lex-os grant’s network fields are network and egress, and both are outbound; there is no inbound notion in it at all.

Which is right, for a reason that decides the capability’s shape: outbound is the program’s choice and belongs in the type, and inbound is not — who reaches a listening port is routing’s answer, which is why lex-os enforces it with iptables on the tap device rather than in the grant. So Net has two axes: a host outbound, a port inbound, and nothing more on the inbound side.

5.2 What covers the difference today

The foreign symbol list, which exists for a different reason and turns out to answer this:

{
  "effects": ["args", "ffi"],
  "labels": [{ "name": "ffi", "argument": "libc" }],
  "foreign_symbols": ["accept", "bind", "close", "listen", "read",
                      "setsockopt", "socket", "write"]
}

A supervisor reading socket, bind, listen, accept knows it is being asked to run a server, and knows it without trusting a word the program says about itself: the list is what pass 2 emitted, which is what main reaches, which is what the binary can call (authority.md §2).

Corrected (#73): under-a-grant.md §3. The first half of that sentence stands and the second does not. The list is a proof about names — every foreign call needs a declaration, so it is exactly the set reachable from main — and a heuristic about domains, because the step from a name to a domain is the declaration, and the declaration is the program’s to write. Six lines make the point:

extern fn syscall[&f](ffi: &f Ffi("libc"), n: int, a: int, b: int, c: int)
    -> [ffi("libc")] int;
r = syscall(f, 41, 2, 1, 0);        // SYS_socket

It type-checks, opens a socket, and reports {"effects": ["ffi"], "foreign_symbols": ["syscall"]}. Against a cooperative program the heuristic is worth having. Against the threat model lex-os exists for, it is not a wall. The row is a grant question and the symbol list is a what will it actually do question, and at the foreign boundary the second is the one with the answer.

Built on (foreign-authority.md). The symbol list is now unbounded_by in the report, each symbol paired with the scope its declaration claims, and one program can hold several libraries. Ffi("libc") and a foreign function with no capability at all both used to be accepted; the second no longer is.

That is a report and not a type, and it should stay a report until §5.1 is answered. A row that said net today would be a row that could not be checked.


6. Open

QuestionWhy it waits
A port with resources and depthporting.md §6. base64 was the first program here that already existed, and it answered this document’s §1 from the other side — but it exercised no linearity, no heap and a call graph three deep, so what it shows about reach is narrower than it looks
Sockets as builtins under Net(host)§5.1. The right shape and a large one: it means taking a domain out of libc and mediating it here, the way filesystem.md §2 did for files. The argument turns on whether a host is worth narrowing to, not on the row being finer
A checked foreign signature§3.2. A declaration is trusted against the C header. A generator reading real headers would check it; writing one by hand would not
float§2’s last row. No design question that is known — it is arithmetic, a Cranelift type and a literal syntax — and it would let std.math mean what the name suggests
Function values§3.3. Named as M1 scope rather than refused, and the thing it unlocks first is threads, which wants more than a pointer. function-values.md §5 lists what more: a callback C can call has row [] and scalar parameters, and pthread_create passes a void *

7. The suite

FixtureRule§
foreign_result_is_a_pointer.choA foreign result is a scalar, and a handle is a pointer3.1
foreign_effect_undeclared.choThe row still has to say ffi1.1
ffi_without_capability.choAnd the capability is still the only way in1.1
TestShows
an_http_server_written_in_cancho_answers_a_real_request§1, over a real socket, both routes, with the declared length checked against the body
the_authority_report_names_the_syscalls_the_row_cannot§5.2: the row is ffi("libc"), and the symbols are what say server
examples/serve/serve.choThe program

c_ptr: an opaque handle that crosses without being trusted

Status: built (edition 3). §3’s proposal is implemented as written: Type::CPtr, valid in both extern fn parameter and return position, val, equality/nullness only, with null_ptr() as the one non-foreign producer. tests/accept/opaque_pointer.cho exercises the full round trip — a foreign call returning a handle, the same handle passed to a second foreign call, and a null comparison on a real failure — through real libc (fdopen/fclose) on both backends (crates/cancho/tests/conformance/backends.rs’s the_two_backends_agree_on_an_opaque_pointer); three tests/reject/ fixtures pin arithmetic, coercion, and the “not a general type” rule §4 predicted. §5’s TLS example was not built at the time: it needed the build pipeline to link a library beyond libc, which nothing in this project had ever needed before. Corrected: docs/foreign-linking.md closed that gap and built the example — examples/tls_client/, a real handshake and an encrypted round trip against openssl s_server on both backends, that document’s §4 has the transcript. §1’s real OpenSSL signatures and §3’s design are otherwise exactly what shipped — reached by writing a first accept fixture against fopen/fopen’s two string parameters, watching it segfault, and finding the actual bug: a pre-existing, c_ptr-unrelated gap in how a &r [byte] parameter crosses (§4 note below), not anything wrong with the type this document proposed.

docs/reach.md §3.1 named the rule this document has to satisfy — “a value crosses into a cancho program only if the checker can say where it came from” — and then listed everything that rule puts out of reach: OpenSSL, libpq, libcurl, FILE *, dlopen. This is the design for letting the first of those in, TLS specifically, without weakening that rule. It was written before any code, per this project’s own “design before code” discipline (CLAUDE.md), and grounded against the real OpenSSL 3.0 header and a live loopback handshake, not a remembered signature.


1. What TLS actually asks for

docs/reach.md §2’s table already answers “can it reach TLS?” with No, and names the reason: SSL_CTX * (§3.1). The real OpenSSL client handshake this document verified against (/usr/include/openssl/ ssl.h, OpenSSL 3.0.13, confirmed present in this environment via openssl version, pkg-config --exists openssl, and every symbol below resolved live in libssl.so.3 with nm -D) is nine calls:

const SSL_METHOD *TLS_client_method(void);
SSL_CTX          *SSL_CTX_new(const SSL_METHOD *meth);
SSL              *SSL_new(SSL_CTX *ctx);
int               SSL_set_fd(SSL *s, int fd);
int               SSL_connect(SSL *ssl);
int               SSL_write(SSL *ssl, const void *buf, int num);
int               SSL_read(SSL *ssl, void *buf, int num);
int               SSL_get_error(const SSL *s, int ret_code);
int               SSL_shutdown(SSL *s);
void              SSL_free(SSL *ssl);
void              SSL_CTX_free(SSL_CTX *);

Split these by what §3.1 already handles and what it does not:

  • int fd, int num, every int return — already int, already crosses today. SSL_write/SSL_read’s void *buf/const void *buf are not opaque handles at all; they are the same “a length and a pointer that cannot disagree” shape write/read already use (strings.md §6), so they cross as &r [byte] exactly like an existing read/write declaration, unchanged by this document.
  • const SSL_METHOD *, SSL_CTX *, SSL * — three distinct opaque handle types, each produced by one call and consumed by the next few, never inspected, never dereferenced, only threaded through in order and eventually freed. This is the whole gap. Three call sites return a handle (TLS_client_method, SSL_CTX_new, SSL_new); five take one as a parameter (SSL_set_fd, SSL_connect, SSL_write, SSL_read, SSL_get_error, SSL_shutdown, SSL_free, SSL_CTX_free — eight, not five; corrected count below in §3).

So the feature this document proposes is narrow on purpose: a value that can be returned by and passed to a foreign function, compared for nullness, and nothing else. Not a general pointer type, not a malloc story (reach.md §3.1 is right that nothing here wants that), not a struct-crossing story (§3.2 already has its own answer, unrelated).

2. Why c_int is the wrong template, and where it still helps

c_int (reach.md §3.4) looks like the closest precedent — a named type that exists only to make one foreign crossing honest — but its mechanism is the opposite of what a handle needs. c_int is recognized by name at exactly one syntactic position (extern fn’s return type, crates/cancho-ir/src/lib.rs) and collapses immediately to plain Type::Int for everything downstream: every assignment, comparison and arithmetic operation on a c_int result sees an ordinary 64-bit int from the very next line on. That collapse is exactly right for c_int, because the thing it is narrowing — a real C int32_t that gets sign-extended once at the boundary — really is an integer afterward, with real arithmetic and real comparisons against 0.

A handle is not an integer afterward. let s: &static [byte] = ssl_ctx; must stay an error the same way reach.md §3.1.1 shows it staying an error for malloc’s pointer today, and ssl_ctx + 1 must be an error too — neither of which c_int’s collapse-to-Int trick can give, because collapsing to Int is precisely what makes arithmetic and comparison legal. So c_ptr needs to remain a distinct type all the way through the program, not a spelling that resolves away at parse time. What c_ptr does keep from c_int’s precedent: a foreign return is exactly where extra scrutiny already lives in this codebase (§3.4’s whole point), and the same trust posture applies here — the declaration is checked for shape, never against the real header (reach.md §3.1.1: “the declaration is trusted — nothing checks a cancho signature against the C header”).

3. The proposal

A new type, Type::CPtr, spelled c_ptr in source. Unlike c_int, it is a real type, not a resolve-away name:

  • Valid in both parameter and return position of an extern fn declaration — c_int is return-only because only a return has the width problem; a handle has no width problem (it is opaque, never read), but it flows in both directions (SSL_CTX_new returns one, SSL_new takes one), so the restriction c_int needs does not apply here.
  • val, not linear. The existing capability/region system exists to track ownership of something this program allocated and must release exactly once. A c_ptr is not owned by this program at all — it is C’s memory, released by calling SSL_free/SSL_CTX_free, and nothing here can express “must call SSL_free exactly once” any more than it expresses “must call close exactly once” for a raw int file descriptor today. That is not a new gap c_ptr opens; it is the same gap reach.md §3.1.1 already accepts for fork’s pid and every file descriptor this language already hands back integer-shaped. A c_ptr is copyable and comparable, freely, the same as an int is.
  • Equality and nullness only. ==, !=, and a comparison against a literal null handle. No arithmetic (+, -, indexing), no dereference, no coercion to or from any reference type, no coercion to or from int. This is what keeps §3.1’s rule intact: the checker never claims to know a c_ptr’s provenance the way it knows a reference’s region, so it never lets a c_ptr be used as if it did. An int is safe to accept because “a number’s provenance is nothing” (§3.1) — a c_ptr is safe to accept for exactly the same reason, as long as the only things done with it are the things that also don’t need provenance: pass it back, compare it, check it against null.
  • A null sentinel, null_ptr(), a builtin of type c_ptr — needed because OpenSSL’s own error convention is “returns NULL on failure” for SSL_CTX_new and SSL_new alike, and the program needs a c_ptr value to compare against without ever constructing one from an int (which the no-coercion rule above forbids). null_ptr() is the one producer of a c_ptr that is not a foreign call’s return.

Corrected count from §1: eight foreign declarations become the surface this touches — three returning c_ptr (TLS_client_method, SSL_CTX_new, SSL_new), and the same handle type appearing as a parameter in SSL_set_fd, SSL_connect, SSL_write, SSL_read, SSL_get_error, SSL_shutdown, SSL_free, SSL_CTX_free (eight consumers, some declarations consuming more than one handle type at once — SSL_set_fd takes SSL * and an int, not two handles).

What this does not solve

  • No struct sockaddr_in-style layout problem exists for TLS. Every OpenSSL call in §1 takes only handles, integers, or a length-carrying byte slice — there is no struct to build the way bind needs one (reach.md §3.2). This document has nothing to add to §3.2’s answer.
  • Does not open malloc. A c_ptr cannot be dereferenced, indexed, or offset, so it is useless for the thing reach.md §3.1 already refuses on purpose (“the heap is a capability with box”). Nothing about accepting a handle back from SSL_CTX_new lets a program treat it as addressable memory.
  • Does not give “must free exactly once.” Stated above; a c_ptr leak is possible, the same way an unclosed file descriptor is possible today. A future capability wrapping c_ptr the way heap.md wraps raw allocation is future work, not required for TLS to work at all, and not proposed here.
  • Does not solve certificate verification’s own trust story — a correct TLS client needs SSL_CTX_set_verify and a trust store, which are more foreign declarations of the same three shapes already covered (c_ptr, int, byte slice), not a new type-system problem. Left to the example program in §5, not this design.

4. Where this plugs into the existing structure

(Sequencing for the implementation that follows this document — not itself part of the design decision.)

  • crates/cancho-types: a new Type::CPtr variant alongside Int, Byte, Bool, ….
  • The resolver/name layer that recognizes c_int by string in crates/cancho-ir/src/lib.rs’s extern-fn checking gains a sibling branch for c_ptr, constructing Type::CPtr instead of collapsing to Type::Int, allowed in both the parameter and return position checks (currently Type::Int | Type::Bool for parameters, Type::Int | Type::Bool | Type::Unit for returns). c_ptr, like c_int, is not registered as a general type name anywhere else, so writing it in any other position stays an unresolved-name refusal for free — the same reason c_int cannot be written outside an extern return today. the binary-operator type checker gains Type::CPtr to its equality/inequality arms and excludes it from every arithmetic and ordering arm.
  • Both backends’ scalar-classification (scalar_kind in cancho-codegen-llvm, and Cranelift’s equivalent width lowering) treat Type::CPtr as an i64-width scalar for register purposes — the same width int already gets, since the handle rides in a register exactly like an integer-shaped file descriptor does. null_ptr() lowers to a 64-bit zero constant on both backends.
  • Differential coverage: crates/cancho/tests/conformance/backends.rs gets a case exercising a c_ptr-returning and c_ptr-consuming extern declaration on both backends, mirroring the_two_backends_agree_on_a_narrow_foreign_return’s existing pattern for c_int.
  • tests/reject/: a case proving c_ptr arithmetic, dereference, and coercion to/from int or a reference type are all refused — the positive mirror of reach.md §3.1.1’s malloc example, now checked by the compiler instead of only by prose.

5. Verification plan

A design that only compiles is not verified; reach.md’s whole method is building the thing and reporting what happened. The plan:

  1. A new example, examples/tls_client/, declares the nine functions in §1 (plus SSL_CTX_set_verify/a trust-store call for a real, not toy, verification posture) using c_ptr for every handle.
  2. It connects to a real TLS server over loopback: openssl s_server -accept <port> -cert <cert> -key <key> (already available — this environment has genuine OpenSSL 3.0.13, confirmed via openssl version), sends a request, and reads the response back through the existing &r [byte] byte-slice crossing.
  3. The test harness (crates/cancho/tests/conformance/, following the existing pattern for examples/serve/’s an_http_server_written_in_cancho_answers_a_real_request) starts openssl s_server as a subprocess, runs the compiled example against it, and asserts the plaintext response bytes match — a real handshake and a real encrypted round trip, not a stub.
  4. Both backends run the same example through crates/cancho/tests/conformance/backends.rs’s existing --backend cranelift/--backend llvm split, so c_ptr is proven on both, not just the default.

If step 2 or 3 cannot be made reliable in CI (a subprocess-managed real TLS server is a heavier test fixture than anything this suite runs today), the fallback is the same one docs/self-hosting.md used for a comparably large claim: land the type-system change and its tests/ accept/tests/reject coverage on its own, and record the live example as a spike verified once by hand in this document (with its exact output) rather than as a CI-gated test — a decision to make explicit at implementation time, not assumed here.

Corrected: this is the fallback that was taken, for a reason independent of c_ptr. Step 1 needs the final linking step to pull in -lssl -lcrypto; every example this project has ever built links only libc, which the compiler always links, so nothing in the current cancho build pipeline can express “also link this library” at all. Adding that is a real, separate feature — a general “link an external library” story, not a c_ptr question — so §1’s nine declarations were grounded against the real header and never compiled into a program. tests/accept/opaque_pointer.cho verifies the type feature itself end-to-end against fdopen/fclose instead, real libc, no extra linking.

6. What building the fixture found, unrelated to c_ptr

The first attempt at that fixture declared fopen(path: &p [byte], mode: &m [byte]) -> c_ptr — two string parameters, matching real fopen’s own signature — and segfaulted on both backends. gdb on the core: _IO_new_file_fopen received mode=0xe, a small integer where a pointer belongs. The cause has nothing to do with c_ptr: a &r [byte] parameter already crosses as two real arguments, a pointer and a separate length (docs/strings.md §6), and path’s own length leaf (14, len("/etc/hostname") + 1) landed in the register real fopen reads as its second parameter, because fopen was never compiled expecting that extra register at all. path’s pointer arrived correctly (it is the first real argument either way); mode’s pointer was silently dropped.

This means a &r [byte] parameter is only safe as the last crossing parameter of an extern fn declaration. write/read’s own long-standing declarations already follow this by construction — fd: int first, the slice last, nothing after it — and so does every declaration in §1: not one OpenSSL function needed two string-shaped parameters, so this gap never touched the TLS design at all. The fixture that found it was rewritten around fdopen(fd: int, mode: &m [byte]), matching the safe shape exactly, rather than working around the gap. Declaring a real C function that takes two NUL-terminated strings (fopen’s own path, mode included) has no correct spelling in this language today — a pre-existing limitation of the byte-slice crossing convention, not of c_ptr, and out of scope for this document to fix. Worth a line in docs/strings.md §6 or a tests/ reject/ fixture of its own; left as found, not chased, the same judgment call docs/llvm-backend.md’s own corrections made about scope.

Linking beyond libc

Status: built. §3’s proposal is implemented as written: -l/-L on build and run, refused everywhere else, passed to cc between the object and -o in the order §3.1 measured. Verified against a real, running TLS server, not a stub: examples/tls_client/ (§4) links -lssl -lcrypto, performs a genuine handshake against openssl s_server over loopback, and exchanges an encrypted message — on both backends. Two things were found building it, neither about linking itself:

  1. c_ptr (and null_ptr()) cannot name an ordinary function’s own parameter or return type — only an extern fn’s, exactly as opaque-pointers.md §4 specifies but a reader could miss: the handshake, the write and the read had to become one function rather than three, with every SSL * staying a local binding inferred from an extern call’s return. Recorded here because this document’s own example is what ran into it, not because opaque-pointers.md was wrong.
  2. examples/tls_client/’s own raw socket/connect collided with edition 2’s connect builtin — a foreign declaration’s Lex name is the linked symbol (confirmed by trying to rename it: the linker then looks for a symbol that does not exist), so it cannot be renamed around the collision, and this document’s edition 3; was needed for null_ptr(). Fixed by docs/many-files.md, not by this document: the raw socket moved to its own file, socket.cho, kept on edition 1 (where connect still resolves to the extern fn, not the builtin) while tls_client.cho stays on edition 3 — the per-file edition docs/editions.md §7 designed for exactly this, a file that predates a name becoming a builtin not having to be rewritten around it. docs/net.md §5 recounts outbound to 5.

opaque-pointers.md §5’s own fallback — “record the live example as a spike verified once by hand… rather than as a CI-gated test” — is the one taken here too, but for a different reason than the one it named: the blocker was never CI’s ability to manage a subprocess server (that part works and is straightforward). It is that -lssl -lcrypto cannot be assumed to link on this project’s own darwin-aarch64 CI runner without also pointing the linker at wherever that runner’s OpenSSL happens to live — Apple ships no OpenSSL headers or libraries in the default search path on purpose, unlike every library this project has linked so far. Gating cargo test --workspace on that would make this document’s own feature the reason the other target turns red, which is a worse failure mode than the one being fixed. So: the example is real, checked in, and built and run by hand against a real server (§4’s exact transcript); it is deliberately not part of the conformance suite that CI runs on every push. A CI-gated version is future work, gated on knowing where CI’s own OpenSSL is without assuming it.

1. What is actually missing

crates/cancho/src/main.rs’s link function is the whole of this project’s linking story:

#![allow(unused)]
fn main() {
fn link(object: &Path, output: &Path) -> Result<(), Failure> {
    let cc = std::env::var("CC").unwrap_or_else(|_| "cc".to_owned());
    let status = Command::new(&cc).arg(object).arg("-o").arg(output).status()...
}

One object file, one output path, nothing else. That has been enough because every program this project has ever built links only against libc, which cc always links whether asked to or not. c_ptr (opaque-pointers.md §3) removed the type-system reason a program could not declare SSL_CTX_new/SSL_new/etc — but declaring a foreign function and linking the library that defines it are two different problems, and only the first one has ever been solved here. opaque-pointers.md §5’s own fallback exists only because of this: its TLS example was grounded against the real OpenSSL 3.0 header and never compiled, because compiling it needs -lssl -lcrypto and there is no way to hand the compiler those two words.

This is not a TLS-shaped gap. reach.md §2’s table lists a Postgres client as a second, unrelated No, for the same reason — PGconn * is the identical handle shape c_ptr already covers, and what stops a libpq client compiling is -lpq, not the type system. Two real, already-named programs (CONTRIBUTING.md rule 3’s bar) are blocked on one missing CLI feature, not two.

2. The shape of the fix: pass through, invent nothing

The library this project already delegates to — cc — has had this solved since long before cancho existed: -l<name> links lib<name>, -L<path> adds a directory to search for it. M0 already made the decision this document does not need to re-argue (link’s own doc comment): “M0 shells out to cc rather than driving a linker itself.” Given that decision, the only question worth asking is whether cancho needs its own syntax for “link this library,” and the answer is no — nothing about which library a foreign declaration needs is knowable from the declaration itself (reach.md §3.1.1: “the declaration is trusted — nothing checks a cancho signature against the C header”), so there is nothing here for the compiler to infer and nothing to validate. The flag is the whole feature: take -l/-L exactly as cc defines them, and hand them to cc unexamined.

This also answers the question docs/standard-library.md §2 raises implicitly by not raising it: is this a package manifest question? No. A manifest resolves which library and which version satisfies a declared dependency; this document is one step earlier — there is no dependency declaration anywhere in a .cho file to resolve, only an extern fn block whose author already knows which system library it needs, the same way they already know its exact C signature. A future package system, if one is ever built, would generate these same flags from something it manages; it would not replace them.

3. The proposal

-l <name> (repeatable) and -L <path> (repeatable), accepted by build and run — the two commands that reach link — and passed to cc verbatim, once per occurrence, in the order given:

cancho build tls_client.cho --std -l ssl -l crypto -o tls_client
cancho run   tls_client.cho --std -l ssl -l crypto

Not --link, and not one flag taking a comma-separated list: cc itself spells these -l/-L, one library or path per flag, and this feature has nothing to add to that spelling — the same judgment opaque-pointers.md §3 made choosing c_ptr over inventing a new convention where an existing one already fit. check, ids, authority, layout and print never link, so -l/-L are refused there the same way -o/--emit already are on every command but build (allow_output in parse_args) — an unknown option today, staying that way rather than becoming a silently ignored one.

3.1 Order matters, and it is measured rather than assumed

A library named with -l must come after the object file that needs its symbols, not before:

$ cc t.o -lssl -lcrypto -o t     # links
$ cc -lssl -lcrypto t.o -o t     # ld: undefined reference to `SSL_CTX_new`

Confirmed against this environment’s real linker (/usr/bin/ld via GNU cc 13.3.0): a static or lazily-resolved archive is scanned once, left to right, for symbols the input already named as undefined, so a -l naming a library before anything has asked for its symbols finds nothing left to resolve. link already writes the object first and the output flag last; -l/-L therefore go after the object and before -o, matching the order that already works, not the order that happens to be convenient to build.

3.2 Where this plugs into the existing structure

  • Invocation (crates/cancho/src/main.rs) gains link_libs: Vec<String> and link_paths: Vec<String>.
  • parse_args gains a second gate alongside allow_output – allow_link – true for build and run, false everywhere else, the same reason run already gets --backend but not -o (it links into a temp directory it names itself, but it still links). -l/-L collect into the two new vectors under that gate.
  • build() and link() gain the two vectors as parameters. link appends -L<path> for every link_paths entry, then -l<name> for every link_libs entry, between the object argument and -o.
  • USAGE gains the two flags on build’s and run’s own lines, and a paragraph, the same as every other flag documented there.

What this does not solve

  • No dependency resolution, no manifest, no version pinning. The two vectors are handed to cc exactly as given; nothing here decides which -lssl a system has, the way docs/standard-library.md §2.1 already named as the first thing to revisit “when a package story exists.” This is not that story.
  • No static-vs-dynamic choice beyond what cc/ld already default to. -static, -Wl,-rpath, and every other linker flag stay unreachable — a program that needs one is a program with a second, separate ask, counted the same way this one was.
  • Does not change what can be declared. c_ptr and the ordinary int/bool/&r [byte] shapes reach.md §2 already allows are the entire foreign surface; this document only makes the library that defines them reachable at link time.

4. Verified

opaque-pointers.md §5 named the exact test this unblocks, and this is it, done: examples/tls_client/ declares the eleven OpenSSL functions §1 of that document grounded against the real header (TLS_client_method, SSL_CTX_new, SSL_new, SSL_set_fd, SSL_connect, SSL_write, SSL_read, SSL_shutdown, SSL_free, SSL_CTX_free, and SSL_get_error named but not needed — every failure path here has nothing more specific to say without it), built with -l ssl -l crypto against this environment’s genuine OpenSSL 3.0.13 (openssl version; libssl.so/libcrypto.so present as unversioned linker symlinks, the -dev package, not only the runtime .so.3), and run against a real openssl s_server -rev (an echo server that reverses one line) over loopback, on both backends:

$ cancho build examples/tls_client/tls_client.cho examples/tls_client/socket.cho \
      packages/net-connect/connect.cho packages/net-sockets/sockets.cho \
      --std -l ssl -l crypto -o tls_client
$ openssl s_server -accept 48395 -cert cert.pem -key key.pem -rev -naccept 1 -quiet &
$ ./tls_client 127.0.0.1 48395 "hello cancho"
sys-xel olleh

The two extra paths are docs/next-phase.md §4.1’s own migration: socket.cho used to declare socket/connect/close/address for itself; it now imports net.connect/net.sockets and needs both on the command line the same way any package-importing example does (vcs fetch for a pinned copy, or — as here, since both are already checked into this repository — the source files directly).

A genuine TLS 1.3 handshake (confirmed in the server’s own diagnostic output: Protocol version: TLSv1.3, Ciphersuite: TLS_AES_256_GCM_SHA384) and a real encrypted round trip, not a stub — the reply is "hello cancho" reversed, exactly what -rev does to what it received, so the bytes crossed the connection both ways intact. Repeated with --backend cranelift against a fresh server: oot tfilenarc for "cranelift too", same result. Neither is CI-gated (the status note above says why); both are hand-verified transcripts, recorded here rather than only run once and discarded.

Corrected (tls-nonblocking.md §2): the example’s handshake failures were read as successes. examples/tls_client/tls_client.cho declared SSL_connect, SSL_write, SSL_read, SSL_set_fd and SSL_shutdown as returning int, and a C int result is only sign-extended if the declaration says c_int: a failing SSL_connect (-1) was read as 4294967295, <= 0 was false, and the program wrote to a session that never existed (examples/tls_nb/gaps/g13_int_vs_c_int.cho). The five are c_int now. Separately, run against a server that accepts and closes, the example is still killed by SIGPIPE: OpenSSL’s socket BIO answers the end of file with a fatal alert written by write(2). It is a one-connection demonstration and is left as it is; a service must use the memory-BIO transport of examples/tls_nb/ or ignore the signal.

Corrected (foreign-authority.md): “the capability is the only way to reach the foreign call” was not enforced. extern fn system[&c](command: &c [byte]) -> [] c_int;, with no Ffi parameter and an empty row, was accepted, ran a shell on both backends, and left cancho authority saying "bounded": true. A foreign function now borrows exactly one Ffi, naming one library (foreign-declaration). The same document makes the scope a set, so one program can hold libc and libssl together, and has the report list every reachable symbol as scope:symbol. The -l/-L flags are the build’s and do not appear in the report.

#388: a program’s own extern fn free / extern fn malloc. A program declaring extern fn free (to release a c_ptr libc allocated, e.g. from strdup) collided with the LLVM backend’s own unconditional declare void @free(ptr) — clang refused the module with invalid redefinition of function 'free', answered as internal, and the same collision sat one declaration away for malloc. The backend now declares malloc and free only when the program does not, the way it already did for calloc, listen, accept and the rest: the program’s declaration is the only one, with the signature it wrote (cancho has no void, so a program’s free returns i64), and the arena, boxed slices and the wasi entry call it through their own call-site type (call void @free(ptr %p)), which opaque pointers accept; the only difference is a result the arena never reads. Measured with clang 18 on linux-x86_64 by a_programs_own_free_and_malloc_link_and_run_beside_the_arena_and_boxes (backends.rs), which builds and runs a program with its own free and malloc beside a region and a boxed slice. Not measured: wasm32, where there is no runtime here. A program-declared free/malloc is already the toolchain row above there (its int is i64 where wasi-libc’s size_t is 32-bit), and a call whose type differs from the callee’s is a signature mismatch wasm-ld reports rather than a native-ABI no-op.

An earlier version of this slice routed the backend’s allocator through private @cancho_malloc/@cancho_free declarations. Nothing defined those symbols, so every --backend llvm program failed to link; it was replaced by the guard above (and a wrapper that defined them would still have needed the libc declaration, and so the same guard).

Non-blocking TLS: https delivery for a service with one thread

Status: spiked and measured; not built into any service. cancho-hooks (the webhook delivery service) delivers over plain TCP: one thread, one Poller, up to 64 attempts in flight as a state machine each (docs/design.md sections 16 and 26 there). This document is what it takes to make https endpoints possible, written as design (§3) and then checked by building the pieces in examples/tls_nb/ and running them: a non-blocking TLS client driven by the Poller (§4), certificate verification with the failures told apart (§5), the authority it costs (§6), a name resolver that does not stop the loop and a pinned address (§7), the numbers (§8), and every compiler and runtime gap met on the way, each with a reproducer (§9). §10 is what cancho-hooks would change, in slices with gates. Nothing here is claimed that was not run; §11 lists what was not.

The user chose OpenSSL through FFI (a system C library) over a pure cancho TLS implementation, which is a research project. §3.1 says what the second would be and why it is not attempted here. Everything below is the first.


1. The question, and the constraints that make it hard

examples/tls_client/ (opaque-pointers.md, foreign-linking.md) is a real TLS handshake and an encrypted round trip against real OpenSSL. It is also blocking (SSL_connect on a blocking descriptor waits for the peer), unverified (it never calls SSL_CTX_set_verify, so it trusts whoever answers), and a single connection. cancho-hooks needs the opposite on all three:

  • One thread. The ingest server, the log, the retry schedule and every delivery attempt share one loop. An attempt that waits is the whole service waiting (design.md section 15 measured a request held for 10 s by one receiver). TLS adds three places to wait that plain TCP does not have: the handshake (one or two round trips, and the peer’s CPU), the name lookup that https endpoints are given by (certificates name hosts, not addresses), and the OpenSSL calls themselves, which must be driven in steps.
  • Many at once. Up to 64 attempts, each in its own state, none blocking another.
  • The check is the point. A client that does not verify the certificate is not TLS, it is encryption to a stranger. Verification has to say why it failed in a form a retry schedule and an operator can use (an expired certificate is not a wrong host name).
  • The authority report is a product feature. cancho-hooks says “no Ffi, no unsafe; the authority report names what the program can do”. Whatever is added has to say what it costs there (§6).

2. What existed, and what running it found

examples/tls_client/tls_client.cho declares eleven OpenSSL functions with c_ptr handles, links with -l ssl -l crypto, and talks to openssl s_server -rev. Run again for this document it still works (sys-xel olleh). Two things in it were wrong, found by pointing it at something other than a friendly server:

  1. A C int result declared int is read wrongly. SSL_connect, SSL_write, SSL_read, SSL_set_fd and SSL_shutdown were declared -> int. The upper half of rax after a C function returns an int is not defined by the ABI, and here a failing call (which returns -1) was read as 4294967295 on both backends (examples/tls_nb/gaps/g13_int_vs_c_int.cho: close(-1) declared int prints 4294967295, declared c_int prints -1). So if SSL_connect(...) <= 0 was false exactly when it mattered, and a handshake that failed was treated as one that worked. Corrected in place: the five declarations are now c_int, which sign-extends once at the boundary (reach.md section 3.4’s purpose).
  2. OpenSSL’s own socket BIO kills the process. With the first fixed, the example, run against a server that accepts and closes, still dies: strace shows OpenSSL answering the end of file with a fatal alert written with plain write(2), EPIPE, SIGPIPE, and no message (killed by SIGPIPE, exit status -13). A blocking client run once does not care; a service that dials strangers does (§3.2, decision D1). Not changed in the example, which is a one-connection demonstration; tls_client.cho’s header says what it does not do.

3. Decisions, with the alternatives

3.1 D0: OpenSSL through FFI, not a TLS written in cancho

The decision is the user’s; this records what the alternative is so the choice is a choice. A TLS 1.2/1.3 client that a service can trust is not a protocol parser. It is the record layer and handshake state machines, a key exchange (X25519 or ECDH over P-256), an AEAD (AES-GCM, with hardware or constant-time software, or ChaCha20-Poly1305), HMAC and HKDF, signature verification for the algorithms real certificates use (ECDSA P-256/P-384 and RSA; Ed25519 is rare in public PKI), X.509 DER parsing, path building and validation (validity dates, basic constraints, name constraints, key usage), host-name matching (SAN, wildcards, the rules about the CN), a trust store, and a source of randomness. std has SHA-256, SHA-512 and Ed25519 (crypto.md, sha512.md, ed25519.md: pub fn sha256, sha512, public_key_from_seed, sign, verify) and no random source, no AES, no key exchange, no HMAC, no X.509; crypto.md section 1 says each of those “is a separate, harder correctness problem with its own attack surface”, and ed25519.md answers “no” to a constant-time posture. Every one of them is security-critical and every one is invisible when wrong. That is a research project with its own verification story (test vectors, differential tests against OpenSSL, a timing-side-channel review). It is the right long-term direction for native-sockets.md section 7’s “no C in the path”; it is not what delivers https webhooks. Not attempted.

The cost of the choice, stated once because §6 and §9 keep returning to it: a system C library behind Ffi is an authority the checker cannot bound (cancho authority prints UNBOUNDED), its handles are integers here (§3.3), and everything it does that the language guards against (writing to sockets itself, a SIGPIPE, memory it owns) is back.

3.2 D1: how the connection reaches OpenSSL: memory BIOs, not a descriptor

memory BIOs (BIO_s_mem x2, SSL_set_bio)the descriptor (SSL_set_fd)
available todayyes: the program reads and writes the socket as conn_read/conn_write and hands bytes to OpenSSLno conn_raw_fd: designed in native-sockets.md section 6, listed there as “not yet built”. The descriptor is reachable only through a layout leak (below)
authoritythe socket stays a Conn; only bytes crossthe program holds a raw descriptor number
closed peerconn_write is MSG_NOSIGNAL: an error codeOpenSSL’s socket BIO calls write(2): SIGPIPE kills the process (§2, test/verify_matrix.py); the fix is signal(SIGPIPE, SIG_IGN) for the whole process, which is what native-sockets.md section 3 set out to stop needing
costone 16 KiB read buffer shared by every slot, a 20 KiB ciphertext buffer per slot for what the kernel has not taken yet, two copies of the bytesnone of those
measured CPU per handshakewithin noise of the other (§8.1)

How the descriptor can be had without conn_raw_fd, found by trying: a std.conns.Table holds tickets, a ticket is epoch << 32 | descriptor (native-sockets.md section 10.3), and Table.tickets is a field of a pub struct, so vec.get(table.tickets, slot) & 0xffffffff is the descriptor, and no Ffi was needed to read it (gaps/g5_table_ticket.cho; tls.fd_of). That is a reading of a layout and not an interface, and it falsifies one sentence of native-sockets.md section 6 (“a program without Ffi cannot reach the hatch”; corrected there in place). The number alone is no authority (calling anything on it needs Ffi), but it is a reason not to build on it.

Decision: memory BIOs. It is the only transport that works with today’s compiler and today’s std.conns without a signal disposition, and the numbers say it costs nothing measurable. tls.cho carries both (open’s fd argument), because the second is what a conn_raw_fd slice would switch to, and the comparison (§8.1) and the SIGPIPE row of the test are the evidence either way. It is the default; io=1 in the client selects the other.

3.3 D2: handles are integers

An SSL * is a pointer returned in a register. c_ptr is the honest type and cannot be used: it cannot be named in an ordinary function’s signature or a struct field (gaps/g1_cptr_param.cho, g2_cptr_field.cho: unknown type c_ptr), so a helper could not take or return one, and a table of connections could not hold them. (A [c_ptr] slice and a generic parameter instantiated at c_ptr both compile and run, g3, g4, but a generic body cannot call anything with its T, so they store a handle and nothing more.) Declaring the extern with int for a pointer result carries all 64 bits and works on both backends. The price is the forgeability c_ptr exists to prevent: SSL_free(ffi, 12345) type-checks. Under Ffi the program can already call anything in the library, so the extra guarantee c_ptr would add is small, but it is a guarantee given up, and §9 asks for c_ptr in signatures.

3.4 D3: one context, one SSL per connection, a step function per operation

tls.context makes one SSL_CTX for the process: minimum protocol TLS 1.2 (SSL_CTX_ctrl(ctx, SSL_CTRL_SET_MIN_PROTO_VERSION, 0x0303): SSL_CTX_set_min_proto_version is a macro), SSL_MODE_ENABLE_PARTIAL_WRITE | ACCEPT_MOVING_WRITE_BUFFER (so SSL_write takes one record at a time and never insists on being retried with the same buffer: the program’s request buffer does not move, but the contract stays simple), SSL_CTX_set_verify(SSL_VERIFY_PEER) and the trust store. Loading the system store costs about 25 ms (§8.1): once per process, never per attempt. tls.open makes an SSL over two memory BIOs for a slot; handshake, write, read and shutdown each run as far as they go without waiting and answer done, pending (the slot is already watched for the direction OpenSSL needs) or failed, in the same shape as attempt.advance (design.md section 16). ERR_clear_error is called before each SSL operation: the error queue is per thread, and one thread serves every connection, so a stale error from one connection would otherwise be read as another’s.

3.5 D4: what verification is, in calls

whatcallnotes
verify the peerSSL_CTX_set_verify(ctx, SSL_VERIFY_PEER, 0)the callback argument is a function pointer: 0
trust store, systemSSL_CTX_set_default_verify_paths(ctx)honours SSL_CERT_FILE and SSL_CERT_DIR (tested)
trust store, a fileSSL_CTX_load_verify_file(ctx, path)OpenSSL 3.0’s one-string form; SSL_CTX_load_verify_locations has two strings and cannot be declared (§9, gap 4)
host nameX509_VERIFY_PARAM_set1_host(SSL_get0_param(ssl), name, len), flag X509_CHECK_FLAG_NO_PARTIAL_WILDCARDStakes a pointer and a length, which is how a byte slice crosses: no NUL needed. SSL_set1_host wants a C string
SNISSL_ctrl(ssl, SSL_CTRL_SET_TLSEXT_HOSTNAME = 55, TLSEXT_NAMETYPE_host_name = 0, name)SSL_set_tlsext_host_name is a macro over SSL_ctrl; the name is a NUL-terminated void * in the last parameter, so a slice with a NUL after it
minimum protocolSSL_CTX_ctrl(ctx, SSL_CTRL_SET_MIN_PROTO_VERSION = 123, TLS1_2_VERSION = 0x0303, 0)
why it failedSSL_get_verify_result(ssl) after a failed handshakean X509_V_ERR_* number

Revocation (CRL, OCSP, stapling) is out of scope and not attempted: OpenSSL does not check revocation unless asked and asking needs a CRL source. A revoked certificate that is otherwise valid is accepted (§11).

3.6 D5: every outcome is a code (stage, detail)

An attempt ends as (stage, detail): stage 0 is success; 1 connect (detail the errno); 2 handshake (detail the X509_V_ERR_* number if verification failed, otherwise the first OpenSSL error code, or -1 if the peer closed, a code both transports produce for the same fact); 3 write; 4 read; 5 setup; 6 name lookup (detail a dns.cho/rtcp.cho code); 7 destination refused (detail the packed address that was refused); 102 deadline. An OpenSSL error code is library << 23 | reason in 3.0 (0x0A00042E is library 20, reason 1070: an alert received from the peer, 1000 + 70, protocol_version). Nothing panics and every connection is closed on every path (§5).

3.7 D6: the name is looked up by the program, on the poller

tcp_connect_start calls getaddrinfo, which blocks, and a name it resolves can differ from the one a check judged (design.md section 26 is why cancho-hooks accepts only IPv4 literals). Three ways to have names:

stops the loopneedswhat it gives up
getaddrinfo as todayyes, for the whole lookup (302 ms for a 300 ms answer, §7)nothingthe address is not known before the connection: no check, no pin
a thread (spawn) running libc’s res_query, answers through a loopback Connno (3 ms gap)Ffi, a fixed port, four workers named in the text (§7, gap 9)/etc/hosts, search list; needs a thread per pool slot written out
a resolver of our own: DNS over TCP, a state machine on the same poller (rtcp.cho)no (2 ms gap)the name server’s address; a Table and buffers/etc/hosts, search list, UDP, trying a second server

Decision: the resolver on the poller. It needs nothing the language lacks, adds no thread and no Ffi, fits the attempt’s deadline, and its parser is pure cancho (dns.cho, tested on a million damaged answers, §7). The thread version was built because the question asked for it; its cost in the language (§9) is larger than its benefit.

3.8 D7: connect to the address that was checked

The destination is resolved, every address in the answer is judged against the ranges of destination.is_public (pin.cho, a copy checked against the original), one is chosen, and the connection is made to it as an IP literal (no second lookup), with the name used afterwards only for SNI and the certificate. A check and a connection that each resolve are two lookups and a name can answer differently to the second (rebinding); here there is one and the connection goes to its answer (§7 shows it by counting queries). The rule is strict: if any address is private the delivery is refused, not “connect to the first public one” (a name that has a private address in its answer is one resolver change from being sent there).

4. What was built

All in examples/tls_nb/, compiled with the checked-in compiler:

cancho build tls.cho dns.cho rtcp.cho pin.cho nat.cho tls_nb.cho --std -l ssl -l crypto -o tls_nb        # the TLS client (scope Ffi("tls"))
cancho build dns.cho rtcp.cho rthread.cho nat.cho resolve_demo.cho ../../packages/net-sockets/sockets.cho ../../packages/net-connect/connect.cho --std -o resolve_demo   # the three resolvers (scope Ffi("libc"))
filewhatlines
tls.chothe engine: the OpenSSL declarations, context, open, handshake/write/read/shutdown, describe, session save/restore, both transports750
tls_nb.chothe client: up to 4,096 connections in flight on one Poller, each a state machine (lookup, connect, handshake, requests, close), options for verification, resumption, I/O mode, a name server822
dns.chopure: build_query, parse (total on any bytes), put_dotted255
rtcp.chothe resolver on the poller: open, start, advance, finish, close, a Resolver that owns its table and buffers304
pin.cho, pin_check.chois_public (the ranges of cancho-hooks’ destination.cho), choose (every address must pass); and the check of the copy against the original83, 128
nat.chonat.parse, the argument reader the two programs here share25
rthread.chothe thread resolver: the worker body (res_query) and the loopback channel, through the repository’s net.sockets and net.connect168
resolve_demo.chothree resolvers behind one loop, to measure what each does to it498
dns_fuzz.chodns.parse on a known answer, the errors, and a million damaged answers185
gaps/23 reproducers (§9) and check_gaps.py, which runs them all (two are kept as .cho.txt, see below)
test/certs.sh, server.py (a TLS receiver), dns_stub.py, harness.py, and the tests and measurements below

The repository checks its examples/ tree, and three of those checks shaped this directory: no function body may be duplicated across files (parse_nat and a copy loop were; nat.cho and a differently-written loop are why they no longer are), every .cho must be formatted by cancho fmt (so the two reproducers the formatter cannot read, t2_ref_field and g11_connect_is_a_builtin, are .cho.txt, which check_gaps.py copies to .cho to run), and extern fn connect may be declared in packages/net-connect/connect.cho alone (net::the_network_programs_are_counted): the thread resolver’s first version declared its own in an edition-1 file, and now uses the package, which is why the resolver demo’s scope is Ffi("libc") and not Ffi("tls"), and why it is a program of its own (gap 10).

4.1 The state machine

connecting --(writable, SO_ERROR 0)--> handshake --(SSL_do_handshake = 1)--> sending --(request out)--> reading --(status line)--> closed
    |                                       |                                    |                           |
 errno                               WANT_READ: flush what OpenSSL queued,     one record at a time:       SSL_read until the whole
                                     feed what the socket has; none: watch     flush, SSL_write(partial)   response (or the first 12 bytes,
                                     readable. Failure: stage 2, X509 number   watch writable while the    as `attempt.advance` reads them)
                                                                               kernel is full

One Poller, level-triggered, a token per slot; a slot is watched writable only while ciphertext waits for the kernel and readable otherwise (a connection watched writable with nothing to write would spin). SSL_ERROR_WANT_WRITE cannot occur with a memory BIO and is handled as “flush first”. SSL_shutdown sends close_notify and does not wait for the peer’s: the attempt is over when the status line is in. A TLS 1.3 server sends NewSessionTicket messages after the handshake; they are read (and dropped, or kept: §10.4) by the first SSL_read, so a connection that sends nothing and waits does not spin on unread input (hold reads them).

4.2 The API a consumer sees

What cancho-hooks would call, in the shape attempt.cho already has (functions over arrays and a slot number, the Conn in a std.conns table, the token the caller registered):

// once, in main: 0 if the context cannot be made (a trust store that will not load): refuse to start
let ctx = tls.context(f, verify, default_paths, cafile /* NUL-terminated, length 0 for none */, release_buffers);

// per attempt, slot s; tt, out and net are the caller's arrays: tls.stride() integers a slot, tls.out_max() bytes a slot, one tls.net_max() scratch
tls.open(f, ctx, tt, s, host /* the name the certificate must carry */, verify, fd /* -1: memory BIOs */, session /* 0, or a saved one */)  -> 0 | failed (stage 5)
tls.handshake(f, tab, poller, tt, out, net, s, token)             -> done | pending | failed
tls.write(f, tab, poller, tt, out, s, token, data)                -> n > 0 bytes taken (one record) | -1 pending | -2 failed
tls.read(f, tab, poller, tt, out, net, s, token, into)            -> n > 0 | 0 close_notify | -1 pending | -2 failed | -3 closed without it
tls.shutdown(f, tab, tt, out, s);  tls.drop(f, tt, s)             // close_notify, best effort; free the SSL and its BIOs (safe on a slot with none)
tls.stage_of(tt, s), tls.detail_of(tt, s)                         // where it failed and why (§3.6)
tls.save_session(f, tt, s) -> int;  tls.free_session(f, session)  // resumption (§10.4)

rtcp.open(heap) -> Resolver;  rtcp.start(heap, rs, net, poller, ns_ip, ns_port, name, token0, lookup, query_id, deadline) -> (Resolver, slot, code)
rtcp.advance(rs, poller, slot, token0) -> pending | count | a negative code;  rtcp.count_of/addr_of/ttl_of(rs, slot, ...);  rtcp.finish(rs, slot);  rtcp.close(heap, rs)
pin.choose(addrs, count, allow_private) -> index | -1;  pin.first_refused(addrs, count)

Every function takes the &f Ffi("tls") it needs and says ffi("tls") in its row; rtcp, pin and dns need none. Two things a package should change that the spike does not: tls takes the caller’s arrays because the spike’s driver keeps its per-slot state in its own, where a package would own them the way rtcp.Resolver owns its table and buffers (Table and Box fields, accessors, one open/close); and the integer handles of §3.3 stay inside it, so that when c_ptr can be named (gap 1) only tls.cho changes.

5. Certificates: every outcome, against a real server

test/certs.sh makes the certificates with the openssl CLI (a CA, leaves for hooks.test, an intermediate, another PKI); test/server.py is an asyncio TLS receiver that serves any of them. Run: python3 examples/tls_nb/test/verify_matrix.py, exit 0 only if every row came out as expected, on both transports. The client is given the CA file ca.pem unless the row says otherwise and expects the name hooks.test.

the server presentsthe client reports (stage, detail)meaning
a valid certificate (ECDSA P-256, and RSA 2048)0, 0, status 200verified against the name, request answered
a certificate for other.test2, 62X509_V_ERR_HOSTNAME_MISMATCH
expired (2020-01-02)2, 10CERT_HAS_EXPIRED
not yet valid (2090)2, 9CERT_NOT_YET_VALID
self-signed, not in the trust store2, 18DEPTH_ZERO_SELF_SIGNED_CERT
a leaf, an intermediate and a root of another PKI, all sent2, 19SELF_SIGNED_CERT_IN_CHAIN
a leaf under an intermediate of the trusted CA, the intermediate not sent2, 20UNABLE_TO_GET_ISSUER_CERT_LOCALLY
the CA file holds the wrong CA; no trust store at all; the system store only2, 20same: nothing that issued it is trusted
a server that offers only TLS 1.1 (openssl s_server -max_protocol TLSv1.1)2, 167773230 (0x0A00042E)the peer’s protocol_version alert: the minimum protocol holds
a server that closes without an alert2, -1the peer closed
nothing listening1, 111ECONNREFUSED, a connect failure, not a TLS one

Controls, each one showing that a failure above it was the check and not the setup: the same wrong-host certificate with the client expecting other.test succeeds; the self-signed server succeeds with verification off; the intermediate, once sent, verifies; SSL_CERT_FILE pointed at ca.pem makes the system-store call succeed. A SIGPIPE row: a peer that closes before the handshake is reported (stage 2) by the memory-BIO transport, kills the process on io=1 unless sigpipe=ignore is given, and is reported on io=1 with it (the three rows are tested, §3.2). Revoked is out of scope (§3.5, §11).

Nothing leaks, on any path (test/leak_test.py, exit 0 only if every check holds): 10,000 connections through 64 slots under ulimit -n 200 (a leaked descriptor per connection would stop it at connection 136) for success, an expired certificate and a server that never answers, on both transports; the bytes malloc still has outstanding when the client exits (an LD_PRELOAD shim, test/shim_heap.c, mallinfo2) rise over the first few thousand connections to 244,992 bytes, the same number in every configuration, and stay there: 177,664 after 500 connections, 221,776 after 2,000, 244,992 after 5,000, 10,000 and 20,000 (an expired certificate on the descriptor transport; something in OpenSSL fills to a bound, and what it is was not found). The test compares 10,000 with 40,000 connections, for success and for an expired certificate on both transports (a leak of one byte a connection would put them 30,000 bytes apart): 244,992 and 244,992 in all four; and valgrind --leak-check=full reports nothing on 40 connections through 16 slots, success and failure. (Peak resident size is not a usable detector and was tried first: it grew by 40 bytes a connection, which is the client’s own record of each connection (tls_nb keeps one for the report), not a leak.) One path is not covered: tls.open frees the SSL but not a BIO if the second BIO_new fails (an out-of-memory condition; there is no BIO_free declared and the process is about to fail anyway: stated in the source).

Partial and refused I/O (test/partial_io_test.py; an LD_PRELOAD shim, test/shim_io.c, on send and recv). An ordinary run on loopback never reaches the branches of tls.cho that handle a write the kernel takes in pieces, a write that has to wait, a TLS record that arrives in pieces and a read with nothing yet (cancho-hooks’ own partial-write branch has never run in a test either, design.md section 16). The shim makes send take at most 700 bytes and recv return at most 300, and every third call of either fail with EAGAIN: 64 connections, each sending three 60,000-byte POSTs over one session, all succeed with the bytes intact (the receiver reads exactly Content-Length, and a lost or reordered byte is a MAC failure, not a quiet pass); 500 handshakes the same way; and a harsher setting (64-byte sends, 17-byte reads, every second call refused). Three mutants of tls.cho against it: a write that would block counts as flushed, and a partial write counts as whole, are both killed by the shim run (every connection runs to its deadline) and invisible without it (they pass the ordinary run); a read with nothing yet counts as data survives the shim run (it spins, and the data arrives) and is killed by the silent-peer check in the same file: 64 connections to a peer that never answers must all end at the deadline, in 1.0 s, and with that mutant the loop never returns to the poller and the test’s timeout kills the process.

6. Authority: what Ffi scope this needs, and what the report then says

Which scope. The scope in Ffi("...") is a label. Nothing connects it to the library a symbol lives in (gaps/a2_scope_is_nominal.cho declares libc’s system under Ffi("openssl"), and it type-checks, builds and runs), and narrow consumes the one Ffi that split hands out, so one program has one scope (gaps/g12_scopes_do_not_compose.cho: a second narrow is linear-use-after-move, and a function that takes both an Ffi("libc") and an Ffi("tls") can never be called). OpenSSL is two libraries (libssl for SSL_*, libcrypto for BIO_*, ERR_*, X509_*), so the scope cannot be “the library”; tls.cho names it by purpose, Ffi("tls"): the C symbols the TLS code needs. Two consequences: the packages that hard-code Ffi("libc") (net.sockets, net.connect) cannot be mixed with it (cancho-hooks does not use them: its sockets are Conns), and the name promises nothing about what a function holding it can call.

Superseded in part by foreign-authority.md. A scope is now a set of libraries (Ffi("libc,libcrypto,libssl")), each foreign function names its own library, and a function is lent only the part it needs, so the spike’s choice of one scope “by purpose” (and libc’s signal filed under "tls") is no longer forced; gap 10’s first two halves are closed (g12 still shows two separate narrows refused, which is intended). The report lists the 37 symbols as tls:... pairs; with per-library scopes it would list libssl:, libcrypto: and libc:. The sentence “the name promises nothing about what a function holding it can call” stands: the scope is a claim, the symbol is the fact.

How a program like cancho-hooks holds it narrowly. Today main says release(ffi). It would say let tls = narrow(ffi, "tls") and lend &f Ffi("tls") down the one chain that needs it: main -> run -> delivery_turn -> start_attempts/start_one/start_replay -> attempt.begin/advance/finish. Each function on the chain takes the capability and declares ffi("tls") in its row, and the checker holds the row exact in both directions (gaps/a3_row_exact.cho: a caller of a function that performs ffi("tls"), with a row that does not say so, is effect-not-declared; and tls_nb.cho has no function whose row names it without using it). A function that is not handed the capability cannot reach OpenSSL, and that is visible in its signature. main itself stays [] (owning discharges, as for Io: tls_nb.cho’s main owns and borrows it and is -> [] int). With the thread resolver main would also need conc (§7); with the resolver on the poller it does not.

What the report says. cancho authority examples/tls_nb/*.cho --std:

UNBOUNDED: this program calls foreign code, and a library is
not an authority domain -- the labels below do not bound what
it can reach. See docs/under-a-grant.md.
performs
    args, clock, conn_read, conn_write, err_write, ffi("tls") <- unbounded, heap, io_write, net_out(""), poll
foreign symbols (37)
    BIO_new  BIO_read  BIO_s_mem  BIO_write  ERR_clear_error  ERR_get_error  SSL_CIPHER_description  SSL_CTX_ctrl  SSL_CTX_free
    SSL_CTX_load_verify_file  SSL_CTX_new  SSL_CTX_set_default_verify_paths  SSL_CTX_set_verify  SSL_SESSION_free
    SSL_SESSION_is_resumable  SSL_ctrl  SSL_do_handshake  SSL_free  SSL_get0_param  SSL_get1_session  SSL_get_current_cipher
    SSL_get_error  SSL_get_verify_result  SSL_new  SSL_read  SSL_session_reused  SSL_set_bio  SSL_set_connect_state  SSL_set_fd
    SSL_set_session  SSL_shutdown  SSL_version  SSL_write  TLS_client_method  X509_VERIFY_PARAM_set1_host
    X509_VERIFY_PARAM_set_hostflags  signal

So the authority cancho-hooks has today, which names every label and no foreign symbol (its main does release(ffi), src/hooks.cho:2481, and its README says so), becomes UNBOUNDED, with a list of foreign symbols a reviewer can read in a minute. (This was read from its source and README; cancho authority was not run on cancho-hooks itself, whose dependencies are fetched by cancho build.) That is a real change to what cancho-hooks’ README says (“No Ffi, no unsafe”) and to what a supervisor can check against a grant (under-a-grant.md: it “is unable to refuse”). The type checker allows the row to say ffi("tls") precisely; it cannot make the report mean anything about what the symbols do. The alternative that keeps cancho-hooks’ report exact is to put TLS in a separate program (a loopback proxy: it holds Ffi("tls") and Net, cancho-hooks dials it in plain TCP and says where to go). That costs a hop and a protocol, and it moves the unbounded row to a program that does nothing else. Not built, not measured; it is the answer if the report matters more than the hop.

7. Names: resolve without stopping the loop, check, pin

resolve_demo runs a poller loop (a wait of 1 ms and a tick counter) and, after 50 ms, starts lookups against a name server that answers one name after 300 ms (test/dns_stub.py). “Longest gap” is the longest time between two iterations of the loop: a loop that waits for DNS has a gap as long as the wait. python3 examples/tls_nb/test/resolve_test.py runs all of it against the three resolvers, checks the numbers below and exits 0 only if they hold. The two libc resolvers read /etc/resolv.conf, so they run in a private mount namespace in which that file names 127.0.0.1 (test/with_resolver.sh, unshare -m: the host’s file is not touched), where a stub on port 53 listens.

five lookups at once, one of them slow (300 ms)the loop’s longest gapwhole runthe fast lookups
blocking: tcp_connect_start(name), getaddrinfo inside, what hooks has302 ms412 ms0-1 ms each, but only after the loop returned from the slow one
thread: four workers, res_query, answers through loopback Conns2 ms351 ms1-2 ms
tcp: rtcp.cho, DNS over TCP on the poller2 ms351 ms0-2 ms
eight slow (300 ms) lookups at oncewhole runthe loop’s longest gap
blocking (one after another)2,519 ms304 ms
thread (four workers: two waves)653 ms3 ms
tcp (all eight at once, rtcp.slots() is 16)352 ms2 ms

The parser is checked, not trusted (dns_fuzz.cho, cancho run dns.cho dns_fuzz.cho --std, exit 0): a known answer (a CNAME and two A records), the error codes it must name (wrong id, too short, cut inside an answer, a query where a response is expected, the TC bit, NXDOMAIN), the query builder on empty names, empty labels and a 64-byte label, then 1,000,000 damaged answers (600,000 with 1 to 4 random bytes replaced and a random cut, 400,000 aimed at the header and first record, 200,000 random strings), each passed as a slice exactly as long as the message so a read past its end is a bounds trap and not a quiet read of the buffer’s tail: none traps (0.23 s). Four mutants of dns.cho: an A record accepted whatever its data length, and the record’s data length not checked against the message: both killed (illegal instruction); byte_at without the check against len(msg), and the record header’s length check dropped: survive, and are equivalent, because every read of those bytes goes through a second check (u16_at calls byte_at).

The copy of the range test is checked against its original (test/pin_test.py <cancho-hooks checkout>: pin.is_public against cancho-hooks’ destination.is_public on both edges of every range and their neighbours, 17 addresses for each first octet and 2,000,000 pseudo-random ones, 2,004,434 addresses, 0 disagreements). Four mutants of pin.cho (169.254/16 moved by one, 172.16/12 short of 31, 224/4 short of 224, 100.64/10 short of 127) each produce disagreements (66, 2, 7,827 and 2 of them): all four killed.

The pinned delivery (python3 examples/tls_nb/test/pinned_test.py, exit 0 only if every check holds; tls_nb with ns=<ip> resolves its first argument as a name over DNS/TCP, judges every address with pin.choose, and dials the one chosen as an IP literal):

  • 16 deliveries to hooks.test all succeed with the certificate verified against the name, and every connection went to the pinned address (127.0.0.1): pinned= in the per-connection lines. The name server saw exactly 16 queries: one lookup per delivery, not two.
  • Refused before any connection (stage 7, detail the address): hooks.test -> 127.0.0.1 (loopback, with allow-private=0), meta.test -> 169.254.169.254 (the cloud metadata address), mixed.test -> 10.0.0.1 and 93.184.216.34, and mixed2.test -> 93.184.216.34 and 10.0.0.1 (one private address refuses the whole answer, and it is the private one that is named, wherever it is in the answer). A counting listener behind those names was never connected to (0 connections for 32 deliveries); the control, the same name with allow-private=1, is dialled (4 of 4).
  • NXDOMAIN is stage 6, detail -103; no name server listening is stage 6, detail -200.
  • Rebinding: flip.test answers 127.0.0.1 and 127.0.0.2 in turn, with TLS receivers on both. Eight deliveries one at a time: each connected to the address its own lookup returned (127.0.0.1, 127.0.0.2, ...) and each verified the certificate for hooks.test, which names no IP address at all. A check that resolves and a connect that resolves again would have seen both answers for one delivery.
  • 64 deliveries behind 64 lookups of 300 ms finish in 1.24 s (the resolver has 16 slots: four waves, the rest of the 64 wait for a slot, they do not fail).

What the thread version costs in the language (rthread.cho, resolve_demo.cho; the reproducers are in §9), which is why it is not the decision:

  1. getaddrinfo cannot be declared (a list of structs behind a struct addrinfo **), so the thread calls res_query and parses the DNS answer itself with dns.cho; that has no /etc/hosts, no search list and no nsswitch.
  2. res_query’s name is a string that is not the last parameter, so it cannot be declared with a slice; the worker passes a pointer it gets from basename, which answers a pointer into its own argument (gap 6), as an int. (strdup and free would do; a declaration of free is an internal error on the LLVM backend, gap 8.)
  3. A thread gets one pointer-wide payload. It needs the authority to call libc and a channel to the poller; they cannot both cross. A shared reference to the Ffi crosses (and the main thread keeps using the same Ffi for OpenSSL: gaps/t3); a struct that owns the Ffi crosses and the main thread loses it until join (gaps/t1); a Conn does not cross (g17). So the worker makes its own channel with libc’s socket and connect, which needs a raw connect that only an edition-1 file may declare (gap 7: it comes from the net.connect package, so this demo’s scope is Ffi("libc")), to a loopback listener on a port both sides know, because nothing says which port an ephemeral listener got (gap 9).
  4. The poller cannot wait for a thread: join is the only way to learn it finished (g16), so completion has to arrive as bytes on that loopback connection, which is a second protocol written in the example.
  5. A Thread is a resource and the containers hold only copyable things (g10), so four workers are four named locals joined by name; the pool’s size is in the program text. A function value cannot be written module.worker (g9).
  6. res_query answers -1 for NXDOMAIN and for a dead server alike; the reason is in h_errno, a thread-local reached through a pointer nothing can read (gap 5). The tcp resolver tells them apart (-103, -200, -203).

What the thread resolver does give: libc’s UDP, retries and server rotation for free, and a resolver that works with whatever resolv.conf says without our reading it. If names are wanted and a resolver of one’s own is not, it works today; it needs conc in main’s row, four named workers, and a fixed loopback port.

8. Numbers

Method, for all of them (test/measure.py; the script is the method, and prints these tables; REPS=7, each row is the median of seven runs with the minimum and maximum beside it): the client under test is pinned to core 3 with taskset, the receiver (test/server.py, Python asyncio, two worker processes) to cores 1 and 2, on a 4-vCPU Firecracker VM (Intel Xeon @ 2.10GHz, with aes, avx2, pclmulqdq and sha_ni; Linux 6.18.44) that other work was also using; CPU is the client process’s user + sys from wait4’s rusage divided by the connections it made; memory is VmRSS read from /proc/<pid>/status at the client’s READY and HELD markers. The protocol state machine is TLS 1.3, TLS_AES_256_GCM_SHA384 (the server’s choice from the client’s OpenSSL defaults, printed by SSL_CIPHER_description) for every row not marked TLS 1.2; the TLS 1.2 rows negotiate ECDHE-ECDSA-AES256-GCM-SHA384 and ECDHE-RSA-AES256-GCM-SHA384. The certificates are ECDSA P-256 unless marked RSA 2048, signed by a P-256 CA. OpenSSL 3.0.13. Differences under about 15% between rows are noise on this machine: the same configuration measured on different runs of this document’s own script moved by that much.

8.1 Handshake CPU per connection

2,000 connections through 64 slots, handshake only (a close_notify and nothing sent):

configurationclient CPU per handshakehandshakes per second per core (wall, client pinned and saturated)
TLS 1.3, ECDSA, no verification, memory BIOs0.549 ms (min 0.487, max 0.633)1,796
TLS 1.3, ECDSA, verified (CA file, name checked), memory BIOs0.648 ms (0.610, 0.749)1,529
TLS 1.3, ECDSA, no verification, SSL_set_fd0.526 ms (0.466, 0.576)1,885
TLS 1.3, ECDSA, verified, SSL_set_fd0.661 ms (0.611, 0.804)1,503
TLS 1.3, ECDSA, verified, CA file and the system store loaded0.635 ms (0.589, 0.725)1,566
TLS 1.3, RSA 2048, no verification0.483 ms (0.439, 0.536)1,956
TLS 1.3, RSA 2048, verified0.567 ms (0.539, 0.621)1,719
TLS 1.2, ECDSA, verified0.641 ms (0.595, 0.836)1,549
TLS 1.2, RSA 2048, verified0.560 ms (0.542, 0.624)1,752
  • A verified handshake costs about 0.6 ms of CPU, one core does about 1,500 of them a second, and the two I/O transports are indistinguishable (0.648 against 0.661 ms verified, 0.549 against 0.526 unverified: inside the noise of the rows). The protocol version makes no difference either (1.3 against 1.2: 0.648 against 0.641 ms with ECDSA).
  • Verification costs about 0.1 ms per handshake (about 20%): 0.549 to 0.648 ms (memory BIOs), 0.526 to 0.661 (descriptor), 0.483 to 0.567 with an RSA certificate. Most of it is not the signature (§8.7: one ECDSA P-256 verification is 0.076 ms and one RSA-2048 verification 0.019 ms, yet the RSA row pays nearly as much as the ECDSA rows): it is building the chain, the date and name checks and the store lookups, which were not itemized. Verification being off is a 20% saving and not a reason to turn it off.
  • The trust store, once per process, not per connection (tls_nb making a single connection, so everything that is not a handshake shows; seven runs): no verification 6.0 ms (5.4 to 7.0) for the whole process, a CA file 7.5 ms (6.1 to 8.2), the CA file and SSL_CTX_set_default_verify_paths 32.9 ms (29.3 to 35.6). Loading the system store is about 25 ms: do it once, in main.
  • Against C: the same work, written the way a C programmer would for many connections on one thread (test/c_epoll.c: non-blocking sockets, SSL_set_fd, one epoll set, 64 in flight), measures 0.507 ms (0.466 to 0.604) unverified and 0.598 ms (0.566 to 0.671) verified, against 0.526 and 0.661 ms for cancho on the same transport: within the noise, a few percent at most. The simplest C (test/c_ref.c: blocking, one connection at a time) measures 0.80 ms unverified and 0.84 verified, more, because a core that waits for the peer between every handshake runs colder: handshake CPU depends on how busy the core is kept (§11).

8.2 Requests over an established session

64 connections held open, TLS 1.3, verified, thousands of requests each (128,000 for the small bodies); the 64 handshakes (0.64 ms each) are subtracted:

request bodyclient CPU per requestrequests per second on one core of the client (1 / CPU)wall rate achieved, receiver-limited
100 bytes0.0105 ms (0.0097 to 0.0114)~95,60056,214 (client 58% busy)
1,024 bytes0.0103 ms (0.0099 to 0.0111)~96,90053,962
16,000 bytes0.0168 ms (0.0153 to 0.0189)~59,50036,312
60,000 bytes (the most a cancho-hooks request can be)0.0476 ms (0.0451 to 0.0488)~21,000 (1.26 GB/s of body)1,407 (client 8% busy)

A request on a session that exists costs 0.01 ms, a sixtieth of the handshake: what a delivery to one endpoint spends is the handshake, and §10.4’s resumption and keep-alive are about that. The receiver is a Python process and is what limits the wall rate; the “on one core” column is a derivation (1 / CPU per request), not a measurement of a server that kept up.

8.3 Memory per connection

All handshakes done, connections held, nothing sent; RSS of the client process; the slope between 64 and 512 connections separates what each connection costs from what the process cost once (measure.py memory; the per-slot ciphertext buffer of the memory-BIO transport, 20,480 bytes a slot, is allocated before the first connection and is not in these figures):

configurationat 64 connectionsat 512 connectionsslope per additional connection
memory BIOs56.1 KiB each (52 to 59)48.5 KiB (38 to 51)47.5 KiB
memory BIOs, SSL_MODE_RELEASE_BUFFERS50.4 KiB29.1 KiB (28 to 32)26.0 KiB
SSL_set_fd50.6 KiB36.8 KiB (36.7 to 36.9)34.8 KiB
SSL_set_fd, RELEASE_BUFFERS40.0 KiB26.4 KiB24.4 KiB

About 26 to 48 KiB a connection over the whole of what OpenSSL keeps (the SSL, its 16 KiB read and write buffers unless released, the peer’s certificate chain, the session), and about one MiB of one-time cost. SSL_MODE_RELEASE_BUFFERS takes it to about 26 KiB and costs nothing measured here: use it. The memory-BIO transport is noisier than the descriptor one (38 to 51 KiB at 512 connections against a steady 36.8) and I did not find out why: the descriptor transport has no BIO buffers, which is a reason, not a measurement. For cancho-hooks at 64 slots: 64 x (26 + 20) KiB = 2.9 MiB with the release mode (3.6 MiB without) beside the 4.1 MiB its request buffers already take.

8.4 64 concurrent connections, one thread

tls_nb 127.0.0.1 <port> hooks.test 64 64 3 ... with a 300 ms hold after the handshakes (measure.py concurrency): 64 handshakes and 3 requests each in flight at once, all 64 succeed, and /proc/<pid>/status says Threads: 1 while all 64 are open (68 descriptors: the 64 sockets, the Poller, and the standard streams). With the 64 handshakes started together against the Python receiver the handshake latency averages 28 ms (maximum 40 ms: the receiver’s CPU, not the client’s) and the whole run takes 463 ms. 20,000 connections through 64 slots all succeed, on both transports (14.9 s and 14.8 s: receiver-limited), at a peak RSS of 13.8 and 14.1 MiB. 64 connections to a server that accepts and never answers, deadline 1,000 ms, all end at the deadline: 1.02 s of wall time and 16 ms of client CPU: the stuck ones do not run in turn and cost nothing while they wait.

8.5 Resumption

2,000 connections, 64 in flight, one request each, verified, TLS 1.3: 0.635 ms of CPU per connection without resumption (0.591 to 0.826) and 0.306 ms with it (0.291 to 0.436), 1,936 of 2,000 resumed (the first 64 start before any session exists). Resumption halves the CPU of a delivery to an endpoint it has delivered to before; §10.4.

8.6 What a name lookup adds

Handshake only, 2,000 connections, 64 in flight, verified: 0.690 ms (0.624 to 0.731) to an IP literal, 0.793 ms (0.709 to 0.895) when the name is resolved over DNS/TCP on the poller for every delivery: about 0.1 ms of CPU for a lookup (a TCP connection, a query, an answer, dns.parse). A per-endpoint cache with the answer’s TTL makes it a rare cost (§10.4).

8.7 The public-key work, alone

taskset -c 3 openssl speed -seconds 3 ecdsap256 ecdhx25519 rsa2048, the machine otherwise quiet: ECDSA P-256 verify 0.076 ms (13,107 a second), sign 0.024 ms (41,107 a second); X25519 one operation 0.035 ms (28,313 a second); RSA 2048 verify 0.019 ms (51,680 a second), sign 0.33 ms (3,010 a second). A TLS 1.3 client does one key generation and one derivation (X25519, OpenSSL’s first group by default; the negotiated group was not checked: about 0.07 ms), one signature verification for the server’s CertificateVerify and, when it verifies, a second for the certificate: about 0.15 ms of public-key work unverified and 0.22 ms verified for ECDSA of the 0.55 and 0.65 ms measured. The rest, about 0.4 ms, is not public-key work: parsing certificates, the key schedule, allocating and freeing an SSL and its buffers, and the system calls. It was not itemized further (no profiler was run). What is the same in C and in cancho is OpenSSL; what the language adds is a few percent (§8.1).

9. Every gap met, with a reproducer

Nothing below was worked around silently. Each row names the file in examples/tls_nb/gaps/ that shows it (python3 examples/tls_nb/gaps/check_gaps.py runs all 23 and exits 0 only if each still behaves as recorded here: a gap that gets fixed turns a row red, which is the prompt to update this table and the document that depended on it), what was done instead, and where the fix would live. C is the compiler, R the runtime, L the language or std, P a package, T the tooling.

#gapreproducerwhat this spike didwhere
1c_ptr cannot be named in an ordinary function’s signature or a struct field (unknown type c_ptr); a [c_ptr] slice and a generic T instantiated at c_ptr compile and run, but a generic body cannot call anything with its Tg1_cptr_param, g2_cptr_field; what works: g3_cptr_array, g4_genericevery handle is an int (§3.3), declared as the extern’s result: forgeable, and every SSL * type error is lostC: allow c_ptr wherever a val type may stand
2no conn_raw_fd; and the descriptor of a Conn in a std.conns.Table is readable without Ffi as the low 32 bits of its ticket (Table.tickets is a field of a pub struct)g5_table_ticketmemory BIOs (§3.2); tls.fd_of reads the ticket for the io=1 comparison onlyR: build conn_raw_fd (native-sockets.md section 6) and make Table’s fields private
3a C int result declared int is wrong: -1 reads as 4294967295 on both backends; examples/tls_client/ declared five sog13_int_vs_c_intc_int everywhere; the example is fixedL: nothing to fix in the language; a lint on extern results named after an int-returning libc/OpenSSL symbol would have caught it
4a foreign function with two string parameters, a string that is not last, or a pointer out-parameter that is not last cannot be declared: a slice crosses as a pointer and a length, so the next parameter’s register holds the length (strcmp(a, b) crashes with SIGSEGV; BIO_new_bio_pair has two out-parameters)g7_two_strings, g14_out_paramsSSL_CTX_load_verify_file (one string) instead of ..._locations; X509_VERIFY_PARAM_set1_host (pointer and length) instead of SSL_set1_host; memory BIOs made one at a time with BIO_new(BIO_s_mem()) instead of BIO_new_bio_pairC: a parameter kind that is a bare pointer (a NUL-terminated slice), or an extern form that says “this slice is a pointer only”
5nothing C returns can be read: a const char * (SSL_get_version), a struct, getaddrinfo’s list, h_errno (a thread-local reached through a pointer)g8_c_string_resulttext comes out only through functions that fill a buffer the caller owns (SSL_CIPHER_description, ERR_error_string_n); the thread resolver loses NXDOMAIN versus failure (§7, item 6)C: a read of n bytes through a c_ptr into a slice, under Ffi
6no address of a buffer: a pointer to the caller’s own bytes can be made only by a libc function that returns its argument (basename)rthread.cho (basename)used once, in the thread resolverC: the same builtin as 5, the other way
7connect is a builtin from edition 2 on and a foreign declaration’s Lex name is the linked symbol, so a raw connect(2) can only be declared in a file of edition 1g11_connect_is_a_builtin (.cho.txt)the thread resolver calls net.connect’s connect_to: the package declares connect in edition 1; the first version had a file of its own, which the repository’s count of network programs refuses (§4)known (foreign-linking.md); an extern that can name its symbol differently from its Lex name
8a foreign declaration of libc’s free (and malloc) is accepted by check and refused by the LLVM backend as an internal error (invalid redefinition of function 'free'): the emitted module already declares them for its heap; Cranelift acceptsg6_extern_freebasename instead of strdup/freeC: the backend should declare with the program’s signature or refuse with a rule tag (CONTRIBUTING.md: every refusal has one)
9threads: (a) a payload is one pointer-wide leaf, so a thread cannot be given a capability and a channel (a shared &Ffi crosses, a struct owning the Ffi crosses and moves it, a Conn does not); (b) join is the only way to wait, a Poller cannot watch a thread; (c) no listener_port, so a loopback channel needs a port agreed in advance; (d) a Thread cannot be stored in a container; (e) a function value cannot be written module.function; (f) every caller up to main declares conct1_spawn_job, t3_shared_ffi_payload, g17_conn_payload, g16_no_thread_poll, g15_no_listener_port, g10_thread_in_container, g9_qualified_function_value, t2_ref_field (.cho.txt)the poller resolver (§3.7) needs none of this; the thread resolver (§7) works around eachR: thread_done, a Poller registration for a thread or a Conn from socketpair/eventfd, listener_port; L: struct payload (threads.md section 5 says “decided not yet: no asker”; this is one)
10Ffi scope: nominal (nothing ties it to a library), one per program, hard-coded as "libc" by net.sockets/net.connect, and the authority report is UNBOUNDED whatever it is calleda1_ffi_scope, a2_scope_is_nominal, g12_scopes_do_not_composeone scope named by purpose (tls) (§6)L/P: a scope per capability value instead of per program; packages that take the scope as a parameter
11[[bin]] in the project file has no linking options (package-system.md: “no linking options in [[bin]]”), so cancho build in a project cannot link OpenSSL(below)a CC wrapper: CC=./cc_ssl cancho build ... where cc_ssl is exec cc "$@" -lssl -lcrypto: verified to build and link tls_nb (ldd shows libssl.so.3, libcrypto.so.3)T: links = ["ssl", "crypto"] in [[bin]]
12standard output is fully buffered when it is not a terminal and there is no flush (std.io’s own comment says so): a progress marker written to a pipe arrives at exittest/harness.py (markers on standard error)READY and HELD go to standard errorL: io.flush

(Row 11’s reproducer is one command: printf '#!/bin/sh\nexec cc "$@" -lssl -lcrypto\n' > cc_ssl; chmod +x cc_ssl; CC=./cc_ssl cancho build tls.cho dns.cho rtcp.cho pin.cho tls_nb.cho --std -o tls_nb.)

Not a gap of the language but found by the integration, and recorded in §2 and §3.2: OpenSSL’s socket BIO writes with write(2) and a closed peer kills the process with SIGPIPE; and SSL_CTX_set_default_verify_paths costs about 25 ms and must run once per process.

10. What cancho-hooks would need

All of this was read from cancho-hooks (src/attempt.cho, src/endpoints.cho, src/destination.cho, src/hooks.cho, sql/, docs/design.md sections 16 and 26) and none of it was changed or run there.

10.1 The endpoints: a scheme, a name, a pinned address

Today an endpoint is <id> <host> <port> <secret> (endpoints.conf, and the endpoints (id, host, port, secret) table), host must be a public IPv4 literal (section 26), the request carries Host: receiver (src/hooks.cho:1293, a constant), and an attempt dials host:port.

changedetail
a scheme and a namea fifth field https and an optional sixth, <name>, in the file (<id> <host> <port> <secret> [https [<name>]]); columns tls smallint not null default 0 and tls_name text in the table; POST /endpoints and PATCH /endpoints/:id take "scheme": "https" and "name". port defaults to 443 for https. name is what the certificate must name, what goes in SNI and what the Host header says; it defaults to host when host is a DNS name
host for an https endpointslice T1 (no resolver): an IPv4 literal, with name required (the address is dialled, the name is verified: §7’s pinned design with the pin written by the operator). Slice T2: host may be a DNS name, resolved and judged at each attempt (§10.2)
the Host headerthe endpoint’s own host (and :port when not 443) instead of receiver, for every endpoint, plain ones included; the signed request is built before the connection, so this is request_for’s argument
in memoryendpoints.stride() is 7 integers (slot, port, host_start, host_len, key_start, key_len, id); add tls, addr (the pinned packed IPv4, 0 when unresolved) and addr_expiry (clock ms): 10
historyattempts.status already holds negative reasons (-1 to -4). Add: -5 TLS handshake failed (the peer closed or spoke badly), -6 certificate refused, -7 name did not resolve, -8 destination refused; and a second column detail int for the X509_V_ERR_* number or the DNS code, so “expired” and “wrong host” are told apart in GET /events/:id/attempts

10.2 The SSRF rule (design section 26)

Section 26’s rule is “an IPv4 literal, public, nothing else, because a name cannot be judged before the connection”. With a resolver that judges the answer and pins it (D7, §7) the rule becomes, for a name:

  1. the name must be a syntactically valid host name (labels of letters, digits and hyphens, 253 bytes at most) at the moment an endpoint is written; that is all that can be checked then, and it is a weaker write-time guarantee than today’s (a name that resolves to 10.0.0.5 is stored, and every attempt to it fails with status -8);
  2. at every attempt (or once per TTL: §10.4), the name is resolved, every A record is judged by the ranges of section 26 (pin.choose: one private address refuses the whole answer), and the connection is made to the address chosen as an IP literal; allow-private-hosts 1 keeps its meaning (any address passes) and is what the tests and demos use;
  3. a redirect is still never followed; the port is still not limited; IPv6 (AAAA) is not asked for, so a name with only an AAAA record fails as “no address” (-7).

“Refuse, don’t downgrade” changes character: for IP literals the refusal is still at the write (a 400 that stores nothing; a bad file stops the start), for names it is at the attempt (a recorded failure that is retried like any other and that an operator reads in the history). A deployment that wants the old guarantee for everything keeps https endpoints to literals plus a name (slice T1’s form) and does not enable names.

10.3 The attempt state machine (src/attempt.cho)

Today: connecting -> sending -> reading, a stride of 8 integers a slot (state, endpoint, event, deadline, sent, held, request length, seven used), the request copied into a 66,560-byte slot buffer, the connection in a std.conns table watched on the server’s poller under token0 + slot. With TLS and names it becomes

(resolving)  -> connecting -> (handshake) -> sending -> reading -> (shutdown) -> finish
 name only      TCP             tls only       now SSL_write      SSL_read       close_notify, close
  • resolving is not a slot: a lookup is a Resolver slot (rtcp.cho, 16 of them, tokens above the pg pool’s) and the attempt waits in the queue of attempts for an endpoint whose address is unknown or expired; when the answer is judged and pinned the attempt takes a slot and goes to connecting. (The spike does the same: st[7] counts lookups, an attempt takes its slot only when it dials.) One lookup serves every attempt that waits for the same endpoint.
  • handshake and the sending and reading phases call tls.handshake/write/read (§3.4) instead of conns.write/conns.read; advance keeps its shape (a loop that runs until something has to wait). Per slot: 10 more integers (tls.stride()) and tls.out_max() = 20,480 bytes of ciphertext; TLS costs 64 x (37 KiB + 20 KiB) = 3.6 MiB at 64 slots (26 KiB each with SSL_MODE_RELEASE_BUFFERS), on top of the 4.1 MiB the request buffers already take.
  • the deadline (2 s by default, covering connect, send and the status line) now also covers lookup and handshake; the handshake alone is one or two round trips plus about 0.6 ms of CPU (§8.1). A per-stage budget (lookup, connect, handshake, request) is better than raising the one number.
  • finish calls tls.shutdown (a best-effort close_notify) before the close, tls.drop always, on every path (the leak tests in §5 are the gate).
  • the read path stays “the first 12 bytes of the response”, as it is: a close_notify before the status line is no_answer, as an end of file is today.
  • conn_write’s MSG_NOSIGNAL is why the memory-BIO transport needs no signal disposition (§3.2); hooks must not switch to SSL_set_fd without sigpipe=ignore.

10.4 TLS session handling

  • One SSL_CTX per process, made in main (the trust store is read once: about 25 ms with the system store, a millisecond or two with a file, §8.1) and freed at exit; SSL per attempt.
  • Resumption pays: 0.64 ms of CPU per connection falls to 0.31 ms (§8.5), 1,936 of 2,000 connections resumed. It needs a saved session per endpoint: tls.save_session after the first complete response (with TLS 1.3 the ticket arrives after the handshake and the first SSL_read is what reads it), tls.open(..., session) on the next attempt, one integer per endpoint in the table and a tls.free_session when it is replaced, when the endpoint’s host or scheme changes (PATCH), and at exit.
  • A resumed session skips certificate verification, which is what makes it cheap and what makes it a hazard: key a session by (endpoint, host name, trust-store generation) and never offer one for a different host name or after the trust configuration changed. The spike has one host and does not model this.
  • Keeping the connection is the larger saving (a request on an established session is 0.01 ms of CPU, §8.2, against 0.6 ms for the handshake) and a bigger change than this document scopes: attempt closes after each response today; a per-endpoint pool changes the table, the deadlines and the retry accounting. Resumption first.

10.5 Certificate trust configuration

--tls-trust system|file:<path>|both (default system) and a GET /config field; at start tls.context(verify, default_paths, cafile); a failure to read the store stops the start with a status and the reason (as an unreadable endpoints table does, section 24), never an unverified client. SSL_CERT_FILE/SSL_CERT_DIR override the system store when set (tested in §5): a deployment that does not want the environment to change what is trusted sets file:. The minimum protocol is TLS 1.2 and is not configurable in v1. Revocation, client certificates and pinning a certificate or key per endpoint are not in v1.

10.6 Slices, each with a gate that can fail

slicewhatgate
T0 (cancho, optional hardening)links in [[bin]] (gap 11), c_ptr in signatures (gap 1), the free internal error (gap 8), conn_raw_fd (gap 2)the compiler’s own: cargo test --workspace; none is needed for T1 to start (a CC wrapper links; int handles work)
T1 tls packagetls.cho as a package (packages/tls, or cancho-tls) with the verify matrix, leak, partial-I/O and fuzz tests moved with it; hooks gets tls, attempt.cho phases handshake, the tls flag, Host, trust config, the history codes, ffi("tls") on the chain, IP-literal https endpoints onlyverify_matrix (every row, both reasons told apart in /events/:id/attempts), the existing suites unchanged and green (retry across kills, signatures, ingest under power cuts, isolation), a new isolation row (64 silent TLS peers, ingest p99 within the 80 ms of section 16), cancho authority shows ffi("tls") only on the chain, the mutants of the new phase (handshake never advanced, verify off, session never freed) killed
T2 namesdns.cho, rtcp.cho, pin.cho into the package; resolving queue, per-endpoint pinned address with a TTL clamp (30 s to 5 min), the SSRF rule of §10.2, tests/ssrf_test.py extendedpinned_test rows (private refused with no connection made, mixed answer refused, rebinding: one lookup per attempt, NXDOMAIN and unreachable server as recorded outcomes), the 41 refused hosts of section 26 still refused, a slow-DNS isolation test (the loop’s longest gap under 15 ms), dns_fuzz in CI
T3 sessionsthe per-endpoint session table, /stats counters (handshakes, resumed, verification failures by code), invalidation on PATCHresumption rate in a delivery run, a changed host name never resumes (a test that fails if it does), the leak test still green

Recommendation: TLS in cancho-hooks is achievable now, with the compiler as it is, in three slices of the service (T1, T2, T3) and no compiler slice required. T1 alone delivers https to endpoints an operator gives as an address plus the name its certificate carries; names that the service looks up itself are T2, and an operator with customers’ hostnames needs T1 and T2 together (T2 is where the SSRF rule changes, so it is the one to review hardest). T0 removes workarounds (int handles, a CC wrapper, a descriptor reached through a ticket) and should follow, not precede. The risk that does not go away is §6: the authority report becomes UNBOUNDED.

11. Honest limits: what was not verified

  • Only local servers. Every handshake was against server.py (Python’s ssl, OpenSSL 3.0.13) or openssl s_server. Nothing was connected to a real public host: outbound traffic in this environment goes through a proxy, so a public certificate chain (a real intermediate, a real root in the system store, a wildcard, an SNI-routed host) was not exercised; the system store was exercised only by pointing SSL_CERT_FILE at the test CA.
  • One machine, shared. 4 vCPUs of a Firecracker VM, other work running; the client pinned to a core, the receiver to two others. Rows carry the minimum and maximum of seven runs; differences under about 15% are noise (the verified-versus-unverified row is one of them: §8.1). The receiver is Python and is the limit on requests per second.
  • Backends. Everything above ran on the default (LLVM) backend. The Cranelift build of tls_nb was spot-checked (200 verified connections, 64 on the descriptor transport, an expired certificate: all as expected), and dns_fuzz and the reproducer g8 were run on both; the verify matrix and the measurements were not repeated on Cranelift.
  • No Darwin. -l ssl -l crypto needs OpenSSL on the linker’s path, which foreign-linking.md says macOS does not give by default; EINPROGRESS, SO_ERROR and the socket constants of tcp_connect_start are Linux-run only.
  • TLS 1.2 renegotiation, early data, post-handshake authentication, session tickets refused by a server on reuse, IPv6, internationalised host names, wildcard certificates, IP SANs, revocation (CRL, OCSP, stapling), client certificates and certificate pinning: not exercised, and the last four not built.
  • Large responses. The client reads until a known response length (the test receivers are fixed-size) or, as hooks does, a status line; a chunked or very large body was not read through TLS.
  • hooks itself. Nothing was changed or run in cancho-hooks; §10 is a design read from its source.
  • The fd transport’s partial-I/O behaviour is not under the shim (OpenSSL’s own socket BIO calls read/write, which the shim does not wrap); only the memory-BIO transport, the chosen one, is.
  • Out-of-memory paths: tls.open leaks a BIO if the second BIO_new fails after the first succeeded (§5); not exercised.
  • Handshake CPU depends on the load on the core: a blocking C client, one handshake at a time, measures 0.80 ms where the same work with 64 in flight measures 0.51 (§8.1): the work is the same and the idle core is not free. Quote a figure with its concurrency.

12. Reproduce

# once: the certificates, the compiler is the checked-in one
cd examples/tls_nb && export TLS_NB_WORK=/tmp/tls_nb_work
python3 test/verify_matrix.py          # §5   every certificate outcome, both transports, the SIGPIPE rows
python3 test/leak_test.py              # §5   descriptors, memory, valgrind
python3 test/partial_io_test.py        # §3.2 partial and refused I/O, a silent peer (needs `cc`)
python3 test/resolve_test.py           # §7   three resolvers against a slow name server (needs `unshare -m`, root)
python3 test/pinned_test.py            # §7   resolve, judge, pin, connect, verify
python3 test/pin_test.py               # §7   pin.cho against cancho-hooks' destination.cho (needs a checkout beside this one; exit 77 without)
cancho run dns.cho dns_fuzz.cho --std   # §7   a million damaged answers
python3 gaps/check_gaps.py             # §9   every reproducer
REPS=7 python3 test/measure.py         # §8   every number (about 20 minutes); sections: cpu reference requests memory concurrency resume resolve

13. Corrected elsewhere, in place

  • native-sockets.md section 6 said a program without Ffi cannot reach the descriptor hatch: it can read the number from a ticket (§3.2, gap 2).
  • examples/tls_client/tls_client.cho declared C int results as int (§2): fixed; foreign-linking.md section 4 notes it.

Under a grant

Status: measured, and it falsifies a sentence in reach.md §5.2.

README.md and authority.md both say authority --output json gives the report “for a supervisor checking it against a grant”. No supervisor had ever been asked. This takes lex-os’s real grant and tries to write the check.

One of its three dimensions is enforceable and cancho is finer than the grant asks. Two are not enforceable at all. And the field reach.md §5.2 offers as covering the difference is a proof about names and a heuristic about domains — six lines defeat it.


1. The two shapes

lex-os’s grant, from demo/manifest.json:

"grant": { "filesystem": "Full", "network": "Allowlist", "exec": "Full" },
"egress": ["results.demo.internal:443"]

Three dimensions and a host allowlist. A cancho authority report, for examples/cut/:

{
  "effects": ["args", "err_write", "heap", "io_read", "io_write"],
  "labels": [{ "name": "args", "argument": null }, ...],
  "foreign_symbols": []
}

The question is whether a supervisor holding the first can decide the second.


2. One dimension at a time

Grant dimensionEnforceable from the report?
filesystemYes, and more precisely than askedfs_read(p) and fs_write(p) carry a path prefix where the grant has a tri-state. A report saying fs_read("/workspace") answers ReadOnly and answers which directory
networkNo
execNo
egress — which hostNo information at all

The two no’s are one no. Here is examples/serve/, which binds a TCP port, listens, accepts a connection and answers HTTP:

{
  "effects": ["args", "ffi"],
  "labels": [{ "name": "args", "argument": null },
             { "name": "ffi",  "argument": "libc" }]
}

A program that runs a network server reports args and ffi. The row is not wrong — reach.md §5 argues that correctly, and the argument holds: the row is exact about what the program was granted, which is a C library. It is silent about the network because libc is not an authority domain.

What is new here is the consequence. Against a grant that says network: None, this report is not merely coarse. It is unable to refuse, and the only cancho program in this repository that touches the network is the one it cannot refuse.


3. The symbol list is a proof about names

reach.md §5.2 offers the foreign symbol list as what covers the difference, and says a supervisor reading socket, bind, listen, accept

“knows it is being asked to run a server, and knows it without trusting a word the program says about itself.”

The first half is right and the second half is not. Six lines:

extern fn syscall[&f](ffi: &f Ffi("libc"), n: int, a: int, b: int, c: int)
    -> [ffi("libc")] int;
...
r = syscall(f, 41, 2, 1, 0);        // SYS_socket, AF_INET, SOCK_STREAM

It type-checks. Its report:

{ "effects": ["ffi"], "foreign_symbols": ["syscall"] }

Neither field says network, and the program opens a socket.

The list is sound about what it claims: every foreign call needs a declaration, so foreign_symbols is exactly the set of foreign names reachable from main, and that is a proof in the same way the row is. The step that is not a proof is the next one — from a name to a domain. socket suggests a socket because C’s authors named it that, and the declaration in a cancho program is written by the program’s author, who may choose syscall, or a wrapper, or a name from a library whose domains nobody has enumerated.

So: a proof about names, a heuristic about domains. Against a cooperative program the heuristic is informative and worth having. Against the threat model lex-os exists for — “the agent is not trusted” — a heuristic is not a wall.

§5.2 is corrected in place.


4. The diagnosis, in one sentence

Every capability in this language bounds what it authorises, except one.

CapabilityIts label says
Fs(p)which directory
Iowhich stream — io_read, io_write, err_write are three labels
Filefile_read, and the path is spent at open_read
Heapallocation, which reaches nothing else
Argsthe command line
Ffi(lib)which library — and a library is not a domain

reach.md §5 says this as a limit on narrowing. It is larger than that: Ffi(lib) is the one capability whose label does not bound what it authorises, and every authority domain the language has not yet named is reachable through it under a single grant.

That is also why the filesystem row above is the one that works. filesystem.md §2 took files out of libc and made them builtins under Fs(p), and the reason the grant’s filesystem dimension is enforceable today is that someone already did the thing §5.1 describes for sockets.


5. What this promotes

reach.md §6 carries “sockets as builtins under Net(host)” as an open row, and §5.1 frames the argument as a question of taste — “a host is a thing worth narrowing to” — with the honest note that adding Net without taking sockets out of libc would produce a row that lies.

That framing was right and is now incomplete. The row is the prerequisite for cancho code running under a lex-os grant at all, because two of that grant’s three dimensions cannot be decided without it. It is not “the row should be finer”. It is “the row cannot answer the question the runtime asks”.

The roadmap’s lex-os join moves accordingly: it is not blocked on a compiler integration, and the supervisor does not need to embed the cancho front end — authority --output json is already the right interface and filesystem already works through it. It is blocked on the effect vocabulary, which has one label where the grant has three dimensions.

5.1 And the report now fails closed (#75)

Until Net exists, the report can at least stop being calm about what it cannot see. An outside audit proposed it and it is the cheapest correct thing available:

{
  "bounded": false,
  "effects": ["args", "ffi"],
  "labels": [
    { "name": "args", "argument": null,   "bounded": true },
    { "name": "ffi",  "argument": "libc", "bounded": false }
  ],
  ...
}

bounded is the first field, so it is the one a consumer that reads nothing else reads, and it is false whenever any reachable label fails to name its own domain — which today means exactly ffi. The prose report opens with UNBOUNDED and marks the label.

What this does and does not change:

  • A supervisor that refuses on bounded: false now refuses examples/serve/ under any grant, including one where it would have been allowed. That is the point of failing closed: the default is safe, and admitting a particular unbounded program is a decision about that program, which the report should not make on the supervisor’s behalf.
  • It does not make the network enforceable. It makes the absence of enforcement impossible to miss. Net is still §5’s prerequisite.
  • The foreign symbol list stays, for the supervisor that does decide to read further — §3’s heuristic, now clearly labelled as one.

6. What this does not say

  • Not that the report is broken. It is exact about what it claims, and the filesystem dimension is enforceable more precisely than the grant can express. What it cannot do, it cannot do for a reason that is written down.
  • Superseded in part by foreign-authority.md. bounded: false is now accompanied by unbounded_by, the exact scope:symbol pairs it is made of, so a supervisor decides over symbols and a CI pin diffs an added one; bounded keeps the meaning given in §5.1. The “proof about names, heuristic about domains” of §3 stands unchanged: the report still cannot say what a symbol does. That document also closes a hole this one missed: an extern fn with no Ffi at all was accepted and reported bounded: true.
  • Not that foreign_symbols should go. It is the best available signal and reach.md §5.2’s first half stands. What changes is the sentence claiming it needs no trust.
  • Not a lex-os change. Nothing here asks that repository for anything; the gap is on this side, and so is the fix.
  • Not that syscall is a hole to be plugged. Refusing that one name would be theatre — the next spelling is a wrapper. The hole is Ffi(lib)’s width, not any symbol’s name.
  • The symbol list is reachability-pruned now. authority.md §3 found it briefly was not: Program::externs lists every extern fn declared in the compiled unit, and until this was fixed that meant every one, not only the ones a reachable call site actually calls — surfaced once a program (examples/fetch/fetch.cho) imported a package declaring more than it calls. cancho-ir::reachable_externs closed it: the report is now exactly what main reaches, the same promise §3 above already made for effects.

7. The suite

TestShows§
a_network_program_reports_no_networkexamples/serve/ binds a port and its row is args, ffi — the gap, pinned, so a future Net(host) closes it visibly rather than silently2
the_symbol_list_is_a_proof_about_namesthe six-line syscall program type-checks and reports ["syscall"]3
the_filesystem_dimension_is_enforceablea program’s fs_read prefix is in the report, which is the dimension that works and why2, 4

Floating point

Status: settled and built.

defined-behaviour.md §8 listed floating point as absent rather than undefined, with three questions attached: IEEE-754 semantics, NaN ordering, and whether the optimiser may reassociate (it may not). §8’s rule is that each gets its answer in the slice that adds it, so this is that answer — plus the one §8 did not ask, which turns out to be the only hard one.

against-c-and-rust.md §4 is why this is worth doing and why the reason is not speed: on the kernel measured there, f64 was 17% slower than the Q16.16 fixed point this language forced. What was missing was ten orders of magnitude of precision, and every numerical method’s error analysis with it.


1. float is binary64, and that is the whole type

let half: float = 0.5;
let big = 6.02214076e23;

One floating type, named for what it is rather than how wide it is — int, byte, bool, float. (Corrected: this paragraph said “There is no f32: a second width is a second set of conversion rules and a second rounding story”, and cancho-gpu asked (f32.md, #251). f32 now exists from edition 6, with no implicit conversion in either direction, which is what keeps the second set of conversion rules at one: the programmer writes f32_of or float_of32 at each crossing. float is unchanged and is still binary64. defined-behaviour.md §8’s deferral of “unsigned integers and other widths” stands for every width but this one.)

A literal needs a decimal point with digits on both sides, or an exponent. 1.0, 1e9, 2.5e-3. Not 1. and not .5: both read as typos more often than as numbers, and neither is needed.

1.0 does not collide with t.0, the tuple component (tuples.md §3.1) — but the reason is not the one the first draft of this document gave.

It said the lexer could decide from the character before the dot, since an index follows a name and a literal follows a digit. That is wrong, and the parser’s own test said so within the hour: t.0.1 is two components, and the lexer reading it left to right sees t, ., and then 0.1, which looks exactly like a float.

So the rule needs one bit of context, stated from the other side:

A number immediately after a dot is a tuple index, and does not get to start a float of its own.

One lookbehind of one token. Rust settles the same collision the same way, and the alternative — making the parser split a float token back into two indices — moves a lexical question into the grammar.


2. IEEE-754, in full, including NaN

Arithmetic is IEEE-754 binary64 with round-to-nearest-even. +, -, *, / are the IEEE operations exactly. Infinities and NaNs exist, are produced where IEEE says, and propagate where IEEE says.

1.0 / 0.0        //  +inf, and that is the answer
0.0 / 0.0        //  NaN

2.1 Which looks like a contradiction, and is not

defined-behaviour.md §2.1 refuses wrapping arithmetic on the grounds that “a silently wrong answer is worse than a stopped process, because the wrong answer propagates and the stop does not.” Integer division by zero traps. So why does floating division by zero not?

Because the two are different things wearing the same shape:

Wrapping lies about a value. NaN announces the absence of one.

int::MAX + 1 has a correct answer, the answer does not fit, and wrapping hands back a different number that looks exactly like a result. Nothing downstream can tell. That is the silently wrong answer.

0.0 / 0.0 has no correct answer, and NaN is the correctly-typed way to say so. It is not a number pretending to be the right one; it is a value whose entire meaning is “there is no number here”, and IEEE-754 specifies its propagation completely. A program that reaches NaN and prints it has not been lied to.

Two more reasons the trap would be wrong even if the first argument were not:

  • It would cost what overflow-cost.md §3.2 measured, and more. A trap is observable, so an operation that may trap cannot be reassociated or vectorised. Checking every floating operation would defeat exactly the loops this feature exists for — and IEEE’s design assumes the opposite discipline: compute the whole array, check once at the end. That is what NaN propagation is for.
  • C can hand us one. Ffi calls returning double exist (reach.md §3.1 permits scalars), so an unrepresentable NaN would need a check at every foreign boundary.

And defined behaviour is not weakened, which is the point worth being precise about. IEEE-754 is a complete specification: every operation has a defined result for every input, NaN included. The undefined behaviour in C’s floating point is not in the arithmetic — it is in the conversions (§4) and in the licence to reassociate (§3). This document defines both.


3. The optimiser may not reassociate

§8 predicted this and it stands: (a + b) + c is not a + (b + c), and no flag makes it so. There is no -ffast-math, no @fast annotation, and no plan for one.

The reason is the project’s, not IEEE’s. defined-behaviour.md §1 promises reproducibility: the same program gives the same answer, and canonical-ast.md hashes a body so two builds of the same source are the same program. An optimiser permitted to reassociate makes the answer a function of the optimisation level, which is a silent difference between a debug and a release build — the exact failure mode the trapping arithmetic rule exists to prevent, arriving through a different door.

A program that wants a different summation order writes it. Kahan summation is nine lines and says what it is doing.


4. Conversions are explicit, and one of them traps

No implicit conversion, in either direction, for the reason strings.md §2 gives about byte: the conversion is where the decision lives, so it is written where a reader can see it.

float_of(n: int) -> [] float      // exact to 2^53, round-to-nearest beyond
truncate(x: float) -> [] int      // toward zero; traps on NaN, ±inf, out of range

float_of rounds rather than trapping, which is a deliberate break from byte_of’s refusal to truncate. The two are not alike: byte_of(300) loses the magnitude and hands back 44, a different number entirely; float_of(2^53 + 1) loses one unit in the last place, under IEEE’s defined round-to-nearest-even. One is a lie about which number this is, the other is the nearest representable number, which is what a floating type means.

truncate traps on exactly the inputs C leaves undefined: NaN, ±inf, and any magnitude at or beyond 2^63. C says the behaviour is undefined; this says the process stops. That is §2.1’s rule applying where it belongs — the result of truncate(NaN) would be a number, and there is no number it could honestly be.

The name states the rounding because the rounding is the thing a reader needs: truncate goes toward zero. Round-to-nearest, floor and ceiling belong in std.math, where each can say which it is.

4.1 And one that converts nothing

bits_of(x: float) -> int          // the same 64 bits, read as an integer

Not a conversion at all: no value changes, only the type that reads it. bits_of(1.0) is 4607182418800017408, which is 1.0’s sign, exponent and mantissa laid end to end.

Except for NaN, which has one representation (#76). IEEE-754 leaves a generated NaN’s sign and payload to the hardware, and x86-64 sets the sign where aarch64 does not. A bare reinterpretation therefore made bits_of(0.0 / 0.0) the one expression in the language whose value depended on the target. Every NaN now reads as 0x7ff8000000000000. Nothing is lost by this: arithmetic never reaches a NaN’s payload, and the one way to build one, float_of_bits (below, edition 7), is read back by bits_of as the same one pattern. See differential.md §4.

It exists so that taking a float apart is a program’s job rather than the compiler’s. The sign, the exponent and the mantissa are what a printer needs, and float-printing.md is the proof that having them is enough: std.fmt.float_into is written in cancho, on this one instruction, and nothing else about floats had to move into the compiler to make it possible.

float_of_bits(n: int) -> [] float   // edition 7: the float whose 64 bits are n

The inverse, and as little a conversion as bits_of: a bitcast, on both backends, no arithmetic. It was missing on purpose – “a builtin with no caller is a builtin nobody has checked” – until it had one: the table tool (cancho-table, docs/numbers.md) keeps a float column as an order-preserving int and had to turn it back into a double through ldexp and two loops over the exponent, and std.math wrote pow2 by squaring for the same lack. Every pattern is a value, subnormals, infinities and -0.0 included, so bits_of(float_of_bits(n)) == n for every n that is not a NaN’s. A NaN built with a payload is a NaN with that payload (is_nan sees it), but bits_of answers the canonical pattern for it, as for every NaN (above): the pair is an identity on everything but NaN payloads, and that is the one thing it does not do.

Edition 7 and nothing else about edition 6 changes: float_of_bits is a name an older file may already declare, so it is not a builtin there (tests/reject/float_of_bits_is_edition_seven.cho; tests/accept/float_of_bits.cho on both backends, and the 1,000,000-pattern round trip in scripts/float_differential.py).


5. NaN breaks comparison, and this document is not going to hide it

==, !=, <, <=, >, >= are IEEE’s. Which means:

let nan = 0.0 / 0.0;
nan == nan            // false
nan < 1.0             // false
nan >= 1.0            // false

== is not reflexive, and trichotomy fails. Both are IEEE-754 as specified, both are what every numerical library expects, and both are a real hazard: a sort comparing with < over data containing NaN produces a garbage order rather than an error.

So is_nan(x: float) -> [] bool exists, because the hazard has to be checkable and x != x is a riddle rather than a test. Whether a total order belongs in std.math — IEEE-754 §5.10 defines one — is §7.

This is the one place in the language where a value does not behave like a value, and it is inherited rather than chosen. The alternative was §2.1’s trap, which costs more than it is worth.


6. What the numbers mean

binary64Q16.16, which this replaces
resolution2.22 × 10⁻¹⁶1.53 × 10⁻⁵
range±1.8 × 10³⁰⁸±32768
range and precisiontraded automaticallytraded by hand, checked by nothing

against-c-and-rust.md §4.2 measured what the difference does to one program: the fixed-point and f64 Mandelbrots disagreed by 6,213 iterations out of 39.7 million, because pixels near the boundary land on the wrong side when the coordinate itself is only good to 1.5 × 10⁻⁵.


7. Open

Printing is no longer here. It was the first row of this table and the one called “what makes float awkward rather than incomplete”. float-printing.md closes it: std.fmt.float_into writes the shortest decimal that reads back to the same bits, in cancho rather than in the compiler, on one new builtin (bits_of). What is left below is the rest.

QuestionWhy it waits
std.math over floatsHalf answered — float-math.md. The capability question was the wrong question for sqrt: it is one instruction, so it reaches no library, needs no Ffi, and its row is []. It is a builtin rather than library code for the reason §2 there measures — the two programs that hand-rolled a square root got 58.4% of values wrong in the last place, and one was wrong by 143 orders of magnitude, because a correctly-rounded root is not expressible in cancho. sin, exp and log are the half that really is about error analysis, and nothing has asked
A total order§5. IEEE-754 §5.10 defines totalOrder; the question is whether std.math should carry it or whether sorting floats should simply be documented as the caller’s problem
f32F1, F2 and F3 built — f32.md. Asked by cancho-gpu (a measured 20× on its interpreter). The conversion-rule worry is answered there by having no implicit conversion in either direction; the type, + - * /, comparisons and the four conversions are on both backends and bit-identical to binary64 rounded once over 10⁶ random pairs and every special value. sqrt32 and the int conversions are F2; printing, fixed point and correctly rounded reading are F3 (std.fmt32, checked against Rust over every 32-bit pattern). % has no float counterpart and f32 has none
Literal parsing exactness0.1 is read by Rust’s f64::from_str, which is correctly rounded. Worth stating as a contract rather than an implementation detail once there is a second front end. (For f32, std.fmt32.f32_of_text states it: correctly rounded directly to binary32, f32.md §5.3)

8. The suite

FixtureRule§
float_without_conversion.choNo implicit int → float4
float_literal_needs_digits.cho1. is not a literal1
truncating_a_nan.choRefused where it can be seen; the runtime case is a conformance test4
TestRule§
truncate_traps_where_c_is_undefinedNaN, ±inf and out-of-range all stop the process4
float_arithmetic_is_ieee754Including that nan != nan and 1.0 / 0.0 is infinite2, 5
AcceptingShows
floating_point.choLiterals, arithmetic, comparison, both conversions, is_nan, and §4.1’s bits_of — including that -0.0 keeps its sign where == cannot see it
examples/newton.cho§6: a numerical method that Q16.16 could not carry, now printing its residuals as floats (float-printing.md)

f32, and the program that asked for it

Status: F1, F2 and F3 are built; F4 is not. The type, the suffix literal, + - * / and unary minus, the six comparisons and the conversions (f32_of, float_of32, bits_of32, f32_of_bits) are on both backends, and F1’s gate passes on both: every operation agrees bit for bit with binary64 over 10⁶ random operand pairs plus 614² special-value pairs (and, once, over 10⁸), in 0.45 s on Cranelift and 0.27 s on LLVM (§5.1). F2 added sqrt32, f32_of_int and int_of_f32 (§5.2). F3 is std.fmt32 (f32_into, f32_fixed_into, f32_of_text): printing is byte for byte Rust’s {:?} and {:.N}, checked against all 2³² patterns, and reading is correctly rounded straight to binary32 (§5.3). It is edition 6, with no edition of its own (§6). Where the build disagreed with the design this document says so in place: the conversion is float_of32, not float_of (§2); the gate’s first oracle can be folded away by LLVM and has a second beside it (§5.1); a struct’s f32 field is still eight bytes, and only a [f32] is four (§2); an f32 static is deferred (§5); the printing functions live in std.fmt32, not std.fmt (§5.3). Not built: % on f32 (float has none), cancho-gpu’s swap (F4).

floating-point.md §7 has carried the row “f32: a second width drags conversion rules behind it, and nothing has asked” since float landed. Something has asked: cancho-gpu (cancho#251) is lex-gpu’s .lx compiler written in cancho, with a reference interpreter whose results must match lex-gpu’s Rust byte for byte, and lex-gpu’s numbers are f32. This document says what the asker pays without the type, what the type would have to be, and what it must not become.

1. What the absence costs, measured

cancho-gpu’s interpreter (src/interp.cho, src/f32.cho) does every f32 operation as a binary64 operation followed by an integer rounding to 24 significant bits (bits_of, then math.ldexp twice). Printing is Steele and White shortest-form with ties up, to match Rust’s {:?}; {:.4} is exact ties-to-even. All 135 cases of its differential test agree with the Rust. That is the evidence the semantics can be written in cancho. The cost is in cancho-gpu/docs/design.md §10.2: gemm_mma and gemm_fp4 at 128³ take 0.33 s and 0.28 s against 13 and 17 ms for the Rust release build, about 20×; the 64×4096 matvec 0.18 s against 39 ms. All of the difference is rounding, so it is the number this document is trying to remove, and the number the first slice must re-measure on the type (§5).

Two other programs in this repository’s orbit are f32 programs without saying so: ds4-class inference engines (weights and activations in f16/bf16/f32 and block-quantised integers) and anything that reads a GGUF or safetensors header.

2. The type

let x: f32 = 0.5f32;          // a literal carries its width: no inference from context
let y = x + 1.0f32;
let z = float_of32(x);        // f32 -> float: exact, never traps
let w = f32_of(z);            // float -> f32: rounds to nearest even, overflow gives infinity
  • IEEE-754 binary32, round to nearest even, with the same answers to floating-point.md §§2, 3, 5 as float: NaN propagates and breaks comparison, -0.0 exists, the optimiser does not reassociate, and a fused multiply-add is never introduced silently.
  • The literal. A float-shaped literal (a point with digits on both sides, or an exponent) may end in the suffix f32: 0.5f32, 1e3f32, -0.0f32, 1_000.25f32. The suffix is part of the token. It is read once, from the decimal, by f32::from_str: reading it as a float and narrowing would round twice, and 1.0000000596046447753906251f32 shows it (a decimal one digit above the tie 1 + 2⁻²⁴: rounded once it is 0x3f800001; through binary64 it becomes the tie and rounds to even, down, 0x3f800000; tests/accept/f32.cho pins the first). An integer-shaped literal takes no suffix, so 2f32 is refused (unexpected-character), for the reason float needs a point or an exponent; the only other suffix is not f64 (refused, same tag), because that is the type float already is. A literal too large for f32 (1e39f32) is refused (literal-out-of-range), as one too large for float is, and infinity is f32_of_bits(2139095040). A bare 1.0 never becomes an f32 (§4). -1.5f32 is one literal, like -1.5, and -(1.5f32) is a negation.
  • No implicit conversion between f32 and float, in either direction, and no f32 + float, no f32 + int, and no comparison across widths: all are type-mismatch, blamed on the operand that disagrees. This is the one decision that keeps “a second width drags conversion rules behind it” from being true: there is one rule, written by the programmer at each crossing. f32_of is a rounding and says so; float_of32 is exact.
  • The four conversions are builtins, and the name is float_of32, not float_of. The first draft of this document called the exact widening float_of(x), and that name is taken: it is int -> float (floating-point.md §4), a builtin has one signature, and overloading it on its argument would be the first builtin whose type depends on a call site’s. So the width is part of the name, as in bits_of32: f32_of(x: float) -> f32, float_of32(x: f32) -> float, bits_of32(x: f32) -> int, f32_of_bits(n: int) -> f32. float_of is unchanged and refuses an f32 (float_of(1.5f32) is expected int, found f32).
  • f32 -> int and int -> f32 follow floating-point.md §4 (the one conversion that traps keeps trapping), with int -> f32 rounding to nearest even: int_of_f32 and f32_of_int. F2; built (§5.2).
  • Operators, comparison, std.math. + - * /, unary minus and the six comparisons are built (% is refused, type-mismatch, because float has none; sqrt32 is F2). sqrt32 is one instruction on both backends, so it is a builtin (float-math.md §3). Everything else in std.math stays over float: a library over f32 is the library’s own question, and the correctly-rounded route (compute in float, round once) is available to it and is what cancho-gpu’s exp32/ln_1p32 already do.
  • Printing and parsing: std.fmt32.f32_into writes the shortest decimal that reads back to the same f32, laid out as Rust’s {:?}; f32_fixed_into is {:.N}; f32_of_text reads a decimal to the nearest f32 in one rounding. It is a module of its own (edition 6), and not std.fmt as first written. The oracle is Rust’s own formatting, which is what the program that emulates f32 today (cancho-gpu/src/f32.cho::debug) was written to match. F3; built (§5.3).
  • Bits: bits_of32(x) -> int and f32_of_bits(n) -> f32 (low 32 bits). float-printing.md §8 left the inverse of bits_of for “when something needs it”; this needs both, and cancho-gpu needs them to write f16 and the fp4/fp8 encodings. bits_of32 zero-extends, so bits_of32(-1.0f32) is 3212836864, not negative, and every NaN reads as 0x7fc00000 (2143289344), for the reason bits_of canonicalises: x86-64 generates a NaN with the sign set and aarch64 without (floating-point.md §4.1). f32_of_bits keeps what it is given, payload included, and uses only the low 32 bits.
  • Layout: a [f32] slice is four bytes an element, so that it and a GPU buffer or a GGUF tensor are the same bytes (layout.md); the backends’ stride is 4 as byte’s is 1 (cancho-codegen’s layout::stride_of and cancho-codegen-llvm’s stride_of, one arm each). A struct field is not. The draft said “four bytes, four-aligned in a struct and in a slice”, and that was wrong about this compiler: every leaf of a struct, a tuple or a spilled value is an eight-byte slot (layout.md §2 measured it and cancho layout still reports it: a struct { a: f32, b: f32 } is 2 leaves, 16 bytes, 8 if packed). Packing leaves to their natural width is that document’s unbuilt change, it applies to bool and byte leaves alike, and F1 does not smuggle it in. What the stride change cost was small, as the brief hoped: the load or store of the one leaf already uses the leaf’s own machine type, so only the number an index is multiplied by changed. A [byte] is not reinterpreted as [f32]: reading one is f32_of_bits over four bytes, and the compiler is allowed to make that a load.

3. Not in this document: f16, bf16, integer SIMD

cancho-gpu also rounds to f16 and to e2m1/e4m3, and an inference engine wants f16 and bf16 as storage. The case is weaker than f32’s, because every consumer converts to f32 before arithmetic, so a storage type with f32_of_f16/f16_of_f32 and no arithmetic is enough. It is stated here so that the f32 slice does not close the door on it, and left for its own document once a program shows a loop where a bit-twiddling conversion is the cost.

4. What it must not become

  • Not a family of widths. f32 only. Each further width is its own measured case.
  • Not implicit. If a design review finds one place where it would be convenient for a literal 1.0 to become an f32, the answer is the f32 suffix, not an inference rule.
  • Not an excuse to weaken float. float stays binary64, and float-printing.md stays true of it.

5. Plan, each step with its own gate

stepwhatgate
F1 (built, §5.1)the type, literals, + - * /, comparisons, f32_of/float_of32/bits_of32/f32_of_bits, both backendsfor every f32 operation, on 10⁶ random pairs plus every special value, the result is bit-identical to f32_of(float_of32(a) op float_of32(b)) (binary64 holds an exact product and a correctly rounded sum of two binary32 values, so the emulation is the oracle)
F2 (built, §5.2)sqrt32, f32_of_int, int_of_f32 (% has no float counterpart, so none); the traps floating-point.md §4 lists, restated for the widtha must-reject case per conversion rule, each with a rule tag; integer-only oracles for all three on both backends
F3 (built, §5.3)std.fmt32: f32_into, f32_fixed_into, f32_of_text (not std.fmt: §5.3)printing is byte for byte Rust’s {:?} and {:.N} and reading is str::parse::<f32>’s, every one of the 2³² patterns checked against Rust’s printing by digest and by round trip and shortness inside the program (run once and recorded); 10⁶ and more differential cases per function; each function’s rule broken on purpose and the tests watched to fail
F4cancho-gpu swaps its emulation for the typeits differential suite (135 cases) unchanged, and the 20× above re-measured: the claim is that the interpreter gets within a small factor of the Rust, and if it does not, this document says so

The binary64-oracle claim in F1 rests on a classical result (Figueroa, 1995): when the wide format has at least 2p + 2 significand bits over the narrow format’s p, rounding the wide result of + - * / or sqrt to the narrow format gives the correctly rounded narrow result, the double rounding being innocuous. Here 53 >= 2 * 24 + 2 = 50. It is nonetheless tested, not trusted, which is why F1’s gate is a test and not the citation. (The same fact is what cancho-gpu’s emulation already relies on, and its 135 differential cases are the first evidence it holds on this compiler’s float.)

5.1 What F1 measured

The gate is tests/programs/f32_gate.cho, a cancho program, run on each backend by crates/cancho/tests/conformance/binary32.rs. Operands are made from bit patterns with f32_of_bits and nothing in the program is a literal, so what runs is each backend’s real binary32 instruction and not a constant the front end folded (an f32 literal is never folded, and an operation on two stays an operation: fold.rs has no f32 arm, so there is no host-f32 computation in the compiler to disagree with the backends). Every operation (+ - * / and the six comparisons) sees the same operand pairs:

  • every special value against every special value, in both orders: both zeros, both signs of every normal power of two (254) and of the 23 subnormal ones, the extremes, the neighbours of 1, 3, 2²³ and 2²⁴, the infinities, NaNs of both signs and with payloads, and a few decimals: 614 values, 376,996 pairs;
  • 10⁶ random pairs (splitmix64, fixed seed, so a failure reproduces), in nine classes that are chosen to make rounding hard rather than to be uniform: random bit patterns; magnitudes within two binades of each other (cancellation); values near 1; subnormals; values near overflow; a second operand chosen so that the product lands at the top or bottom of the exponent range; the same for the quotient; a second operand 23, 24 or 25 binades below the first, half the time a power of two (an exact tie in a sum); and the same binade with nearly the same mantissa (exact differences, subnormal results). Mantissas are boundary ones (0, 1, 0x400000, 0x7fffff) one time in four.

Two oracles, and a mismatch against either fails.

  1. f32_of(float_of32(a) op float_of32(b)): the oracle this document named. It cannot be the only one, and that is a correction to §5’s first draft: LLVM at -O2 rewrites fptrunc(fadd(fpext a, fpext b)) to fadd a, b, which is legal for exactly Figueroa’s reason, so on the LLVM backend the oracle becomes the instruction under test (measured: a function returning bits_of32(f32_of(float_of32(a) + float_of32(b))) compiles to one addss). Cranelift does not do it, so the oracle is real there and vacuous on the other backend.
  2. The same binary64 result rounded to binary32 by integer arithmetic, written in the program (round32: sign, exponent and 52-bit mantissa taken apart, 29 bits shifted off, ties to even, the subnormal and overflow cases by hand). Nothing an optimiser knows about floating point reaches it, and it checks f32_of itself as a side effect.

Comparisons have the same two: the comparison on the promoted operands (exact), and a comparison made on the bit patterns with integers only (key folds sign and magnitude into one integer, NaN is unordered). The second is there because of a mutation (below): float and f32 compare with the same code in both backends, so a bug in it moves them together and the first oracle cannot see it. NaN results are compared as “both NaN”, since a generated NaN’s payload is the hardware’s.

Result: 0 mismatches, in every operation, on both backends, over 1,376,996 pairs each; and 0 over 100,376,996 (10⁸ random pairs) when f32_gate is run by hand, which took 38.7 s on Cranelift and 14.6 s on LLVM.

Run time. The default is the full 10⁶: the gate program runs in 0.45 s on Cranelift and 0.27 s on LLVM (1,376,996 pairs, ten operations each, both oracles; measured with cargo test -- --nocapture, build excluded), and the whole of binary32.rs in about 1.5 s. So nothing is opt-in. LEX_F32_PAIRS=<n> overrides the random count, for the longer run above.

It could have failed. “No mismatch” is only worth something if the operands made rounding hard, so the program also counts what it reached, and the test requires it (at the default count):

per operation, over 1,376,996 pairsrounded resultsexact ties (normal range)subnormal results that were roundedfinite operands, infinite resultexactly zero
+464,480129,37309,38717,342
-464,560129,64409,38017,228
*645,93425,324283,224280,75524,279
/758,929093,22298,7916,890

(The zeros are expected: a sum of two binary32 values is exact in the subnormal range, so none is ever rounded there, and a quotient is never exactly halfway between two binary32 values.) And the gate was broken, on purpose, and watched to fail:

mutationresult
Cranelift f32 add emitted as fsub+: 1,016,051 mismatches against both oracles; first 0 + min-normal
Cranelift f32 add done in binary64, the significand cut to 24 bits (round toward zero), then narrowed+: 231,171 mismatches against both oracles; first min-normal + (−2⁻¹⁰¹), an exact tie that rounds to even, gives 0x8cffffff for 0x8d000000
Cranelift f32_of (fdemote) rounding toward zero+: 231,171 mismatches against oracle 1 and 0 against oracle 2: the two are independent, and the integer one is the one that stayed right
LLVM f32 add emitted as fsub+: 1,016,051 mismatches against both oracles
Cranelift != on floats ordered, so NaN != NaN is false (it shares code with float)!=: 0 against the binary64 oracle and 10,587 against the integer one: the first oracle alone would have passed it; first 0 != NaN

Each was reverted; git diff of the two backends shows only the F1 change.

What the layout cost (§2): the stride is the only thing that changed for a slice, and an_f32_slice_is_four_bytes_an_element (cancho-codegen) pins stride == 4 against the layout report. Both backends lower the same program for aarch64 and x86-64 object formats (f32_lowers_on_every_target), and agree byte for byte on tests/accept/f32.cho and on the gate’s report (binary32.rs).

Not done in F1, on purpose. An f32 in a static (static t: [f32] is refused with static-item, like any element type but int, byte, bool and float): the evaluator’s store holds Expr::Int | Bool | Float and has no f32 arm, which is also why nothing folds; it is a small addition when a program asks for a table. f32 crossing spawn and extern fn are as float’s are (not allowed). A struct’s packed layout (§2). sqrt, %, int conversions, f32 in std.math and std.fmt (F2, F3).

Found on the way. print rendered -(1.5) as -1.5, which reparses as the literal -1.5: the negation of a floating literal came back as a different tree. The integer literal already had the guard (-(5)); it now covers float and f32 as well, and an_f32_literal_round_trips covers both.

5.2 What F2 measured

sqrt32 is one square-root instruction on both backends, f32_of_int converts straight from the integer (sitofp i64 to float; going through binary64 rounds twice and 13,031 of 3.2M gate inputs come out different), and int_of_f32 is truncate’s rule at the narrower width. The gate is tests/programs/f32_convert_gate.cho with integer-only oracles for all three, 0 mismatches on both backends (figures as recorded in F2’s commit, 5ca3ddd, not re-measured for F3). It also found that Cranelift’s fcvt_to_sint accepted exactly -2⁶³ where floating-point.md §4 says any magnitude at or beyond 2⁶³ traps; both truncate and int_of_f32 now check the bound.

5.3 What F3 built and measured

What it is, and one divergence from the plan. std/fmt32.cho, module std.fmt32:

f32_into(out: &!o [byte], x: f32) -> [] intRust’s {:?} for an f32: the shortest digits that read back, positional from 1e-4 up to below 1e16 with at least one digit after the point (0.5, 16384.0, 9999999000000000.0), exponential outside (1e-5, 1e16, 3.4028235e38), NaN, inf, -inf, 0.0, -0.0. Bytes written, or -1 if out is too short
f32_fixed_into(out, x: f32, prec: int) -> [] intRust’s {:.N}: the exact binary value, rounded half to even at the N-th place (0.125 at 2 is 0.12, 2.5 at 0 is 2), a negative value that rounds to zero keeps its sign (-0.00), NaN/inf whatever prec is. -1 if out is too short or prec is negative
f32_of_text(text: &t [byte]) -> [] (bool, f32)str::parse::<f32>’s grammar and answers: (false, 0.0f32) for anything else
f32_of_decimal(negative, w: int, exp10: int) -> [] f32, decimal_bits, shortestthe pieces: a decimal w · 10^exp10 rounded once, and the digits of one pattern

It is a module of its own and not std.fmt, where the plan put it, for one reason: f32 is visible only from edition 6 (§6), and std/fmt.cho has no edition marker (it is below 6) and prints every float; raising it to add three functions moves a module that did not need to move.

Printing: Ryu at binary32, with one change. The digits are Ryu’s (Adams, 2018): the shortest decimal inside the interval that rounds to this f32, the closest to the true value among those of that length, in 64-bit integer arithmetic against two tables of powers of five, no bignum. Rust rounds an exactly halfway candidate pair up, and Ryu rounds it to even; shortest rounds up (the last >= 5 in its final step), and that is the one place it differs from the published algorithm. {:?} is then the layout rule above, which is not std.fmt.float_into’s d.ddde-k form (float-printing.md §1.1 left positional notation out on purpose).

Fixed point expands the exact value digit by digit in integer arithmetic: a whole part of at most 128 bits in four limbs, a fraction of at most 149 bits in at most five limbs that multiplying by ten carries a digit out of, and the tie decided from what is left after the N-th digit.

Reading, and how it differs from cancho-gpu’s parse. cancho-gpu/src/f32.cho::parse reads the text as a float and rounds that to binary32, which rounds twice and is wrong when the first rounding lands exactly on a binary32 midpoint the decimal was not on. f32_of_text is correctly rounded directly: the digits are held exactly (up to 160 of them and then a sticky bit: no binary32 midpoint has more than 113 significant digits (computed over the extremes of every exponent), so nothing past 160 matters except as “more”), scaled by integer arithmetic until the quotient has 26 or 27 bits, and rounded once, the remainder being the sticky bit. A fast path takes the common case (at most 16 digits, |q| <= 22: two exact binary64 operands, one correctly rounded operation, then to binary32), and sends to the exact path any result that is exactly a binary32 midpoint in binary64, which is the one case the double rounding can get wrong. tests/accept/f32_text.cho pins 1.0000000596046447753906251 to 0x3f800001 (through binary64 it becomes the tie 1 + 2⁻²⁴ and rounds to even, 0x3f800000; parse_does_not_round_twice shows both numbers) and the same decimal read as a literal (§2) gives the same bits.

What the language accepts. Rust’s grammar, mirrored and not diverged from, and checked against Rust’s parser line by line: an optional sign; then inf, infinity or nan in any case; or digits with an optional point (5., .5 and 5.5, but not .) and an optional e/E exponent with an optional sign and at least one digit; nothing before, between or after (so " 1", "1 ", "", "1e", "1_0", "0x10" are all (false, ..)). Overflow is infinity and underflow zero, signed; an exponent too big to hold is clamped, which cannot change the answer. Every NaN spelling gives the canonical NaN (0x7fc00000; the sign of -nan is not kept, as bits_of32 would not show it).

No input traps. A short buffer, a negative or enormous prec, an exponent or a w at the end of the int range are all answers (-1, or infinity and zero), not traps; the first two had been traps (prec + 2 and decimals + q overflowed) until tests/accept/f32_text.cho tried them. The module returns no refusal, so it needs no rule tag and no tests/reject fixture.

The gate.

  • Every 2³² patterns, once. tests/programs/f32_exhaustive.cho prints each non-NaN pattern with f32_into into a buffer and (1) hashes the bytes (FNV-1a 64 over each line and a newline), (2) reads the line back with f32_of_text, which must give the same bits, zero’s sign included, and (3) checks it is shortest independently of how it was found: neither of the two decimals one digit shorter that bracket it (the digits without the last, and that plus one) may read back to the same bits, and the set of reals that read back to one f32 being an interval, no shorter decimal at all can then. Its digest is held against tests/programs/f32_oracle.rs, which prints Rust’s {:?} for the same patterns (rustc -O) and hashes the same way, so equal digests mean every line is byte for byte Rust’s, including which of several shortest strings is chosen. The closest-to-the-true-value rule is therefore checked by the digest and not by a separate search. The run is cut into 64 ranges of 2²⁶ patterns, each result kept as a file (done.<k>), so a killed run starts again where it stopped. Result, run by the opt-in test itself on the final code: 64 of 64 ranges, 4,278,190,082 printed patterns (2³² less the 2·(2²³−1) NaNs), every range’s digest equal to Rust’s, 0 that did not read back, 0 that were not shortest, in 782 s of wall time on four cores (LLVM build of the program). An earlier run of the same program before the last refactor (three at a time, with other work running) took 1,204 s and gave the same counts and digests. Over all of them the longest output is 19 bytes (-9999999000000000.0) and the most digits in a shortest string is 9, so f32_into needs 19.
  • In the default suite, a sample of that: for each sign and exponent, 19,500 fractions spread by a stride plus nineteen boundary ones, 9,954,694 patterns on LLVM in 7.6 s and 1,004,194 on Cranelift in 3.1 s (the digest, the read-back and the shortness all checked). The whole run is opt-in: LEX_F32_EXHAUSTIVE=1 cargo test f32_text_exhaustive -- --nocapture (LEX_F32_EXHAUSTIVE_DIR for where the ranges are kept).
  • Differentials against Rust, each in a per-test scratch directory with its own rustc -O build of the oracle: {:?} over 1,000,000 patterns on LLVM and 250,000 on Cranelift (half uniform over the 32 bits, a quarter random with a random exponent, a quarter with few significant bits, then the zeros, extremes and infinities); {:.N} for N in 0, 1, 2, 3, 4, 5, 6, 8, 10, 12, 20, 45 and 60, 1,014,143 pairs on LLVM (264,134 on Cranelift), 14,144 of them constructed exact ties: (2a + 1) / 2^(N + 1) is a decimal with N + 1 places ending in 5 and an f32, so it is halfway at N, and also k + 0.5, k + 0.25, k + 0.125 and so on, and runs of nines next to carries; parse over 1,349,823 strings on LLVM (344,227 on Cranelift): 1,000,000 random of 1 to 40 digits with a point, leading zeros, a sign and an exponent from -300 to 40, 353 hard cases (spellings, nan/inf/infinity in every case and sign, junk before and after, the empty string, overflow and underflow at and either side of the boundary, the largest finite and the smallest subnormal, the halfway points at 40 and more digits, 200-digit and 300-zero strings), 25,000 exact midpoints between neighbouring f32s with one digit above and below (up to 113 digits, subnormals included), 224,470 short decimals next to a midpoint (their digits cut to 14 to 17 and rounded either way, which is what the fast path reads) and {:?} and {:e} output of random f32s. 0 mismatches in each.
  • The fixture tests/accept/f32_text.cho runs on both backends and says the same on each.

It could have failed. Each rule was broken on purpose in std/fmt32.cho, the suite run, and the file restored:

mutationresult
print: the exact tie rounds down (last > 5){:?} and sample digest tests fail on both backends (4 tests)
print: the shortest search stops one digit late5 tests fail: both {:?}, both sample digests, and the fixture
print: the nearest of the shortest is not picked (no rounding by the removed digit)the same 5
fixed: an exact tie rounds up instead of to even{:.N} on both backends and the fixture
parse: a tie always rounds up5 tests fail, among them the sample’s read-back
parse: the sticky bit is dropped (a tie ignores what is below it)6 tests fail, among them parse_does_not_round_twice
parse: the fast path’s midpoint guard removed (so binary64 then binary32, double rounding)passed the first time: the 1,000,000 random strings never reach it. The near-midpoint strings above were added for it; now 8,577 of 1,349,823 differ, the first 0.7186756363353197e-6 (got bits 893446916, Rust 893446915)

The last row is the one that matters: a guard that no test fails without is not a tested guard, and for ten million random decimals it was not.

Cost to the default suite. The f32_text module’s eleven tests (the exhaustive one returns at once unless opted in) take 19.5 s when run alone on four cores; the conformance binary took 155.4 s with them and 140.2 s with --skip f32_text, so about 15 s of wall time (the whole workspace run is 3 min 34 s to 3 min 58 s). Memory is a few megabytes; each test builds its own oracle with rustc -O into a scratch directory of its own.

Deferred. A direct comparison with cancho-gpu’s f32.cho::debug (a binary64 emulation) was not made: Rust is the oracle cancho-gpu is itself held to, and the swap is F4. fixed over all 2³² patterns is not exhaustive (it is differential on 10⁶ and constructed ties; its algorithm has no shortness to check but N and the value are two dimensions). Reading to f32 from {:e}-style text beyond Rust’s grammar (a _ separator, hexadecimal) is not offered.

6. The edition decision, and what is still open

No new edition. editions.md §5 classifies by what breaks: f32 is additive (a type name, a literal suffix and four builtin names), which is the kind an edition absorbs, and the way an edition absorbs it is by the addition being absent from earlier editions, not by a new edition number. The repository’s own precedent for the latest edition is to add to it while no file at that edition could have used the name (value_barrier and the dir_* builtins were added to edition 6 after it existed, each with the count). Counted first, as file-handles.md §4.2 asks:

whatfiles containing it, of the 547 .cho files under tests/, examples/, std/, packages/ and benches/anywhere else
f32 as an identifier (type, field, function, local)00 in crates/, scripts/ and docs/ outside this document and its two mentions; 0 in the git history of .cho files (the one textual hit is inside a hex string in std.ecdsa’s vectors)
f32_of, float_of32, bits_of32, f32_of_bits00

So edition 6 gains the type and the four builtins, gated the way every such addition is: f32 resolves as a type only in a file at edition 6 or later (function.rs’s name resolution), the four builtins have since() == 6, and the suffix literal is refused below it (literal-form, “needs edition 6;”). Below edition 6 the names are the file’s own again, which is the part a count does not prove and a test does: tests/accept/f32_names_are_edition_five.cho declares struct f32 and fn f32_of at edition 5 and runs; in an edition-6 file struct f32 is builtin-redeclared (f32_cannot_be_redeclared.cho). The rest of the gating has a fixture each: f32_literal_is_edition_six, f32_conversions_are_edition_six, f32_type_is_edition_six.

If a program ever does use f32 as a name at edition 6 or later, that is the case editions.md §6.2 says an edition is for, and it would be edition 7’s to open. Hashes: the tag for an f32 literal is appended (0x74), the type is hashed by its written name, and tests/golden.rs, which pins the encoder’s output for existing programs, passes unchanged: nothing that exists moved.

QuestionWhy it waits
Whether the type needs an editionAnswered above: no new edition; edition 6, gated; count 0 of 547.
f32 as a map key or a hash input-0.0/NaN, as for float (floating-point.md §5): no new answer is proposed
A vector typeOnly after F4 shows what the scalar type did not remove
A struct of f32s at four bytes a fieldlayout.md §2’s packing, for every leaf, not for f32 alone (§2)
An f32 staticWhen a program asks; the evaluator needs the one arm (§5.1)

A character, written as a character

Status: settled and built.

bitwise.md §1.1 added the second spelling of an integer and wrote down the rule that made it cheap: “a hexadecimal literal is a spelling, not a type.” This document adds the third, for the same reason and with the same consequence — and it exists because examples/wordcount.cho was already writing the translation by hand, in a comment, three lines running.


1. What the corpus spells today

Counted by reading every .cho file in std/, examples/ and tests/accept/: 121 places write an ASCII character as a decimal number.

WhereSites
std/ and examples/53
tests/accept/68

The constants are not spread out. One is nearly two thirds of them:

SitesValueCharacter
75480
1210newline
665A
532space
313carriage return
2 each9, 45tab, -
1 each44, 57, 61, 69, 73, 79, 85, 90, 97, 100, 101, 104, 116, 122, 9 = E I O U Z a d e h t z

The 75 are one idiom, not 75 decisions: 48 + n % 10, the digit printer that every program without std has to carry. That it is the same three lines copied fifty times is a fact about std being opt-in, and it is also why the notation pays here — a reader meeting 48 in a fixture has no std.io nearby to tell them what it is.

And three lines already carry the answer, written by a human, in the only place the language left for it:

let needle = alloc_slice[a](3, byte_of(116));   // 't'
needle[1] = byte_of(104);                       // 'h'
needle[2] = byte_of(101);                       // 'e'

That is examples/wordcount.cho. A comment that restates the line above it is the clearest evidence a notation is missing: the information exists, the programmer typed it, and the compiler cannot see it. If the number and the comment ever disagree, nothing finds out.

1.1 Which is worse than unreadable, twice

Two of the 129 are not merely opaque.

if len(flag) == 2 && int_of(flag[0]) == 45 && int_of(flag[1]) == 100 {

That is examples/base64/base64.cho testing for -d. And:

return c == 32 || c == 9 || c == 10 || c == 13 || c == 11 || c == 12;

That is std.bytes.is_blank, and the sixth alternative is a form feed — which is either correct or a typo for something else, and reading the line does not tell you which.


2. The decision: a spelling, not a type

The obvious design is a character literal of type byte, because byte is what one unit of text is here (strings.md §1). Counting says otherwise.

What the site wantsSites
int91
byte30

Three quarters want an integer, and it is not close. The reason is in two places the language already decided:

  • putchar(io, c: int) and getchar(io) -> int mirror libc, so every character that crosses the console is an int (standard-input.md).
  • The single most common site, 75 of 121, is 48 + n % 10, whose 48 is arithmetic on an int by construction.

So:

'a' is the integer 97. It is a third spelling of an integer literal, after decimal and hexadecimal. There is no character type, no new node, and no new rule about what a character is.

canonical-ast.md §3 keeps values rather than spellings — 007 and 7 are one node, 0xff and 255 are one node — so 'a' and 97 are one node too, and §5 below is true for free rather than by effort.

2.1 What that costs, stated rather than hidden

The 30 byte sites do not lose their conversion:

text[i] == byte_of(10)      becomes    text[i] == byte_of('\n')

The call stays. What changes is that its argument can be read. That is the smaller half of the win and it is the honest description of it — this slice makes 129 constants legible, and removes zero conversions.

A byte-typed literal would remove those 30 conversions and add one to each of the 97 others, which is the same trade run backwards, plus a new literal node and a moved hash. The counting decides it; the node is why it is not even close.


3. The rule

'a'    'Z'    '0'    '-'    ' '
'\n'   '\r'   '\t'   '\\'   '\''   '\0'

One character between single quotes, or one escape. The value is that character’s byte, as an int in 0..=127.

The escape set is strings.md §4’s six, with one substitution: \' stands where \" does, because the delimiter is what an escape is for. '"' needs no escape and "'" needs none either — each literal escapes its own delimiter and not the other’s.

Five things are refused, with the span on the literal:

WrittenRefused becauseRule
''empty — a character literal holds exactly oneLiteralForm
'ab'two characters; a string is "ab"LiteralForm
'é'not ASCII: é is two bytes in UTF-8, and this literal is oneLiteralForm
'aunterminatedLiteralForm
'\q'not an escapeUnknownEscape

No new rule tags: agent-errors.md’s catalogue stays at 52, and both rules already carry fixtures.

3.1 Why non-ASCII is a refusal and not an encoding

'é' has an obvious meaning under one reading — U+00E9, 233 — and a different obvious meaning under another: the first byte of its UTF-8 encoding, 195. strings.md §1 declines to make an encoding claim about [byte], and a literal that silently picked either would make one here.

utf8.md is the place that reads multi-byte text, and it reads it from bytes at run time. A program that wants U+00E9 writes 233, and a program that wants its encoding writes the two bytes — both of which say which one they meant.


4. What it does not do

  • No character type. 'a' is an int; byte still has no arithmetic (strings.md §2) and still needs byte_of.
  • No \u or \x. strings.md §4 left both out and the reasons have not changed: \u is an encoding claim and \x is the escape hatch bitwise.md §1 replaced with a mask.
  • No \v or \f, and that leaves two sites behind. std.bytes’s is_blank tests six characters, and this slice names four of them: the vertical tab and the form feed have no escape in strings.md §4’s set and stay 11 and 12. Taking the string’s set unchanged is worth more than covering that line, and the line now says so where it used to say nothing — before, all six were numbers and the sixth was either correct or a typo for something else with no way to tell.
  • No multi-character literal. C’s 'ab' is implementation-defined, which is the category this language exists to leave.
  • Nothing about strings. "abc" is unchanged, and a one-character string is still a string.

5. Adding it moved no hash

A character literal is lexed as TokenKind::Int and parsed by the same int_value that reads 255 and 0xff. Nothing downstream of the parser can tell which spelling was written, so no SigId and no body_hash moved.

That claim is not asserted here: hash-stability.md found that the 35 golden hash fixtures had never observed an encoder change, because they landed after the last one. This slice is the second observation of the right kind and it passes — a_character_literal_hashes_as_its_integer checks the two spellings against each other rather than against a written-down hash, and the golden fixtures check that rewriting 119 sites across std/, examples/ and tests/accept/ moved nothing either.

It is still not the test that would tell us the instrument works, and saying so is the point of hash-stability.md §1: a change designed to move no tag cannot demonstrate that a moved tag would be caught.


6. The suite

FixtureRule§
character_literal_empty.cho'' holds no character3
character_literal_two.cho'ab' is two, and a string is not a character3
character_literal_not_ascii.cho'é' is two bytes and this literal is one3.1
character_literal_unterminated.chothe quote is not closed3
character_literal_bad_escape.cho\q is not one of the six3
TestShows§
a_character_literal_hashes_as_its_integer'a' and 97 are one node5
golden hashesrewriting 59 sites moved no identity5
AcceptingShows
character_literals.chothe set, the six escapes, '\'', '"', and arithmetic on one
std/bytes.cho§1.1’s six alternatives, named
examples/base64/§1.1’s -d

Bits

Status: settled and built.

defined-behaviour.md §8 listed “shifts and bitwise operators” as absent rather than undefined, with two questions attached: what happens at a shift amount ≥ 64, and whether >> is arithmetic. §8’s rule is that each gets an answer in the slice that adds it, before the code that needs one — so this document exists because a port needed them (docs/porting.md), and it answers both plus the two §8 did not think to ask.


1. The set

a & b      a | b      a ^ b      ~a       a << n      a >> n

Six operators on int, and nothing on byte. strings.md §2 refuses arithmetic on byte so that unsigned widths can stay deferred, and a bitwise operator is arithmetic for that purpose: byte_of(int_of(b) & 15) is what you write, and it says where the range check happens.

That is the same shape strings.md §4 predicted — “a caller that wants the low eight bits writes the mask explicitly, once there are bitwise operators to write it with” — so the sentence that was a promise is now a program.

1.1 And hexadecimal literals, which the same code demanded

Writing 0xff was not possible until this slice either, and a mask in decimal is a mask nobody can read:

byte_of(int_of(b) & 15)        // fine, because 15 is small
value & 4294967295             // and this is not
value & 0xffff_ffff            // this is

0x is the one prefix. Base two and base eight were left out on purpose: a mask is written in hex by everyone who writes masks, 0b earns its place only where a bit pattern is the documentation (a register map, which this language cannot yet reach), and 0o’s only customer is a Unix file mode, which it cannot set.

A hexadecimal literal is a spelling, not a type. canonical-ast.md §3 keeps values rather than spellings, so 0xff and 255 are the same node and hash identically — which is why §6 below is true of this too.


2. >> is arithmetic, and that is forced

int is signed, and a signed right shift that filled with zeros would turn a negative number positive. §8 had already called this: “it will be arithmetic; int is signed.”

(0 - 8) >> 1   ==   0 - 4

A logical shift is a different operation on a different type, and the type it belongs to — an unsigned width — is still deferred (defined-behaviour.md §8). When unsigned integers arrive, >> on them will be logical, and it will be the type that decides rather than a second operator.


3. A shift amount out of range traps

x << 64        // traps
x << -1        // traps
x >> 64        // traps

C leaves this undefined. Rust panics in debug and masks in release, so the same program means two things. This language has one answer for the whole family, and §2.1 of defined-behaviour.md already gave it: a silently wrong answer propagates and a stopped process does not.

The range is 0 <= n < 64, checked against the shift amount and not against the value. 64 is the width of int and the only width there is.


4. But a shift does not trap on the value it produces

This is the exception, and it is deliberate:

1 << 62        //  4611686018427387904
1 << 63        // -9223372036854775808, and this is fine

1 << 63 sets the sign bit. Read as arithmetic that is an overflow, and §2.1 traps on overflow. Read as bits it is exactly what was asked for, and refusing it would make << useless for the only thing it is for.

The rule: + is arithmetic and traps; << is bits and does not.

Which is the same line defined-behaviour.md §2.2 already draws between + and wrapping_add — “+ is what you write when you mean arithmetic, wrapping_add is what you write when you mean the bits” — applied to an operator that has no arithmetic reading at all. A bitwise operator is a wrapping_ operator that does not need the prefix, because nobody reaches for & by accident.

&, |, ^ and ~ cannot overflow, so the question does not arise for them.


5. Precedence is Rust’s, not C’s

Loosest to tightest:

||
&&
==  !=
<  <=  >  >=
|
^
&
<<  >>
+  -
*  /  %

Bitwise binds tighter than comparison. In C it does not, so

if (flags & MASK == 0)      /* C: flags & (MASK == 0) */

is a bug that every C style guide warns about and every C programmer has written. It is a famous defect, C keeps it for compatibility with code written before && existed, and there is no compatibility to keep here.

So flags & mask == 0 means (flags & mask) == 0, which is what it looks like.

Shifts bind tighter than & and looser than +, which is Rust’s ordering and reads correctly for the thing shifts are mostly used for: base + n << 2 would be surprising either way, so §7’s fixture pins it rather than leaving it to be discovered.

5.1 & in expression position is unambiguous

& is the reference constructor — &r T, &!r T, and a region binder fn f[&r]. All three are type positions, and cancho has never let a type and an expression meet: a type appears after :, after ->, or inside [...] on a declaration, and nowhere else.

There is also no address-of operator to collide with, because a reference here comes from borrow and from nothing else (linearity-and-effects.md §5). So a & between two expressions can only be the binary operator, and the parser needs no lookahead to know it.

>> is free for the same structural reason: generic arguments are written in brackets, Pair[int, Vec[int]], so the closing-angle-bracket problem that C++ spent a decade on does not exist here.


6. Adding these moved no hash

An operator is hashed by a code inside Binary/Unary (crates/cancho-id), not by a node tag of its own, and the six new codes are appended after the thirteen that existed. So every SigId and body_hash in the repository is byte-identical to what it was before this slice, and docs/INVARIANTS.md needed no entry.

That is worth one line because it is the property the canonical AST was designed for and this is the first slice that got to check it rather than assert it: ids_are_stable_across_the_operator_set compares the hashes of a program written before the operators existed.


7. The suite

FixtureRule§
bitwise_on_a_byte.chobyte still has no arithmetic, so a mask says where the range check is1
bitwise_on_a_bool.cho& is not &&, and a bool is not an int1

An out-of-range shift is not a reject fixture, for the reason slicing.md §8 gives: the reject harness runs check, and a program that traps is one that compiled. Those are conformance tests.

TestRule§
a_shift_past_the_width_trapsn >= 64 and n < 0 both trap, and 1 << 63 does not3, 4
ids_are_stable_across_the_operator_setAdding five binary codes and one unary code moved no hash6
AcceptingShows
bitwise.choAll six, the precedence table, arithmetic >>, 1 << 63, and a hex mask
examples/base64/§1’s promise as a program: the port that needed them

Strings

Status: settled and built. This was the gating artifact for M3’s last item (#1), written and reviewed before the code the way linearity-and-effects.md was written before M2. §9’s must-reject suite was stated in advance and is now enforced, fixture by fixture.

One thing changed on contact. §4 said the static region had “nothing to name” and so was not writable in source. Writing the first fixture showed that this makes a literal impossible to return — fn greeting() -> [] &static [byte] has nowhere to put the region — which is a basic thing to want and nothing was buying the restriction. So static is writable in a type, and reserved as a binder: it is the one region with a name rather than a binder.

Everything else in M3 is built: arenas, libc FFI, checked arithmetic, slices, the canonical printer, per-unit identity. Strings are what is left, and they are the one item whose representation is still an open question rather than an implementation.


1. What a string is

A string is a run of bytes. No encoding is claimed, and none is validated.

str does not exist as a type. There is byte, and a string is &r [byte] — an ordinary slice, which means it is an ordinary reference, which means every rule §5 of linearity-and-effects.md gave references applies to it without a second mechanism. That is the same trade slices made and it paid off there: regions, the escape check, the unique-to-shared coercion and val mode all came for free.

Why not UTF-8-validated. A validated string type has to answer what an invalid one is — an error, a replacement character, an unrepresentable state — and each answer costs either a fallible constructor everywhere or a lie somewhere. Bytes answer it by not asking: there is no invalid byte string, so defined-behaviour.md §8’s open question (“Strings — encoding, and what an invalid one is”) is closed by having no encoding to be invalid against.

Validation and decoding belong in a library, written in cancho, over &r [byte]. That is the “slice-shaped, not a port of std.str” the epic asks for.

Why not a distinct str referent. It was the other candidate: make str an unsized shape like [T], with indexing that yields int. It avoids adding a type — but it needs its own construction form, its own indexing rule, its own length rule and its own FFI rule, all parallel to the slice ones and none shared with them. byte costs one type and reuses everything.


2. byte is storage, not arithmetic

byte is an 8-bit unsigned integer with no arithmetic. It is converted to int to be computed with, and back to be stored.

byte_of(n: int) -> [] byte      // traps unless 0 <= n <= 255
int_of(b: byte) -> [] int       // always defined, always 0..255

This is the decision that keeps the type small. defined-behaviour.md §8 defers unsigned integers and other widths because they drag conversion rules and a second overflow question behind them — and a byte you cannot add to never asks either. b + 1 is refused; byte_of(int_of(b) + 1) is what you write, and it says where the range check happens.

== and != on bytes are allowed: comparing storage is not arithmetic, and a parser that cannot say b == byte_of(44) is not worth having.

byte_of traps rather than truncating. Truncation is the silently wrong answer defined-behaviour.md §2.1 already refused for +. A caller that wants the low eight bits writes the mask explicitly, once there are bitwise operators to write it with.


3. [byte] is packed

A slice’s elements are one leaf-stride apart, and a leaf is 8 bytes (defined-behaviour.md §5). [byte] is the exception: its elements are one byte apart, because a string that took 8 bytes per character could not be handed to C and would not be a string so much as a rumour of one.

So the layout rule generalises: an element’s stride is its size, which is 1 for byte and the leaf count times 8 for everything else. This is the one place M3 introduces a size that is not a multiple of 8, and it is confined to byte on purpose — the day a second such type appears is the day the layout section of defined-behaviour.md stops being a paragraph and becomes a contract.


4. Literals, and where they live

let greeting = "Hello, world!\n";     // &static [byte]

A literal’s bytes are emitted into the object file’s read-only data and the slice points at them. That needs a region that outlives every other, so:

Region::Static joins Param, Block and Var. It outlives everything and nothing outlives it.

One row in the outlives relation (Static outlives all; nothing outlives Static but itself), and the escape check needs no change at all — a reference into the static region never mentions a block, so it never escapes anything.

static is writable in a type and reserved as a binder. &static [byte] is how a function says it hands back a literal; fn f[&static] and &!static are both refused, the first because the region is not a parameter anyone declares and the second because there is no unique reference into read-only data.

String literals are shared, never unique: two occurrences of "ok" may be the same bytes, and a program that could write through one would be writing through both. &static [byte] it is.

Escapes. The set is \n, \r, \t, \\, \" and \0, and nothing else. No \u, because that is an encoding claim (§1), and no \x, because that is the bitwise escape hatch §2 is deferring. A backslash before anything else is refused where it is written rather than passed through.

\r was added after M3, and by a program rather than by a list. The original five left it out because nothing had asked for it, which was true right up until examples/serve/ spoke HTTP. A protocol’s line ending is CRLF, so without \r the separator had to be assembled as byte_of(13) into a buffer — the one piece of that program that was awkward for no reason anybody could defend (docs/reach.md §4). It is a byte with a spelling, not an encoding claim, so it belongs with \t rather than with \u.


5. Building one

An arena already allocates slices, and a string buffer is a slice:

region a {
    let buffer = alloc_slice[a](64, byte_of(0));   // &!a [byte]
    buffer[0] = byte_of(72);
}

Nothing new. alloc_slice, len, s[i], s[i] = v and the bounds checks all work on [byte] the moment byte exists, which is the point of §1’s choice.

What M3 does not add: concatenation, formatting, growth, interning, or a String that owns its buffer. Those are library work over slices, and the first three need an allocator story beyond one arena chunk.


6. What crosses to C

linearity-and-effects.md §8.4 lets int, bool, () and borrowed capabilities cross. Strings add one:

A &r [byte] crosses as a pointer, and its length crosses as a separate int argument. The two are separate parameters on the C side, because C has no notion of the pair.

So write(fd, ptr, len) is expressible and strlen(ptr) is not — the second needs a NUL terminator, and this design does not put one there. A program that wants a C string builds one, with the \0 it chose, and takes responsibility for the byte it wrote.

That asymmetry is deliberate: the functions that take an explicit length are the ones that cannot run off the end, and preferring them is the same argument the bounds check makes.


7. What this unblocks

The epic’s M3 acceptance is “a real ~500-line program (a working CLI tool doing file IO and parsing)”. After this, what is still missing for that is file IO, which needs an Fs capability — linearity-and-effects.md §8.1 lists it as waiting for exactly this reason, and it is M3’s honest last mile rather than a surprise.


8. Open, and deliberately so

QuestionWhy it waits
Bitwise operators on intNeeded for masking, and defined-behaviour.md §8 wants shift semantics settled with them
A growable bufferDone — boxed-slices.md gave it an allocator that is not one arena chunk, and std.buffer is the buffer. slicing.md §6 added bytes, which is how one crosses to anything taking a &r [byte]
UTF-8 decodingBuilt — utf8.md, std.utf8. “Once there is enough language” arrived: a decoder compiled first try and matched GNU wc -m exactly. What the row did not see coming is that §1’s own three-way question — an error, a replacement character, an unrepresentable state — comes due at the decoder, which cannot dodge it the way the representation did. Four implementations give four different answers on malformed input
\x and \u escapesEach is a claim this design declines to make (§4) — and utf8.md §1 found the decision costs nothing: a source file is already UTF-8, so "café 日 😀" is eight code points with nothing to escape
Interning literalsAn optimisation, and one that changes whether two literals share an address — which is observable, so it needs a rule before it needs code

9. The must-reject suite

The deliverable that matters, stated before the code exists — the same shape as linearity-and-effects.md §11, which is the part of M2 that made the difference.

FixtureRule§
byte_out_of_range.chobyte_of(256) traps rather than truncating2
byte_arithmetic.chob + 1 is refused; a byte is storage2
string_literal_is_shared.choA literal may not be written through4
unknown_escape.cho"\q" is refused where it is written4
string_escapes_its_region.choA buffer in an arena does not outlive it5
c_string_without_terminator.choA &r [byte] is not a C string6

And the accepting counterparts, because a rule that rejects everything is not a rule:

FixtureShows
string_literal.choA literal printed a byte at a time, with no packing
string_buffer.choA buffer built in an arena, written, read back
bytes_to_c.choA pointer and a length crossing to a C function that takes both

examples/hello.cho stops packing its greeting into two 64-bit words, which is the single clearest signal that this landed: that file had been carrying an M0 workaround since the first milestone, and the comment at the top of it said so. It now writes "Hello, world!\n".

examples/wordcount.cho is the first program in the repo that is mostly text processing rather than demonstration: wc over an embedded document, with a whole-word search that is two slices compared a byte at a time — which is all a string comparison is when a string is bytes. It counts bytes and ASCII whitespace and says in its own header that it is not Unicode-aware, because §1 claims no encoding.

UTF-8

Status: settled and built. std/utf8.cho implements §3; the rules below were written first and none of them moved on contact.

strings.md §8 files UTF-8 decoding as “library work over [byte], in cancho, once there is enough language to write it.” There is. Two probes compiled on the first try and one matched GNU wc -m exactly, so this document is not about what the language is missing.

It is about the one decision a decoder cannot avoid, which is the decision strings.md §1 avoided on purpose — and which §1 was right to say would have to be paid somewhere.


1. The language is ready, measured

Two things had to be true before a string library was worth designing.

A decoder is writable. Forty lines over &r [byte] — lead-byte widths from a mask, continuation bytes from another, a running index — counting code points in a file of ASCII, Latin, Japanese, emoji and a combining sequence:

bytescode points
the fixture11979
GNU wc -m (LC_ALL=C.UTF-8)11979
the probe11979

Nothing was missing. Bit operators (bitwise.md), int_of, subslices (slicing.md §1) and len are the whole requirement, and each of them arrived for a different program.

A library can return substrings. The shape that matters for split is a collection of references into the text, which means a region parameter flowing through a generic:

fn split_spaces[&h, &t](heap: &!h Heap, text: &t [byte])
    -> [heap] vec.Vec[&t [byte]]

That compiles and runs. Vec[T: val] accepts a reference because a shared reference is val, and &t binds the substrings to the text they came from, so none of them can outlive it. No new machinery, and the escape check does the work it already did for slices.

And a non-ASCII literal needs no escape. strings.md §8 declines \x and \u as claims the design will not make, which sounds like a cost until you write one: a string is bytes and a source file is already UTF-8, so "café 日 😀" is fourteen bytes and eight code points with nothing to escape. The fixture is written that way on purpose.

So: the rest of a string library — split, trim, join, ordering, case folding — is code, not design. It needs writing, it does not need deciding, and this document has nothing to say about it beyond that.


2. The decision, and why it arrives here

strings.md §1 declined a validated string type, and the argument was precise:

A validated string type has to answer what an invalid one is — an error, a replacement character, an unrepresentable state — and each answer costs either a fallible constructor everywhere or a lie somewhere. Bytes answer it by not asking.

That was the right call for the representation: &r [byte] has no invalid value, so there is nothing to ask about. But a decoder is exactly the thing that asks. Handed c0 80, it must do something, and §1’s three options are not hypothetical — they are three shipping implementations that disagree.

Seven fixtures, four answers:

byteswhat it isGNU wc -mPython strictPython replacenaive probe
e2 82 ac€, valid1111
c0 80overlong NUL0reject21
c0 afoverlong /0reject21
ed a0 80surrogate U+D8000reject31
f5 80 80 80above U+10FFFF1reject41
e2 82truncated0reject12
80lone continuation0reject11

Every column is a defensible reading of §1’s three options — error, replacement, unrepresentable — and no two columns agree on a single malformed row.

GNU is not usable as the oracle here. It counts f5 80 80 80 as one character, and that sequence encodes a value above U+10FFFF, which is not a character at all. It also answers 0 for every other malformed input, which is “unrepresentable, so absent” — a third position again. benchmarks-game.md §1 already had to pick rules rather than trust a reference; same shape, and worth noting because wordcount.cho is checked against wc and must not be checked against wc -m.


3. The rule

decode[&r](text: &r [byte], at: int) -> [] Step
enum Step {
    Code(int, int),   // code point, and how many bytes it took
    Invalid(int)      // how many bytes to skip; never 0
}

Rejection is not an option and neither is silence. A decoder that traps on malformed input makes reading a file a program can be killed by, which is filesystem.md’s own position inverted — a malformed byte is an ordinary outcome of reading the world, not a bug in the program. A decoder that silently substitutes loses the one fact the caller might need. So Step reports it and the caller decides, which is the same shape file-handles.md §3 chose for a read: three outcomes, an enum, no sentinel.

This is Result-shaped and is not a Result, for the reason the Invalid payload gives: the caller needs the width even in the failing case, or it cannot advance and the loop does not terminate.

3.1 What counts as invalid

Strict, and the strictness is the point:

refusedwhy
overlong formsc0 80 is a second spelling of 00, and a second spelling of anything is a security bug waiting for a comparison to be done before a decode
surrogates d800–dfffnot scalar values; they exist only inside UTF-16
above 10ffffnot a code point
truncated sequencesa lead byte whose continuations are missing or malformed
lone continuation bytes80–bf cannot start anything

f5 80 80 80 is refused, which is where this departs from GNU and agrees with Python, Rust and the Unicode standard.

3.2 How far Invalid skips

The maximal subpart rule (Unicode 5.2 onwards, and what Python’s replace and Rust’s from_utf8_lossy implement): skip the longest prefix that could still have been the start of a well-formed sequence, and at least one byte.

So e2 82 is one Invalid(2) — the pair was a plausible beginning — while c0 80 is two Invalid(1)s, because c0 can never begin anything and 80 cannot either.

The alternative — always skip one byte — is simpler and turns one truncated character into two errors, which is the row where my own naive probe disagreed with everyone. Picking the rule the rest of the world picked is worth more than picking the shorter one, because the only reason to have a code-point count is to agree with somebody.

Checked rather than asserted: the widths this rule produces reproduce the Python replace column of §2’s table on all six malformed fixtures — one error for e2 82, two for c0 80, three for ed a0 80, four for f5 80 80 80. That column is the target, and the rule hits it.

And over inputs nobody chose. utf8_decoding_agrees_with_an_oracle runs 2,000 generated cases — well-formed text at every width, raw noise, valid text with bytes corrupted, valid text cut short — against Rust’s own from_utf8_lossy and str::from_utf8, which implement exactly §3.2’s rule and §3.1’s validity. Both columns agree on every case. Widening one range so surrogates are accepted makes it fail and name the bytes, so the test is a falsifier rather than a formality.


4. What it does not do

Not normalisation, not grapheme clusters, not case folding beyond ASCII, not collation. The fixture in §1 contains é — an e and a combining acute — which is two code points and one thing a reader would call a character. Counting code points is not counting characters and this document does not pretend otherwise; it is what wc -m counts and where the agreement in §1 comes from.

Those are all table-driven, the tables are large, and compile-time-data.md gives a static the right shape to hold one when a program asks. None has asked.


5. Open

QuestionWhy it waits
Encoding — code point back to bytesThe mirror of §3 and genuinely easier: there is no invalid code point once §3.1 has refused the ones that are not scalar values. Left out so the two are not designed together, for bulk-io.md §3.3’s reason
Grapheme clusters§4. A real table and a real specification, and nothing in this repo has needed one
Whether wordcount.cho grows a -mIt is the obvious first consumer and the obvious first mistake: §2 says it must not be checked against wc -m, so it would need the fixture and the oracle this document used instead
The rest of the library§1: split, trim, join, ordering, case. Code with nothing left to decide, and std/bytes.cho is 125 lines today
Ordering beyond byte ordersort.cho is LC_ALL=C on purpose and locale is excluded (ROADMAP.md). Code-point order and byte order agree for UTF-8 — verified over 4,006 scalar values, the boundary ones and random — so bytes.compare sorts text correctly without decoding it at all, and the decoder is not on the path a sort takes. Worth a fixture when there is a comparison to put one on

A program in more than one file

Followed by docs/modules.md. This document chose one flat namespace — “no import, no namespaces, no visibility, because each of those is a design and the minimum that unblocks a library is none of them”. That was right for a library and not enough for a standard one, so all three now exist. Everything below still holds: a program is still the set of files named on the command line, a file that declares no module is still in the root, and identity is still by content rather than by location — a module reaches no hash.

Status: settled, and built in the same change. The gate for the sentence three design docs have now had to write: “there is nowhere to put a library.” heap.md §5 says it of Rc and Gen, arguments.md §5 says it of flag parsing, and every future stdlib function says it too.

It is also where canonical-ast.md §1 stops being aspirational. That section has claimed since M0 that “moving a function between files changes nothing about it”. With one file there were no files to move between.


1. What is missing

Everything this language can express, it can express in one file — and one file is where all of it has to go. There is no std, because a stdlib is a set of declarations somebody else wrote and there is no way to bring them in. There is no Rc, no Vec, no flag parser, for the same reason: §9 of the M2 document calls those libraries, not language features, which is only a meaningful distinction once a library can exist.

The epic’s non-goals exclude “non-flat module layout” from minimal. This document takes that literally: a program gains more than one file, and gains nothing else.


2. A program is a set of files

cancho build main.cho util.cho list.cho -o app
cancho check main.cho util.cho

Named on the command line, in any order, and that is the whole of it. No import, no search path, no file-name-to-module-name rule.

2.1 Why not import

import "util.cho" would make a program self-describing, which is a real advantage, and it needs three things this document would rather not decide yet: how a path resolves (relative to what?), what happens when two files import each other, and where a library lives when it is not next to the program. Each has a defensible answer and none is obvious, so they get their own document when somebody needs them.

A list of files is what C has, and it is enough to put a library somewhere other than the program. That is the whole ambition here.

2.2 The namespace is flat and shared

Every top-level declaration is visible in every file. There is no pub, no privacy, and no qualified name — push is push wherever it was written.

So a name declared twice is an error, whether the two declarations are in one file or in two. That is not a new rule: it is duplicate_function.cho, which has been enforced since M1, now noticing a second file.

The cost is real and worth stating: with no privacy, a library’s internal helper is as visible as its interface, and two libraries that both want the name len cannot be used together. Both are what namespaces and visibility are for, and both arrive with them.


3. Identity is content, not location

canonical-ast.md §1: “A unit is one top-level declaration… Moving a function between files, or reordering declarations, changes nothing about it.”

That has been the design since M0 and until now it could not be checked, because there was one file. It can be now, and it is:

The same function in two different files has the same BodyId and the same SigId. Moving a declaration from one file to another changes nothing about its hashes.

Nothing had to be added for this to hold — a unit hashes its own content and has never seen a file name. What is new is a test that would fail if that ever stopped being true.


4. Spans became global

The one piece of real surgery, and it is worth a section because the obvious design is the wrong one.

A Diagnostic carries a message and a Span, and a Span is a pair of byte offsets. With several files, an offset alone no longer says where the error is — so the obvious move is to put a file id in Diagnostic.

That would mean touching every place that constructs one, of which there are hundreds, to say something the offset already determines. So instead:

A Span is an offset into the whole program’s source, not into one file’s. Each file is given a base offset when it is parsed, and a SourceMap turns an offset back into a file, a line and a column when a diagnostic is rendered.

No Diagnostic call site changed. The checker never learns that files exist, which is correct — it is checking a program, and a program is a set of declarations however they were spelled across how many files.


5. What one file still means

  • cancho print <file> renders exactly one file, and round-trips it exactly as before. Printing is per file because printing is about text, and text is what a file is.
  • cancho ids <file> prints that file’s declarations’ hashes. Per file for the same reason, and §3 is the property that makes the choice harmless.
  • main must be declared exactly once across all the files, which is the rule it already had within one.

6. What this does not add

  • No namespaces and no visibility. §2.2 says what that costs.
  • No import. §2.1 says what it would need.
  • No separate compilation. Every file is parsed and checked on every build. Incremental compilation is explicitly outside “minimal” (#1), and content-addressed units are the right foundation for it when it comes.
  • No circular dependency detection, because there are no dependencies to be circular: the files are one flat program, and a function in the first may call one in the last.

7. Open

QuestionWhy it waits
import§2.1: path resolution, cycles, and where a library lives
Namespaces and visibilityThe other half of §2.2’s cost
Separate compilationWants import first, so a unit knows what it depends on
A standard libraryWants somewhere to ship it from, which wants all of the above

8. The must-reject suite

Two of these are old rules noticing a second file, which is the point: nothing new had to be invented.

CaseRule§
The same function declared in two filesA name is declared once per program2.2
main in two filesExactly one entry point5
No main in any fileSame5

And the accepting counterpart, plus the property test §3 exists for:

CaseShows
A program split across three filesA call in the first file to a function in the last
The same declaration in two different filesIdentical SigId and BodyId

Modules

Status: settled. The precondition for a standard library, and the first thing here that changes how a name is resolved.


1. What asked for it

many-files.md unblocked a library: a program is the set of files named on the command line, sharing one flat namespace — no import, no namespaces, no visibility, “because each of those is a design and the minimum that unblocks a library is none of them”.

That was right for a library. It is not enough for a standard one.

The evidence is countable. Across this repository’s examples and accept fixtures, print_nat is defined 25 times, byte for byte identically, and write_all 19 times. Every program rewrites them because there is nowhere to put them — and putting them in one flat namespace would mean a standard library owning names like open, push and total that programs here already use for their own things.

So: a namespace, a way to reach into one, and a way to keep something out of reach.


2. A module is a namespace, not an identity

This is the constraint that shapes everything below, and it comes from canonical-ast.md §1:

“moving a function between files changes nothing about it.”

A module must not break that. So a module name is not part of any hash: not a SigId, not a BodyId, not a type’s identity.

The surprising part is that callers do not change either. A call already encodes the callee’s hash rather than its spelling — that has been true since M0, for an unrelated reason (a callee’s body may be rewritten without touching a caller’s hash). So moving print_nat into std.io and calling it as io.print_nat changes:

  • not print_nat’s own SigId or BodyId, because the module is not in them, and
  • not the caller’s BodyId, because the call encodes a hash that did not move.

Modules cost the identity system exactly nothing, and that is a property of content-addressing rather than luck: in a language that addresses code by what it is, a namespace is a thing the reader needs and the store does not. moving_a_function_into_a_module_changes_no_hash is that claim as a test.


3. Declaring one

module std.io;

The first item in a file, and at most one. A file with no module declaration is in the root module — so every program written before this document still compiles, unchanged, which is the whole compatibility story and it costs nothing.

3.1 Declared, not derived from the path

A module could be named by where its file sits — std/io.cho is std.io. It is not, because many-files.md §3 chose identity by content rather than location, and deriving a module name from a file path puts the file system back into the language: a build that reorganises directories would rename modules, and a hash that does not depend on location would sit inside a name that does.

3.1.1 Corrected: a name is unique per module, and the object file now agrees

This section said a name is unique within a module. The type checker held to that; the backends did not. Every function was emitted as lexs_<name> and every static as lexs_static_<name>, so two modules each declaring pub fn drop – std.buffer, std.vec and std.json all do – were accepted by the checker and refused by the assembler (invalid redefinition of function 'lexs_drop'). Worse, two modules each declaring static table were not refused at all: the lowering looked a static up among every module’s and took the first, so a.first() * 10 + b.first() answered 55 for tables holding 5 and 7. Both are fixed: a symbol is module.name for any function or static outside the root module (. is legal in a symbol and cannot appear in an identifier), the root keeps its bare names so main is still main, and a static is looked up in its own module. Both backends; conformance/modules.rs has the two programs. Found by writing std.json, which was the first module to need a drop alongside std.buffer’s.

3.2 Two files may declare the same module

They share its namespace, exactly as two files in the root share the root today. A module is a name, not a file.


4. Importing one

import std.io;              // bound as `io` — the last segment
import std.io as console;   // bound as `console`

Per module, and the binding is a qualifier, not a set of names:

io.print_nat(i, 42);
let b: io.Buffer = io.empty();

Per module rather than per file, because §3.2 already says two files declaring one module share its namespace — and a namespace you share while each half sees different names is two namespaces wearing one name. So a module’s imports are the union of what its files import, and a file that imports nothing still sees what its module’s other file imported.

4.1 Why qualification is required

An unqualified import — bring print_nat itself into scope — puts back the collision that modules exist to prevent, and this language has no visibility-scoped shadowing story to resolve one with. §7 keeps it open; the minimum that unblocks a standard library is the qualified form.

4.2 Cycles are fine

A module may import one that imports it back. In most languages that is a problem because imports drive load order; here the program is the set of files on the command line, compiled at once, and an import is a rule for resolving a name rather than an instruction to go and read something. Functions have been mutually visible since many-files.md; modules do not change that, they only decide what a name is spelled.

4.3 A qualified name is never a local

m.f(...) resolves f in the module m is bound to, and nowhere else: not against a local binding called f, and not in the current module (§8, “not a fallback”). Only an unqualified name can refer to a local, so a local may shadow f but never m.f.

import std.math;
let max = 3;
return math.max(max, 2);    // `std.math`'s `max`, called with the local

This was always what the identity hash did (cancho-id’s qualified_name consults locals only for an unqualified name, per §2). The checker disagreed until std/process.cho found it: it looked for a local before reading the qualifier, so a local list made process.list(lr) a not-a-function refusal, and a local function value with the right signature was called in the module function’s place, compiled, and ran.


5. Visibility

pub on a function, struct or enum. Default private to its module.

pub fn print_nat[&i](io: &!i Io, n: int) -> [io_write] int { ... }
fn digit(n: int) -> [] int { ... }          // private

Private means invisible from another module — including in a type. A pub fn whose signature mentions a private type is refused, because a caller that cannot name the type cannot call the function, and a signature that cannot be used is a signature that is wrong.

Within the root module everything is visible to the root, which is why existing programs need no pub anywhere.

5.1 The root module cannot be imported

It has no name, so there is nothing to write. Once a program uses modules, main lives in the root and imports what it needs; the root sees out and nothing sees in.


6. What a module is not

  • Not a trust boundary. This is the one that matters. Authority here comes from split(world) and from nowhere else (§8.2 of linearity-and-effects.md), and a module cannot grant, widen or conjure a capability. pub means reachable, never safe: a pub fn that takes an &!i Io still needs a caller holding one, and its row still says what it did. A module boundary hides names. It does not hide effects, and it must never be read as though it does.
  • Not a compilation unit. The program is still the set of files on the command line. Modules do not add a build step, a search path or a manifest.
  • Not a package. No versions, no fetching, no registry. That is a distribution question and this is a naming one.
  • Not a file path. §3.1.

7. Open

QuestionWhy it waits
Unqualified import — import std.io.print_nat§4.1. It needs a collision rule, which is a design
Re-exportUseful for a std that is many files behind one name; needs §4.1 first
A package and version story§6. Distribution, not naming
Importing the root§5.1. It would need a name, and naming the anonymous thing is its own decision

8. The suite

Most of these need two files, and tests/reject/ compiles one at a time — so they live in the conformance harness instead, under the_module_rules_are_enforced_across_files. Same discipline: every rule here has a program that breaks it, and the message it is refused with is written down.

RuleWhere§
A module declaration is the first itemtests/reject/module_not_first.cho3
At most one per filetests/reject/two_module_declarations.cho3
An import names a module the program declarestests/reject/import_unknown_module.cho4
Two imports may not bind the same qualifierconformance, with the as that fixes it4
Private is private, for a functionconformance5
Private is private, for a typeconformance5
A pub signature names usable typesprivate_type_in_a_pub_signature5
An import binds a qualifier, not a set of namesconformance4.1
A qualified name that is not there is an error, not a fallbackconformance4
A qualified call is never shadowed by a local, accepted: an int local and a function valueconformance, a_qualified_call_is_not_shadowed_by_a_local4.3
AcceptingShows
examples/modular/Two modules and a root: a qualifier, an as, a private helper, a module importing another, and a qualified type in an annotation and a pattern

And four that are not about refusing anything:

  • moving_a_function_into_a_module_changes_no_hash — §2, the claim that modules are free, checked rather than asserted.
  • two_modules_declaring_the_same_function_agree_on_its_hash — the other side of §2. Identical code has one identity however many namespaces mention it, which is content-addressing working rather than a collision.
  • pub_grants_no_authority_and_hides_no_effect — §6. Worth a test rather than a sentence, because “public API” means sanctioned in most languages and here it must not.
  • modules_may_import_each_other — §4.2.

defer

Status: settled and built.

§4.2 of linearity-and-effects.md called defer close(f); “the obvious sugar” and deliberately left it out of M2, so the expansion and the checker would not be debugged at the same time. §12 kept one question open: is a consumption the programmer did not write at the point it happens still “visible”?

§1 answers it. The rest of the document is smaller than the question.


1. Is it still visible?

Yes, because “visible” here has never meant “written on the line where it runs”.

The word does real work in this project, and it means something precise: the type says what happened. §7’s rows are exact so that reading a signature tells you what a function did; §2.1 of reading-references.md refuses inferred binding modes so that reading a match tells you what a binding is. Both are about what a reader can learn without executing the program in their head.

defer takes nothing away from either:

  • The effect is still performed, so the row still declares it. A function whose defer closes a file still says [fs_write], and one that hid an effect this way would not compile.
  • The consumption is still checked. The expansion is real, so the exactly-once rule is enforced on every path — a value consumed twice is the ordinary “already consumed” error, and one consumed never is the ordinary leak.
  • The line is still written. defer close(f); is text in the function, one line below the open.

What moves is where the text sits, and it moves to the better place. Compare:

fn take(f: File, flag: bool) -> [] int {
    if flag { return close(f) + 2; }
    return close(f) + 4;
}

A reader checking this has to find every exit and confirm each closes exactly once — work that grows with paths times resources. With the consumption written next to the acquisition, there is one line to find and the checker does the counting:

fn take(f: File, flag: bool) -> [] int {
    defer close(f);
    if flag { return 2; }
    return 4;
}

The pairing is what a reader is looking for, and defer puts the two halves of it adjacent. That is more visible, not less.

1.1 What would have failed this test

A destructor. ~File() running at scope end is invisible in three ways defer is not: nothing is written at the use site, the effect does not reach the row, and which function runs is decided by the type rather than named. heap.md refuses those and this does not reopen them — defer names the function, and the row still tells the truth.


2. The rule

defer E; runs E at every exit from the block it is written in, in reverse order of declaration.

An exit is falling off the end of the block, or a return in it or under it. There is no break or continue, so that list is complete.

A return’s value is evaluated first, then the defers run, then the function returns. That order is forced rather than chosen: a defer close(f) alongside return fd_of(f) has to read f before the close, and the other order would make every such function unwritable.

E’s value is discarded, so it obeys the rule an expression statement obeys: discarding a res is a leak, and defer open(1); is refused for exactly that.

2.1 Block scope, not function scope

fn nested[&i](io: &!i Io) -> [io_write] int {
    defer putchar(io, 90);      // Z — the function body's frame
    if true {
        defer putchar(io, 89);  // Y — this block's frame
        putchar(io, 88);        // X
    }
    putchar(io, 87);            // W
    return 0;
}                               // prints XYWZ

Block rather than function, because borrow and region are blocks and a defer inside one should run before that block closes — a defer that outlived the borrow it was written in could not touch what it borrowed.

2.2 It does not run on a trap

A trap kills the process. There is no unwinding here and no defer-runs-on-panic, because there is no panic: defined-behaviour.md §2.1 stops rather than continuing with a wrong answer, and a stopped process has nothing left to tidy.


3. It is sugar, and stays sugar

defer is expanded during lowering, into the statement it stands for, once per exit path. Nothing downstream knows it exists: the linear checker replays the same events it would have replayed for the hand written version, and the backend emits the same code.

That is deliberate, and it is what §4.2 was waiting for. A defer that the checker knew about would be a second set of linearity rules to keep in agreement with the first. This way there is one set, and defer is a way of writing it.

The expansion is re-run per path rather than shared, which is what makes a double consumption the ordinary error:

defer close(f);
return close(f);
    error: `f` has already been consumed; a `res` value is used exactly once

The diagnostic points at the defer, which is right: it is the one that runs second.

3.1 One hash, not two

defer close(f); and close(f); run at different points, so they are different programs and get different BodyIds. The tag is the statement’s own, never the expansion’s.


4. What it is worth

Measured rather than assumed. Across this repository the pattern §4.2 describes — one resource, several exits — appears in twelve functions, and several of those would not benefit, because their paths consume different things and rebuild (examples/tree.cho::insert is the clearest).

The honest case is not the existing code; it is the code that was not written. A fallible pipeline that acquires a buffer and bails out at four checks repeats the drop four times, tangled into each return expression. That program is examples/pipeline_checks.cho, and it is seven lines shorter and considerably clearer with one defer.

defer does not help the ceremony at main. Five release calls become five defer release calls, which is the same five lines — ROADMAP.md keeps that question separate, and it is a different one.


5. Open

QuestionWhy it waits
defer on a value that is later movedCurrently a plain “already consumed” at the defer’s expansion. Correct, and the message could name the move instead
errdeferZig’s run-only-on-failure form wants a notion of failure the language does not have: there is no unwinding, and a Result is an ordinary value
Running defers on a trap§2.2. Would need unwinding, which is a much larger decision than this one

6. The suite

FixtureRule§
defer_consumes_twice.choThe expansion is real, so a second consumption is the ordinary error3
defer_leaks_its_value.choThe value is discarded, so it may not be res2
defer_on_a_frozen_value.choA defer may not consume what an enclosing borrow froze2.1
AcceptingShows
defer.choLIFO order, block scope, a loop body, and an early return through two frames
examples/pipeline_checks.cho§4: the program the feature is for

The network

Status: all three slices built. Net, net_out/net_in and connect/bind/listen/accept all exist now, behind edition 2; (editions.md §7). connect takes a name, not octets: it checks the name and the port against the capability’s bound, then resolves with getaddrinfo (§4.1, connect.md §10). bind takes a port, not a name (§2.1, listen.md §6.1), checks it against the capability’s bound, and folds socket, setsockopt(SO_REUSEADDR) and bind into one call; listen and accept take no capability at all, the fd already being what bind proved the authority for. Split into three slices, agreed when the scope came into view mid-implementation: (1) the edition-2 plumbing and the capability, (2) getaddrinfo-based resolution, closing §4.1’s “the builtin resolves” the rest of the way, (3) inbound — bind, listen, accept and net_in (listen.md §6) — all three now built. examples/fetch/ and examples/report/ are still not ported onto it: both still need read, write and close on the socket connect opens, and those stay extern fn against libc until a later slice, so porting now would add Net to their capability list without removing Ffi("libc") from it — the report would read wider, not narrower, which is not what this was for (§3 already names the day that gap closes). §5.1’s framing describes neither side, which §1 below found; the question §4.1 opened, who turns a host into an address, is decided there: the builtin, after checking the name against its capability’s bound, which is now built exactly that way. §5’s two-asker bar, per half, is cleared on both sides (connect.md §6, listen.md §4).

under-a-grant.md §5 promoted reach.md §6’s Net(host) row from a question of taste to the prerequisite for cancho code running under a lex-os grant. This is the design, written before the code the way filesystem.md was.


1. The two directions do not meet

reach.md §5.1 puts the argument as “a host is a thing worth narrowing to”. That is the outbound question: which host may I reach?

Read what the one network program in this repository actually calls:

socket  setsockopt  bind  listen  accept  read  write  close

examples/serve/ is pure ingress. It binds a port and waits. There is no connect anywhere in this corpus.

Now read what a lex-os grant says about the network — every field, from every manifest in that repository:

network     Full | Allowlist | None
egress      ["results.demo.internal:443"]

Two fields, and both are outbound. There is no inbound notion in the grant at all.

So the one program cancho has does the direction the grant does not describe, and the grant describes a direction cancho has no program for.

That is not a defect in either. It is the reason this document exists before the code rather than after it.


2. Which is right, because the two directions have different owners

The asymmetry is not an oversight in the grant. It is correct, and the reason is worth stating because it decides the capability’s shape.

Outbound is the program’s choice. A program that calls connect("evil.example", 443) picked that host. The authority question is may you pick it, and the answer belongs in the type, narrowing the way Fs(prefix) does.

Inbound is not. A program that binds port 8080 has chosen a port. Who reaches that port is decided by routing, by a firewall, by whoever holds the network the box is on — never by the program. A capability that claimed to authorise “who may connect to me” would be describing a decision the program does not make.

lex-os already does exactly this, and does it in the right place: its demo’s second wall is iptables on the tap device, so the microVM’s NIC has no route to a host outside the allowlist. That is the perimeter deciding reachability, at a layer the agent cannot touch. The grant has no inbound field because inbound is not a grant question.

2.1 So Net has two axes and they are not symmetric

What the capability boundsWho decides the rest
Outboundwhich host — Net.out("api.example.com:443"), narrowing by prefix the way Fs doesnobody else; the program picked it
Inboundwhich port — Net.in(8080)the perimeter, which decides who can reach that port

An inbound capability that bounded anything more would be a row that lies, in reach.md §5.1’s exact sense.


3. The operations are builtins

The same decision filesystem.md §2 made, for the same reason, and it is the whole point of the exercise:

Socket operations reach libc from the backend, the way putchar, malloc and the file operations already do. They are not extern fn declarations.

If they stayed extern fn, they would be gated by Ffi("libc") — and holding the FFI capability would let a program open any socket, with Net contributing nothing. under-a-grant.md §4 is that failure already measured: Ffi(lib) is the one capability whose label does not bound what it authorises, and adding Net beside it without taking sockets out of libc leaves both true at once.

Which also means this is not an additive change. A program holding Ffi("libc") can still declare extern fn socket and open one, so the guarantee Net offers is conditional on the grant that program holds — and that conditionality is honest, because it is the same one Fs lives with today.


4. What a row looks like

fn fetch[&n](net: &n Net, path: &p [byte])
    -> [net_out("api.example.com:443")] int
fn serve[&n](net: &!n Net) -> [net_in(8080)] int

Two labels rather than one, for the reason §2 gives: they answer different questions and narrow along different axes. A single net label covering both would be back to a library’s name — coarse in exactly the way this document exists to avoid.

Against a grant, the mapping is then direct and is the thing under-a-grant.md §2 could not write:

GrantReportDecidable
network: Noneany net_out or net_in labelyes
egress: ["h:p"]net_out("h:p"), by prefixyes
network: Fullanythingtrivially

Correction (#77). The literal in net_out("api.example.com:443") is a bound, not a destination. examples/fetch/ takes its address from argv, and a client always does, so no compile-time row can name where it connects. The shape that works is Fs(prefix)’s: a static bound, checked against the grant as above, and a run-time check on every connect that traps outside it. This table is still right about the bound. What the run-time check compares against, a name or an address, is the question connect.md §1 opens. §4.1 answers it.

4.1 Decided: the builtin resolves names, after checking them

connect.md §1 found that a cancho program can only ever connect to an address, while a lex-os grant only ever names hosts, so whoever turns one into the other also owns the check. There were two candidates: a builtin that calls getaddrinfo, or the perimeter. The builtin resolves. connect takes a name and a port. It checks the name against the bound its Net capability was narrowed to, and only then looks it up. A name outside the bound is refused before any lookup happens.

The deciding requirement is that cancho is usable without lex-os. Under the other answer, a program on an ordinary host could not use a host name at all (fetch takes four octets today and nothing else), and nothing would narrow where it connects, since the only check would live in a perimeter that is not there. The builtin answer gives a standalone program both: names, and a run-time check against its capability’s bound, in the same shape as Fs(prefix).

The perimeter answer was considered first, and its reasons do not survive the requirement:

Reason given for the perimeterWhy it does not decide it
lex-os already resolves each egress host on the host machine and pins the IP (install_egress_allowlist in lex-os-perimeter/src/firecracker/net.rs)It keeps doing so. The firewall stays as the outer wall, and under lex-os a program is checked twice, both times from the same grant
Two resolvers can disagree, for example under round-robin or split-horizon DNSOnly if the box asks DNS. The perimeter can write the addresses it pinned into the guest’s hosts file. With the usual lookup order (files before dns), the box then resolves each allowed name to exactly the pinned address
Resolving in the box needs DNS egress the grant never listedNot with the hosts file above: an allowed name resolves locally, and every other name is refused by the bound before a lookup is attempted
One grant, one enforcerOverstated. lex-os already has two enforcement points for one grant (its CLAUDE.md: “one declaration, two enforcement points”). A static check, the builtin’s check and the kernel filter are three points derived from one grant, not three authorities
getaddrinfo returns a pointer, and a foreign result is a scalar (reach.md §3.1)That rule is about extern fn declarations a program writes. A builtin keeps the pointer inside the backend, the way malloc already does for box, and hands the program an address or an error

What each part now means:

OwnerWhat it checks
net_out("host:port") in a rowthe static check, before the program runsthat the label is within the grant’s egress, by name, as §4’s table says
The name connect is giventhe builtin, at run time, before resolvingthat the name and port are within the capability’s bound. It traps or refuses outside it, the way Fs(prefix) does
The address the name resolves tothe perimeter, under lex-os onlythat the IP and port are ones it pinned for an allowed host

So the future connect builtin takes a name and a port, which also answers the shape question connect.md §6 left open.

What this hands to lex-os. For the two resolvers to agree by construction, the perimeter should write its pinned addresses into the guest’s hosts file when it provisions the box. Until it does, a host with several addresses can resolve differently in the box than on the host. That is a change in lex-os, not here, and nothing in cancho depends on it: a mismatch fails closed, as a refused connection.

What this does not settle. The builtin’s failure for a name outside its bound, a trap or a refusal the program can handle, is a question for the implementation. Fs(prefix) traps. A network client may want to report a bad host as a usage error, as fetch reports a bad address today, so it may want a value instead.


5. The uncomfortable count

This project’s rule is that a feature earns its way in when a program asks (standard-library.md). Counted by reading:

HalfPrograms here that ask
Inbound — bind, listen, accept1 (examples/serve/)
Outbound — connect0

The half that would unblock the lex-os join has no asker, and the half with an asker is the one the grant does not ask about.

Recounted (#77): inbound 1, outbound 1. examples/fetch/ is the program the next paragraph asks for. One asker is still below the bar, so the status stands. Both network programs build struct sockaddr_in by hand, and they are portable only through a BSD compatibility rule (connect.md §3 and §6).

Recounted (#171): outbound 2. examples/report/ is a second outbound program, an agent that posts a result rather than a generic client, so it exercises a shape fetch/ does not: a request with a body, needing a Content-Length going out the same way serve/’s responses need one coming back. The outbound half has cleared the bar. Inbound is still at one, so the two halves are no longer together: building connect/net_out is now justified by this project’s own rule, and building bind/listen/accept/net_in still is not, until a second inbound program arrives.

Recounted (#176): inbound 2. examples/collect/ is a second inbound program (listen.md): it accepts more than one connection without restarting and reads a request’s body, the two things serve/ never had to do. Both halves have now cleared the bar.

Recounted (#122): outbound 3. examples/vsock/ connects over AF_VSOCK – the channel lex-os-guest uses to reach its host supervisor, scoping the first slice of moving lex-os off Rust. It is extern fn socket/connect/close again, the same as fetch/ and report/, because AF_VSOCK is Linux-specific and has no Net builtin behind it – and docs/reach.md’s own point stands: it does not need one, Ffi("libc") already names the authority. Not a change to the bar itself, just a third program clearing it the same way.

Recounted (#125): outbound 4, inbound 3. examples/agent_guest/ and examples/agent_supervisor/ play the same guest/supervisor exchange examples/vsock/ plays over AF_VSOCK, but over plain HTTP/1.0 – not a second transport for lex-os (lex-os-proto names none, and none is proposed here), a channel this sandbox can actually round-trip end to end where vsock/’s own note says it cannot (no vhost_vsock). socket/connect on the guest side and socket/bind/listen/accept on the supervisor side are the same declarations fetch//report//serve//collect/ already made; a fourth and third program clearing the same bar, not a new one.

Recounted (#129): inbound 4. examples/results_stub/ is the cancho port of lex-os/crates/results-stub – the single allowed-egress target the lex-os demo’s manifest narrows to (lex-os issue #10), and the cancho epic issue’s own “lex-os component ported/written in cancho” – socket/bind/listen/ accept again, the same declarations serve//collect/ agent_supervisor already made; a fourth program clearing the same bar, unlike vsock//agent_guest//agent_supervisor, which are cancho analogues of a lex-os exchange rather than a port of a real lex-os source file.

Recounted: outbound 5. examples/tls_client/ (docs/foreign-linking.md) is a fifth, and the only one on edition 1 by necessity rather than by age: it declares its own socket/connect in a file kept off edition 2 specifically because connect became this document’s own builtin, and a Net bound’s port half has to be a compile-time literal (§4), which cannot express a port a test picks freely at run time – the same reason every program in this list still hand-rolls the socket it connects.

Recounted (#141): inbound still 4 programs, now 3 declaring files. serve/ and results_stub/ no longer write extern fn bind/listen/ accept themselves – both import net.sockets (packages/net-sockets/sockets.cho, docs/package-system.md §6), this repository’s first real published package, built because the two files had duplicated the same eight extern fns and two byte helpers, byte-for-byte, since #129. The four programs still ask for inbound socket authority exactly as before – nothing about what Net would need to cover changed – only where the source text asking for it lives: agent_supervisor/, collect/, and the one package file serve//results_stub/ both import now.

Recounted: outbound still 5 programs, now 4 declaring files. examples/fetch/ no longer writes extern fn connect itself – it imports net.connect (packages/net-connect/connect.cho), a second real package, alongside net.sockets for socket/read/write/ close: the first program here with two real dependencies at once, composed with no new tooling (docs/package-system.md’s own status header has the detail). report/, vsock/, agent_guest/ still declare their own connect and are candidates for the same move, not done here. The five programs still ask for outbound socket authority exactly as before; only fetch/’s own source text moved.

Recounted: both halves down to their package files alone. report/, vsock/, agent_guest/ now import net.connect too, and collect//agent_supervisor/ now import net.sockets instead of declaring their own eight extern fns – the move #141 made for serve//results_stub/ and the move above made for fetch/, finished for the rest of the corpus rather than left as the “candidates, not done here” #141 named them. Inbound is 4 programs across the one packages/net-sockets/sockets.cho file now; outbound is 5 programs across packages/net-connect/connect.cho and examples/tls_client/ socket.cho alone, the latter still on edition 1 for the reason given above. Nothing about what Net would need to cover changed on either side – every program still asks for exactly the socket authority it always did; nine files’ worth of duplicated declarations collapsed into two.

Recounted: outbound down to its package file alone, the last straggler found by a standing check rather than a fresh hunt. examples/tls_client/socket.cho – kept on edition 1 for the reason given above, and for that reason not part of the move above – no longer declares its own extern fn socket/connect/close or its own address: docs/next-phase.md §4.1’s duplicate-body conformance check (built after this document’s own count had gone stale twice, by the same hunt-vs-verify argument next-phase.md §1–2 makes) found the copy packages/net-connect/connect.cho’s own header already named as one of the files its declarations were extracted from, never migrated. It now imports net.connect/net.sockets and forwards into them, verified against a real OpenSSL server over an actual TLS 1.3 handshake. It is still its own file (edition 1, a marker of that file rather than the program, tls_client.cho needing edition 3 for c_ptr) and still declares no builtin Net capability, for the reason already on record – but it declares no extern fn of its own either now, just two pub fns forwarding into the packages. One of those two is not named connect_to: the backend’s own symbol for a function is its name alone, with no module qualifier (crates/cancho-codegen/src/abi.rs), so a second pub fn connect_to compiled into the same program as net.connect’s own collides at the object file – clang -c refuses the emitted LLVM IR outright, past what the type checker’s own module-scoped resolution catches. Named socket.open instead. Inbound is still 4 programs across packages/net-sockets/sockets.cho alone; outbound is now 5 programs across packages/net-connect/connect.cho alone – both halves down to one declaring file each, matching.

That is the honest state up to here. Both halves of the two-asker bar §5 set are cleared, and §3’s own reason to build now rather than before still applies in full: Ffi("libc") lets a program declare extern fn socket and extern fn connect on its own, so connect becoming a builtin does not by itself take sockets out of libc — a program that also holds Ffi("libc") (as examples/fetch/ and examples/report/ still do, for read/write/close) contributes nothing new by adding Net beside it, which is exactly why those two programs are not yet ported onto it (top of this document). Net is a real, narrower way to open a connection, and it is honest only once holding it — and not Ffi("libc") — is enough to speak the network, which needs the remaining socket operations to become builtins too.


6. What this does not do

  • No TLS. reach.md §3.1’s rule stands: a foreign result is a scalar, so OpenSSL’s opaque handles are out regardless of Net.
  • Hostname resolution: built. getaddrinfo returns a pointer, so it stays inside the backend the way malloc already does for box — a program never sees it, only the address or the failure. A host in a label is a name to be checked, and turning it into an address is either a builtin of its own or the perimeter’s job — §5’s program will say which. It did not choose one (#77). It showed that a cancho program can only ever connect to an address, while a grant only ever names hosts, so whoever resolves also owns the check (connect.md §1). Decided (#83): a connect builtin resolves, after checking the name against its capability’s bound, and the perimeter stays the outer wall under lex-os (§4.1). Slice 1 built connect for an address a caller already had, the octets_of half of examples/fetch/ moved into the backend (docs/connect.md §1); slice 2 replaced it with a connect that takes a name and resolves it (docs/connect.md §10) — the shape §4.1 actually decided, now built exactly that way.
  • No socket type. A descriptor is an int, as File was before file-handles.md gave it a linear type. Whether a socket wants the same treatment is a question that program answers too. It answered no, for now (#77): two close paths, no leak, and a linear type would have caught nothing (connect.md §5).
  • Nothing about exec. The grant’s third dimension has the same shape as this one and none of the same operations. It waits for its own document.

7. The suite

TestShows§
the_network_programs_are_countedThe count §5 rests on, file by file: inbound examples/serve/, outbound examples/fetch/. It was the_only_network_program_is_inbound, written to fail when an outbound program landed. It did, and §5 was recounted rather than left to age1, 5

A program that connects

Status: a probe, and it found four things.

net.md §5 counted the programs that ask for each half of the network and found inbound 1, outbound 0. It concluded that the next step was not to build Net, but to write a program that connects, because that is the only way to find out what connect needs that the design had not thought of. That program is examples/fetch/: curl -s with one method and one protocol version, 335 lines, the same extern fns against libc that examples/serve/ uses. The test suite points it at examples/serve/ itself, so a cancho client fetches from a cancho server.

It found four things net.md had not written down. Two of them change that design, one confirms a limit that was already known, and one is about portability rather than authority. That last one was never on the list, and it is smaller than it first looked: the first version of §3 was refuted by CI and is corrected in place.


1. There are no names

$ fetch localhost 8080 /health
fetch: the address must be four decimal octets; there is no name resolution

Resolving a name means getaddrinfo. It answers a struct addrinfo *, and a foreign result is a scalar (reach.md §3.1), so this language cannot hold what it returns. fetch’s whole “resolver” is octets_of: four decimal octets, or a usage error that says why.

net.md §6 left this open, with “§5’s program will say which” (a builtin, or the perimeter). What the program says is sharper than either option: a cancho program can only ever connect to an address, and a lex-os grant only ever names hosts:

egress   ["results.demo.internal:443"]      what the grant allows
connect  127.0.0.1:8080                     what the program does

A static label could say which host a program means. That can be compared with the grant by prefix, which is what net.md §4 proposed. A connection only ever says which address it reached, and nothing in the language can relate the two. So whoever resolves the name also owns the check. There are two candidates, and the difference between them is the design question net.md must now answer:

Who resolvesWhat a net_out label then namesWhat checks it
A builtin (the backend calls getaddrinfo)a host, as the grant doesthe builtin, comparing the name before it resolves it
The perimeter (lex-os already resolves egress to IP rules, its demo’s second wall)an addressthe kernel’s filter, which is outside the language entirely

Decided (#83): the builtin. net.md §4.1 gives the reasons. cancho has to be usable without lex-os, and only this answer gives a standalone program names and a run-time check. connect checks the name against its capability’s bound, then resolves it. Under lex-os, the firewall stays as an outer wall.


2. The destination is data

fetch takes its address from argv. net.md §4 wrote the row as [net_out("api.example.com:443")], with a literal. That fits a program that always calls one API, but not a client, where the host is an input. So no row written at compile time can name what fetch connects to.

This is not new. It is Fs(prefix), whose paths are also runtime data: the capability is narrowed statically to a prefix, and every operation checks its path against that prefix at run time and traps outside it (a_path_outside_the_granted_prefix_traps). A net_out label has to work the same way: a static bound, and a run-time check on each connect. §4’s row is still correct, but the literal in it is a bound, not a destination. Stated that way, the check depends on §1: it compares against a name, or against an address, and §1 has not decided which.


3. The address is portable by accident

struct sockaddr_in is not the same bytes on the two supported targets:

byte 0byte 1then
Linuxsin_family low byte: 2sin_family high byte: 0port (big-endian), four octets, eight zeros
macOSsin_len: 16sin_family: 2the same

The first two bytes contradict each other, and a program with no struct layout and no target conditionals can write only one of them. the_linux_address_layout_connects_on_both_targets measures what each target does with each one. It connects to a listener with one layout at a time, and each CI runner checks its own row:

2, 0 (Linux bytes)16, 2 (macOS bytes)
Linuxconnectsrefused: read as family 528
macOSconnects: family 0 read as AF_INET, sin_len taken from the length argumentconnects

So one array works on both targets: the Linux one. But it does so through a compatibility rule, not by being right. BSD kernels accept family 0 (AF_UNSPEC) as AF_INET for old programs that never set it, and they overwrite sin_len with the length the call was given. That is why examples/serve/’s bind has always passed on macOS, and it turns out connect follows the same rule.

Correction (#77). This section first said the opposite: that “no one array is right on both”, that connect on macOS would refuse the Linux bytes, and that fetch had to try both layouts. The first two claims came from reading BSD source from memory, not from running anything. The darwin-aarch64 runner refuted the assertion that encoded them, fetch lost its second attempt, and the test was renamed to pin what was measured. Linux does refuse the macOS bytes; that half was measured here and stands.

What survives is smaller. Both network programs build the struct by hand, with no layout the language knows. They are portable to exactly the targets whose kernels forgive the Linux bytes, and a third target without that rule would break both of them silently, at run time.


4. Why a connection failed is not observable

When connect fails it returns -1, and the reason is in errno: a thread-local that libc reaches through __errno_location() on Linux and __error() on macOS. Both return a pointer, so §1’s rule applies again. fetch therefore cannot tell nothing is listening from network unreachable from permission denied. It says could not connect and exits 3.

This is reach.md §3.1’s known limit, in the place it costs most. A network client’s error messages are most of what it tells its user.


5. What it did not find

  • No need for a socket type. net.md §6 asked whether a descriptor wants the linear treatment File got. fetch has two close paths: a failed connect in connect_to, and the end of the exchange in main. Writing it produced no leak, and a linear type would have caught none. The one bug writing it did produce was in buffering: the first version copied a whole read into the 4 KiB header buffer, and gave up when the end of the header block and the start of the body arrived in the same read. fetch_speaks_http_1_0_and_streams_the_body found it on its first run. That is ordinary code, and no type would have prevented it.
  • No need for Heap. The response is streamed: header bytes are held until the blank line, and every later byte goes straight to standard output. main releases fs and heap on its second and third lines.
  • No TLS, as net.md §6 already said. fetch speaks HTTP/1.0 in plain text.

6. The count, and what clears the bar

HalfPrograms that ask
Inbound: bind, listen, accept1 (examples/serve/)
Outbound: connect1 (examples/fetch/)

The bar is two askers (standard-library.md), so Net itself is still settled, not built. That is the honest result of adding one program. the_network_programs_are_counted pins the count, so the next network program has to change it.

Both network programs build struct sockaddr_in by hand, so there are two askers for a socket address the language knows the layout of. §3 shows the case is weaker than it first looked. Both programs work on both targets today, through a BSD compatibility rule. What a builtin would buy is portability that does not rest on that rule, and a library cannot provide it, because a library knows no more about the target than a program does.

It is recorded here, not built, for two reasons. Nothing is broken on the targets this project supports. And the builtin’s shape depends on §1: an address builtin that takes octets and a port suits the perimeter answer, and a connect builtin that takes a name suits the builtin answer. Building either before choosing would decide §1 by accident. §1 is now decided for the builtin, so it is the one that takes a name and a port.

Recounted (#171): outbound 2. examples/report/ is fetch/’s shape with a genuinely different HTTP request: POST <path> with a body, so a Content-Length goes out on the connection this time rather than only coming back, the way serve/’s does. It found nothing new about §1 through §4 — the same address rules, the same layout, the same opaque errno, copied rather than shared because there is nowhere between two examples to put a shared function. It found two things §1 through §4 had no reason to raise: §8. The outbound half has cleared the bar; inbound is still at one.


7. The suite

TestWhat it pins§
a_cancho_client_fetches_from_a_cancho_serverfetch against examples/serve/, both exchanges: the body, and exit 0 for 200 and 1 for 404—
fetch_speaks_http_1_0_and_streams_the_bodythe request byte for byte; a blank line split across two reads; a 100,000-byte body, 25 of the client’s reads5
fetch_refuses_a_name_it_cannot_resolvea name, three bad addresses and two bad ports are usage errors that say why1
the_linux_address_layout_connects_on_both_targetsLinux accepts 2, 0 and refuses 16, 2; macOS accepts both. Each row is measured on its own CI runner. The first version asserted that macOS refuses 2, 0, and CI refuted it3
the_client_and_the_server_differ_only_in_their_symbolsboth reports are the same unbounded ffi("libc"); connect is in one symbol list and bind/listen/accept in the other—
the_network_programs_are_countedinbound 1, outbound 2. It used to be the_only_network_program_is_inbound, written to fail the day the first outbound program arrived6
a_cancho_agent_reports_to_a_cancho_serverreport against examples/serve/: a POST gets the same 404 serve/ gives any unmatched route, over a real connection6
report_sends_a_body_the_server_can_read_in_fullthe request byte for byte, including Content-Length, with a 100,000-byte body that takes the client more than one write6

8. What changed and what did not

A second outbound program is exactly what §6 says it is: a test of whether one asker was an accident. It was not, on the design questions §1 through §4 already settled: report/ reuses fetch/’s address handling, connect loop and response reader with no changes, and the one place it differs — assembling a request with a body — is a problem this project had already solved once, on the other side of the same connection, in serve/’s respond. Nothing there is a new design question, which is itself the finding: the outbound half of Net’s shape generalizes past its first asker.

Writing it found two things anyway, both bugs rather than design questions, and both only because this program does something fetch/ never needed to: send a body large enough to matter.

  • A region is one 64 KiB arena chunk (docs/defined-behaviour.md), and report/’s first version copied the whole request — headers and a message from argv — into one scratch slice before sending it, the way fetch/’s much smaller request is built. A message over roughly 57 KiB made that allocation itself trap. The fix sends the header block and the body as two send_all calls instead of one, so the body streams straight from the caller’s own slice and is never copied into an arena at all — the same reason fetch/ never materialises a whole response body either.
  • The header buffer was five bytes short. Fixing the first bug still left head_out sized len(path) + len(host) + 64: enough for the 63 bytes of literal text around it, but with only one byte of headroom for Content-Length’s digits. Every message this program exists to send is large enough that the digit count exceeds that, so put wrote past the end of the slice and the bounds check caught it — the moment a five-figure body was tried, not before. + 96 is the fix, the same margin fetch/’s own scratch buffers already use.

Both were caught by a bounds check and an arena-capacity check, not by a wrong answer: the failure mode docs/defined-behaviour.md promises, not a silent one. And both are the ordinary cost of a second program that actually exercises the first one’s untested edge, which is the entire argument for writing it rather than trusting the count.


9. Slice 1: an address a caller already has

Once both halves cleared §6’s bar, Net turned out to be bigger than one slice: edition N; parsed but nothing consumed it (editions.md §8), so making edition 2 a real edition meant threading it through every place a written name resolves — a function’s parameters and return type, a let that destructures a struct, a struct literal, a variant constructor — not only the one resolve_type_at already had a lookup closure for. Agreed as three slices once that came into view: this one (the edition, Net, and a connect that takes octets), name resolution, then inbound.

What is built: Net(bound), narrowed the way Fs(prefix) is (plain prefix widening, with no /-boundary rule of its own — §4’s table has no boundary character to land on); edition 2’s six-field Split, a second declaration of the same source name rather than a sixth field added to the one all 210 files destructure, because Split is res by inference and a field nothing could unname would have to be consumed by every caller — exactly what editions.md §7 says an edition must not do; and connect(net, a, b, c, d, port), which builds the same sixteen-byte struct sockaddr_in, in the same Linux layout §3 measured, that examples/fetch/’s address builds by hand — once, in the backend, so a program with Net gets it instead of writing it again.

What is not: the bound is not checked against what is dialled. §4.1’s run-time check compares a name, and slice 1 has none to compare — its row is honestly the bound the capability was narrowed to, exactly as open_read’s row is the directory Fs was narrowed to, but nothing yet traps a connect whose octets land outside it. That is slice 2’s getaddrinfo call, not a bug in this one: a name is what makes the check meaningful, and octets alone would only be checking a program against itself. examples/fetch/ and examples/report/ are not ported onto connect for the same reason net.md’s status note gives: both still need read, write and close on the socket connect opens, and those stay extern fn against libc (ffi("libc")) until a later slice, so porting now would add Net to their report without removing the wider capability from it.


10. Slice 2: getaddrinfo resolves

connect’s shape changes from slice 1’s connect(net, a, b, c, d, port) to connect(net, name, port), where name is &r [byte] — the one design §1 through §4.1 actually asked for. Octets were a stand-in for “no resolver exists yet”; getaddrinfo accepts a numeric address exactly as it accepts a symbolic one, so a single name-taking builtin covers what the four-octet one did and what fetch/’s octets_of never could.

10.1 The bound is checked before anything is resolved

§4.1 decided this order for a reason: a name outside the bound must never reach the resolver, or a program could use its narrowed Net to probe DNS for names it was never granted. connect takes the name and the port as two arguments (§4.1’s own words), while the bound is one string, "host:port" (§4). The two are split once, at compile time, since the bound is fixed at that point and neither half needs a run-time parse: everything up to the last : is the host bound, everything after it the port bound. A bound with no : at all — "", unnarrowed, included — has no port bound, which means no restriction on it.

The host half is checked_host (mirroring checked_path, docs/filesystem.md §4): it copies name into a NUL-terminated stack buffer while comparing it, byte for byte, against the host bound. Two things checked_path checks that this does not, because neither is a fact about a host name: the ..-traversal refusal, and the requirement that a match land on a separator. net.md §4 bounds net_out by plain prefix, with no boundary character of its own — narrow’s own Net branch already says so (lower/mod.rs) — so "api.example.com" narrows a host bound of "api" exactly as far as the bytes agree and no further rule applies. An unnarrowed Net (the bound is "") matches every name, the same way an unnarrowed Fs/Ffi matches every path or library.

The port half is different in kind, not degree: "host:8443" names one port, not a prefix of ports the way a directory is a prefix of paths, so the check is equality — the dialled port either is 8443 or the capability was not narrowed to authorise this call — and it traps before the host is even copied. A bound with no port restricts nothing there, the same permissive default the host half has for an empty bound.

10.2 What the resolver is asked for, and what comes back

getaddrinfo(host, NULL, &hints, &res), with hints a zeroed struct addrinfo except ai_family = AF_INET (2) and ai_socktype = SOCK_STREAM (1) — IPv4 only, matching the one address family this project has ever built a struct sockaddr_in for, and one connection kind. service is NULL rather than the port as a decimal string: building that string is exactly the work net.md §6 keeps out of this language (std.fmt’s printer is the one place digits are the language’s problem), and it is unnecessary — the port is two bytes at a fixed offset in whatever sockaddr_in the resolver hands back, the same two bytes examples/fetch/’s address writes by hand, and connect.md §3’s whole finding was that only the family bytes differ between Linux and macOS. Patching the port after resolving needs no layout assumption beyond that one, already measured.

struct addrinfo is 48 bytes on every target this project supports, but not in the same field order.

Correction. This section first claimed the order was the same everywhere, reasoned by analogy with the rest of <netdb.h>’s fields rather than measured — precisely the mistake §3’s own correction warns against, in the same document. The darwin-aarch64 runner refuted it on the first CI run: connect_a_refused_address printed nothing at all, because the backend read ai_canonname where it expected ai_addr and either patched a NUL pointer’s non-existent port bytes or handed connect(2) an address that was never a sockaddr. struct sockaddr_in’s family byte predates POSIX settling it (§3); ai_addr and ai_canonname did not need history to disagree — the original BSD getaddrinfo (RFC 2553) declared ai_canonname before ai_addr, Darwin’s libc still does, and glibc does not.

OffsetFieldSize
0ai_flags4
4ai_family4
8ai_socktype4
12ai_protocol4
16ai_addrlen4
20(padding)4
24ai_addr (glibc) / ai_canonname (Darwin)8
32ai_canonname (glibc) / ai_addr (Darwin)8
40ai_next8

Six of the eight fields — everything but the pointer pair at 24 and 32 — agree on every target, the same way connect.md §3 found that only struct sockaddr_in’s first two bytes disagree. The backend picks ai_addr’s offset from self.module.isa().triple().operating_system, the same target check errno’s symbol name already makes, rather than assuming glibc’s order everywhere.

Read once, for the first result: ai_addr (a struct sockaddr *) and ai_addrlen. The backend never reconstructs the address itself here — unlike slice 1, where there was no resolver to ask — it patches the port at ai_addr[2..4] and passes ai_addr/ai_addrlen straight to connect(2), the same call slice 1 already made. freeaddrinfo(res) releases the list; a client connects once, so nothing here needs more than its first entry.

10.3 What this does not do

  • No IPv6. ai_family = AF_INET asks the resolver to filter it out, rather than this project deciding what a Net bound means for two address families that write hosts differently in host:port text. That question is still open.
  • No port lookup by service name. service is always NULL; a Net bound is "host:port", and this reads the port half itself rather than asking the resolver to look up "http" or "https".
  • The struct offsets above were asserted rather than measured, and CI found the one that was wrong within its first run (§10.2’s correction). tests/accept/connect_a_refused_address.cho reads through all of them via a real connect on every target this suite runs (accepted_programs_build_and_run), the same build-and-run-on-both- runners check §3’s table formalised, without a table of its own — there was only one row to disagree, and it did.

A second inbound program

Status: probe done, slice 3 built. net.md §5 and connect.md §6 count inbound and outbound separately, each against a bar of two askers; §4 below is where this probe cleared inbound’s. Net’s third slice (connect.md §9’s plan) builds on that count: bind(net, port), listen(fd, backlog) and accept(fd), and the net_in label, all behind edition 2;. §6 is the build.


1. What serve/ never had to do

examples/serve/ binds one port, accepts one connection, answers one request, and exits — which reach.md and net.md §5 both note is what makes it testable without a shutdown protocol. Its router reads only the request line (read_request stops at the first \n); nothing in it ever reads a body, because GET /health has none.

That leaves two things a second inbound program can exercise that the first one never did, and both are ordinary parts of being a server rather than exotic ones: accepting more than one connection without restarting the process, and reading a request that carries a body.

2. collect/: a bounded accept loop that reads one

examples/collect/collect.cho takes a port and a count on the command line, accepts that many connections in a loop, and for each one reads a POST request in full — headers, then the body, by Content-Length, the same accounting examples/report/’s exchange sends by (connect.md §8) — and writes the body to standard output before moving on to the next connection. It answers every request with a bare 200, since the point is what it can read, not what it can route.

A fixed count, taken from argv, is the same discipline examples/serve/’s own test already relies on: a server with no shutdown protocol is testable only if something bounds how long it runs, and “one” was serve/’s bound. collect/’s is a number instead of a constant one, which is the whole of what makes the loop a loop rather than a copy of serve/ with a bigger number hard-coded in.

3. What this does and does not settle

This is not a new design question the way connect.md §1–§4 were. net.md §2 already decided inbound’s shape — a capability bounds a port, and who may reach it is the perimeter’s answer, not the program’s — and nothing about reading more than one request or a body changes that. collect/ is here to clear connect.md §6’s count, the same way examples/report/ did for outbound, and to find what a second inbound program finds by being written rather than assumed.

4. The count

HalfPrograms that askago
Inbound: bind, listen, accept1 (examples/serve/)net.md §5, connect.md §6
Inbound, recounted2 (examples/serve/, examples/collect/)here

Both halves of Net have now cleared the two-asker bar.

5. What writing it found

Not in collect.cho itself: reused unchanged from docs/connect.md §8’s answer, its body streams straight to standard output rather than being copied into a scratch slice, so a region’s 64 KiB arena cap is never in play here either.

In the test. collect’s own stdout is a pipe when the test spawns it, and a pipe’s kernel buffer is smaller than the 100,000-byte body the large-body test sends. A first version of that test read the child’s stdout only after the HTTP exchange finished — wait_with_output after post returned — which deadlocks the moment the pipe fills: collect blocks writing the rest of the body to a pipe nobody is draining, so it never gets to send the response the test is waiting on. Draining the pipe on its own thread, concurrently with the exchange, is the fix — the same shape fetch_speaks_http_1_0_and_streams_the_body already uses for the send side. Not a bug in a rule that traps; a liveness bug ordinary tools do not catch until the deadline expires, found by writing a test whose body was actually large enough to fill one buffer.


6. What slice 3 builds

Three builtins replace the extern fn socket/setsockopt/bind/ listen/accept five examples/serve/ and examples/collect/ still declare by hand against Ffi("libc"):

  • bind(net, port) -> [net_in(bound)] int folds socket, setsockopt(SO_REUSEADDR) and bind into one call, the inbound mirror of connect’s socket+connect (docs/connect.md §9). It builds the same struct sockaddr_in serve.cho’s own serve function builds by hand: family bytes, the port big-endian, and INADDR_ANY — eight zero bytes where connect’s has four octets, because a listener binds every address the host has, not one it chose.
  • listen(fd, backlog) -> [] int and accept(fd) -> [] int are thin: listen(2) and accept(2) with the peer address ignored (NULL, NULL, the same as serve.cho’s own accept(libc, fd, 0, 0)). Neither takes a capability — the fd is what bind already proved the authority for — so both are fixed signatures, checked the way Builtin::Close is, not the way connect and bind are.

6.1 Net.in(port): the bound is a port, not host:port

net.md §2.1’s table draws the line: outbound’s bound is which host, inbound’s is which port — nothing else, because who may reach a bound port is the perimeter’s decision, never the program’s. So a Net narrowed for bind carries just the port as text — narrow(net, "8080") — and bind’s row is net_in(bound), checked against the capability the same way connect’s port half already is (§10.1): equality, not a prefix, because a bound of "8080" names one port. Reusing Net rather than adding a second capability type keeps the two halves of docs/net.md §2.1’s table one type with two meanings for its one string field, exactly as the table already implied.

Only bind performs net_in. listen and accept perform nothing, the same reasoning close’s empty row has: ending or operating a resource whose authority was already spent when it was acquired is not itself a new use of anything. (file_read’s row is the counter-example and is not one here: it spends bytes off a path-bound budget on every call, where accept spends nothing a bound could ever name — the perimeter, not the row, is what still decides who gets through.)

6.2 A correction, found while writing the same check twice

Writing bind’s port check by the same shape as connect’s (docs/connect.md §10.1) surfaced a bug already latent in connect’s: a bound whose port half does not parse as a number (narrow(net, "host:abc"), which narrow never refuses — a bound is free-form text) made connect’s port_bound None, the same value an absent port uses for “no restriction”. A malformed bound is not the same fact as an unnarrowed one, and reading them alike would restrict a port that was meant to be bound to nothing at all. Both parses now read a non-empty, non-numeric half as -1 — a port connect/bind can never be asked to dial, so a malformed narrowing refuses every call rather than none. This does not widen what a program can reach: narrow only ever narrows from what a capability already held, so failing to tighten as intended was never a way to gain authority — but it was a row that would have promised a bound it did not enforce, which is the one thing docs/reach.md says a row must never do.

6.3 A symbol collision, found writing the first program to hit it

Testing bind/listen/accept against a real connection needed read, write and close on the accepted socket, which still cross through a program’s own extern fn declarations (§6 above; close is not one of the three builtins this slice adds). Declaring extern fn close in that test alongside bind failed to compile: IncompatibleSignature on the linker symbol close, because it is declared twice at two different widths. Every extern fn in this compiler crosses a cancho int as 64 bits, whatever the C function’s own parameter or return width — abi.rs’s leaves_into always answers types::I64 for Type::Int, foreign calls included. bind’s own internal close (the socket-failure path, docs/net.md §3’s “sockets have to come out of libc”), like open_file’s in filesystem.md §2, is declared against libc’s true 32-bit int instead, because that is the signature libc’s own close(2) actually has. One name, two declared widths, and Cranelift is right to refuse a module that asks for both.

This did not need fixing to finish this slice — close is one call in a test with nothing left to do once it returns, so the test simply does not declare or call it, and the OS reclaims both descriptors when the process exits. It is recorded here because it is not particular to bind: any program combining an internal socket or file builtin with an extern fn of the same libc name hits it, and open_read/ fs_read/fs_write have shared this exposure with file_op’s own internal open/close since before Net existed. Whether the right fix is widening the internal declarations to match extern fn’s convention, or something else, is a question for a document of its own — not answered by avoiding it once.


7. The suite

TestWhat it pins§
an_inbound_agent_reads_several_requests_in_a_rowcollect accepts three connections in a row without restarting, and reads each body whole2
collect_reads_a_body_larger_than_one_reada body that arrives across more than one read, larger than the 4 KiB a request line alone would need, drained concurrently with the exchange2, 5
the_network_programs_are_countedinbound 2, outbound 24
a_cancho_listener_accepts_a_real_connectionbind/listen/accept, built, answer a real client over loopback6
binding_the_wrong_port_trapsbind’s port is checked against the capability’s bound, the inbound mirror of connecting_to_the_wrong_port_traps6.1

The standard library

Status: settled and built. The thing modules.md was the precondition for.


1. What it is for

Across this repository, before this document, print_nat was written out 25 times byte for byte and write_all 19 times. Not because anyone wanted twenty-five copies, but because there was nowhere to put one — many-files.md gave a program several files and one flat namespace, and a standard library in a flat namespace would own open, push and total, which programs here already use.

modules.md fixed the namespace. This is what goes in it.


2. How a program reaches it

Two ways, and the second is the point.

# Name the files, like any other module:
cancho build tool.cho std/io.cho std/bytes.cho

# Or ask for the whole library:
cancho build tool.cho --std

--std is not a search path. The library’s source is compiled into the cancho binary, so the flag adds no filesystem lookup, no manifest and no build step — modules.md §6 promised none of those, and this keeps that promise by not going near the disk at all.

It is opt-in, and deliberately so. There is no implicit prelude: a program that says nothing gets nothing, and import std.io; is still written where it is used. --std decides whether the source is present, never whether a name is in scope.

2.1 What that couples

The library ships with the compiler, so its version is the compiler’s version. For a language at this stage that is the right trade — one artifact, one thing to install, nothing to resolve — and it is the decision to revisit first when a package story exists (§6).


3. The surface

Eight modules. The four below earned their place first; the four collections that followed are docs/collections.md’s, and the short version is that std.list holds resources, std.vec does not, and the difference is the shape rather than the generics.

3.1 std.bytes — text, which here means bytes

strings.md §1: a string is bytes, not an encoding. So the text module is a byte module, and every function in it is honest about working on one byte or a run of them.

is_blank(c)The six bytes C’s isspace calls space. examples/tally.cho got this wrong twice before it was written down once
is_digit(c), is_upper(c), is_lower(c), is_alpha(c)Classification, ASCII, no locale
to_lower(c), to_upper(c)ASCII case, and a no-op on anything else
digit_of(c)The value of a digit byte, -1 for anything else
equal(a, b)Two slices, byte for byte
starts_with(haystack, prefix)
find(haystack, needle)The index, or -1

-1 rather than an enum, matching fs_read and getchar (standard-input.md §3.1). The same caveat applies and the same question is open.

3.2 std.io — the console

write_all(io, s)A slice of bytes, one putchar at a time
print_nat(io, n)A non-negative integer
print_int(io, n)Any integer, with the sign
print_pad(io, n, width)Right-aligned in a field, for columns
newline(io), space(io)
error_all(io, s)A slice, on the other stream — [err_write], not [io_write] (standard-error.md)

Every one of these takes an &!i Io and declares what it did with it — [io_write] for all but the last, [err_write] for error_all, because a library does not get to be quieter about its effects than a program would be. That is modules.md §6 in practice: pub bought these functions reachability and nothing else.

3.3 std.math

min(a, b), max(a, b), abs(n)
gcd(a, b), sign(n)examples/rational.cho wrote gcd for itself; now it need not

abs on the most negative integer traps, because negating it overflows and defined-behaviour.md §2.1 says an operation with no right answer stops rather than inventing one. A library that quietly returned the negative number would be exactly the silently-wrong answer the language exists to refuse.

3.4 std.buffer — bytes, growable

A growable byte buffer: Buffer, empty, reserve, push, append, push_nat, size, write, clear and drop.

clear arrived last and is the clearest case any of these has: examples/cut/ reads a line at a time and has to reuse its buffer, and nothing here could move used back — filled only goes forward. The alternative, drop plus empty on every line, is 13.0× slower (docs/line-reading.md §4). A gap rather than a convenience.

size rather than len, because len is a builtin and a program may not redeclare one — which is the right refusal, and worth having hit while writing the library rather than in someone else’s code.

It is res and takes a &!h Heap, so it obeys every rule an ordinary program’s buffer would — it is boxed-slices.md §4’s allocate-copy-end written once instead of once per program. The doubling policy lives here now, which is the only thing that changed: it is still policy, still in a library, still not in the compiler.

std.vec is this with the element type lifted out, and it stays a separate module rather than replacing this one: a byte buffer’s append and push_nat are about bytes, and a Vec[byte] that had them would be a byte buffer wearing a type parameter.

3.5 The collections

std.option, std.result, std.list and std.vec — docs/collections.md. What is worth carrying back here is that three of the four needed no language change: a generic container has worked at both modes since M2, and what was missing was a library that said so. The fourth needed a bound on a type declaration, because res struct Vec[T: val] is two modes about two different things.

3.6 std.fmt and std.bignum — printing a float

fmt.float_into(out, x) -> int       // bytes written, or -1 if `out` is short

The shortest decimal that reads back to the same bits — 0.1 as 1e-1, not as 0.1000000000000000055511151231257827 and not as 0.100000. docs/float-printing.md is the whole story; two things belong here.

It is library code, and that is the interesting part. Float printing is the routine every other language keeps in its runtime, in C or Rust, a thousand lines behind whatever interface it chose. Here it is std/fmt.cho, written in cancho, with an effect row of [] and no Heap — the working numbers live in a region. The compiler’s entire contribution is bits_of, a bitcast that gives every NaN one pattern (differential.md §4). Anything float_into does, a program could have done.

std.bignum is underneath, and has no division. Exact integers to 1080 bits, base-2³² limbs in a fixed-length [int], in place and allocation-free. It is ninety lines rather than four hundred because the one quotient the printer needs is a single digit, and nine subtractions settle it.

Neither module is a general-purpose facility and neither pretends to be: std.bignum is as wide as std.fmt needs, and std.fmt prints one type. §4’s “no allocation-free string formatting” still stands for everything else.

std.fmt32 is the same idea for f32 (edition 6; f32.md §5.3): fmt32.f32_into(out, x) is Rust’s {:?}, fmt32.f32_fixed_into(out, x, prec) is {:.N} with exact ties to even, and fmt32.f32_of_text(text) reads a decimal to the nearest f32 in one rounding. Each answers a byte count or -1 (or (false, _)), and none traps.


4. What is deliberately not in it

  • No std.slab. examples/slab/ stays an example. sharing.md §3 is explicit that a generational handle is an escape hatch and “neither is reached for by default” — putting it in std would say the opposite.

  • No Option or Result. Both are in, along with std.list and std.vec — see docs/collections.md. The reason first given here was wrong twice over, and the second wrong reason was the correction to the first.

    It said they “want generics over a mode”, which the language does not have. It does, and has since M2 (mode-polymorphism.md §1); that claim was taken from §12’s open list rather than from a test.

    The correction then said a Vec[T] over a resource type is expressible. It is not. A Vec keeps its elements in a boxed slice, and collections.md §2 gives two independent reasons a boxed slice holds val data only — the fill is copied into every element, and freeing the run is one free that runs nothing. std.vec is therefore [T: val], honestly, and std.list is the collection that holds resources. The difference is the shape, which is what both earlier answers missed by looking at the generics.

  • No allocation-free string formatting, with one exception that proves the rule. print_* writes to the console; formatting into a buffer is a second surface and wants §6’s open question about writers answered first. fmt.float_into (§3.6) does write into a caller’s [byte], because printing a float has no second way to do it — and it is one function for one type, not the surface.

  • No implicit prelude. §2.


5. What building it found

Three things, and the second is a language bug this slice fixed.

  1. A library must declare everything a program declares. Every function here has an exact effect row and every capability is a parameter. Nothing was easier to write because it was “the standard library”, which is what modules.md §6 predicted and is worth having confirmed.

  2. --std with no import must stay silent — and it did not. This section first said “a declaration nobody calls costs nothing: emission only reaches what is called”. That was written from the comment above the code, which said the same thing, and both were wrong. A program calling none of the library got a 6720-byte object against 1048 without it.

    Emission seeded from every non-generic function, which is indistinguishable from reachability while every function in a program is one somebody wrote. A standard library is the first time it is not.

    Fixed rather than documented around, because a library you cannot afford to link is not standard. The two passes now have one job each: checking is total — every function, once, generics with rigid parameters — and emission starts at main. With no main there is no program, only declarations, so every non-generic function is a root instead; that is the binary-versus-library distinction every toolchain draws, drawn from the one fact available here.

    The fix is not about std. Every program gets it.

    It also made checking run in source order, which surfaced a must-reject fixture that was passing for the wrong reason: effect_not_propagated.cho had putchar(33), missing the Io, and was refused for that rather than for the effect rule it tests. A fixture passing for the wrong reason deserves more attention than one failing, and this is the second time a rule’s real test turned out to be somewhere other than where it was written down.

  3. Byte classification is where programs quietly disagree. tally.cho and wordcount.cho each had their own idea of a word boundary and they were not the same. One definition, in one place, is most of what a standard library is for.

    Corrected (#57). “One definition, in one place” was the plan, not the outcome. wordcount.cho moved to std.bytes; tally.cho did not — it kept a private is_blank, byte-identical, under a comment arguing that a word boundary is a policy belonging in the program. That argument was against putting it in the compiler, and it was written before there was a library to be the third option. So there were still two definitions for a year. They agreed, and nothing checked that they did — which is the failure mode this entry is about, surviving inside the entry that named it. tally.cho imports std.bytes now.


6. Open

QuestionWhy it waits
A writer abstraction — format into a buffer or a file, not just the consoleWants something like a trait, and there are none
A line readerAnswered, no — line-reading.md. Five programs call getchar and one reads a line at a time; the bar is two. The row that asked for it named a second program that keeps nothing at all, which is what §5’s rule costs when the count is taken from memory rather than by reading
split returning a collectionutf8.md §1 verified vec.Vec[&t [byte]] compiles, so this is writable. bytes.field covers the case examples/cut/ had without allocating, and nothing has yet needed the whole list at once — which is the bar the other four cleared
Option / Result over res typesMode polymorphism, §12
Versioning the library separately from the compiler§2.1. A package story, which is modules.md §7’s open question too
An enum instead of -1standard-input.md §6, now for a third set of operations
error_nat, and what goes in front of a diagnosticstandard-error.md §3.2 and §8. A number on that stream wants std.buffer and one write, which is writable today; the prefix — sort:, cut: — is written out at eleven call sites, and what stops it being a function is where the name comes from, since arg(g, 0) needs a capability the failure site may have released

7. The suite

The library is checked the way a program is: it compiles, its examples run, and its rules have fixtures.

TestShows
the_standard_library_compiles_on_its_ownEvery module of std/ type-checks together, without a program
std_is_available_behind_a_flag--std compiles a program that imports it, with no file named
std_declarations_cost_nothing_unless_called§5.2: a program built with --std and one built without it emit byte-identical object files. This was false when it was first written down, which is why it is a test
abs_of_the_most_negative_integer_traps§3.3
examples/wordcount.choRewritten on std — five helpers gone — and prints exactly what it printed before
examples/queue.choThe collections at work: jobs that own memory, held in a List, ended exactly once each
printing_preserves_every_identity_and_is_idempotentstd/ walks with everything else: the library is code and gets the same contract

Every example is now built with --std passed unconditionally, which is only safe because of §5.2. That is the property being relied on rather than merely asserted.

Printing a float

Status: settled and built.

floating-point.md §7 listed printing as “the one that makes float awkward rather than incomplete”, and that was the right word. A language with float and no way to print one reports numbers through truncate and a scale factor, which is fixed point with extra steps — examples/newton.cho did exactly that, and said so.

This document is that row closed. The answer is std.fmt.float_into, and the thing worth reading it for is where the answer lives: not in the compiler.


1. What “printing a float” means

There are three things a printer could aim at, and only one of them is a contract:

AimExample for 0.1Verdict
The exact value0.1000000000000000055511151231257827True, and useless. Nobody typed that
Some fixed number of digits0.100000Loses values. 1e300 and 1e-300 both print as 0.000000
The shortest decimal that reads back to the same bits1e-1The only one that is a round trip

The third is the contract, and it is the one this implements: for every float x, reading back what float_into wrote gives x again, and no shorter decimal would have.

That property is worth stating precisely, because it is stronger than “accurate”. 0.1000000000000000055511151231257827 is more accurate and still wrong for this purpose: the value it names is the same float, so the extra digits carry no information and cost thirty characters.

1.1 The form

d[.ddd]e[-]k

One digit, optionally a point and the rest, then e and a decimal exponent. 0.1 is 1e-1, 1.5 is 1.5e0, 100.0 is 1e2, zero is 0e0.

This is not the prettiest choice and it is deliberate. Positional notation — printing 0.1 as 0.1 and 1e21 as 1000000000000000000000 — needs a second set of decisions (at which exponent does it switch? how many leading zeros before it gives up?) which have nothing to do with the digits and which every language answers differently. Those decisions are a formatting layer’s business. The digits are the hard part, and this is the layer that gets them right.

inf, -inf and NaN are spelled rather than refused, because floating-point.md §2 made them values. -0.0 prints as -0e0: the sign bit is part of the value, and losing it on the way out would make printing the one place the language forgot.


2. Where it lives, which is the point

float_into is written in cancho, in std/fmt.cho. It is not a builtin, not a call into libc, and not a special case in the code generator. The compiler’s entire contribution is one builtin:

bits_of(x: float) -> int          // the same 64 bits, read as an integer

A bitcast, plus two instructions that give every NaN one pattern (movq, ucomisd, cmovp on x86-64; differential.md §4, #76). From there the algorithm is ordinary cancho: masks and shifts to pull the mantissa and exponent apart (bitwise.md), a region to hold the working numbers, while loops, and a [byte] to write into. Its effect row is [] — printing a float performs nothing, because writing the bytes somewhere is the caller’s effect, not this function’s.

This matters more than it looks. The usual arrangement is that a language’s float printing is a thousand lines of C or Rust inside the runtime, reachable only through whatever interface it chose to expose. Here the worst piece of numerical formatting there is turned out to be library code in the language itself, and the thing that made it possible is a builtin small enough to describe in six words.

It is also the honest test of bits_of. A primitive that only the compiler’s own code could use would not have earned its place; float_into is the program that proves a user could have written this.


3. The algorithm

Steele and White’s, which Dragon4 is a refinement of and Grisu and Ryū are the fast approximations to.

Keep the value as an exact ratio of integers R / S, and keep alongside it the distance to the midpoint of each neighbouring float, M+ and M-. Scale until the value sits in [0.1, 1). Then, repeatedly: multiply R by ten, take the digit, and ask whether what remains is already closer to this float than to either neighbour. When it is, stop — the digits emitted so far are the shortest that round-trip.

The stopping rule is the whole algorithm. It is why the output is shortest rather than merely correct, and it is what Grisu2 cannot guarantee and falls back to Dragon4 for.

The integers involved reach 1080 bits (§4), so std.bignum is underneath — also written in cancho, also in this repository, and ninety lines rather than four hundred for the reason in §3 below.

3.1 The factors of two, and why the setup has four cases

A float is m * 2^e exactly. To make R, S, M+ and M- all integers, everything is scaled by a power of two:

RSM-M+
e >= 0, even neighboursm * 2^(e+1)22^e2^e
e >= 0, unevenm * 2^(e+2)42^e2^(e+1)
e < 0, even neighboursm * 22^(1-e)11
e < 0, unevenm * 42^(2-e)12

“Uneven” is the case every paper on this spends a paragraph on: at a power of two the gap to the float below is half the gap to the float above, because the mantissa just rolled over. A printer that ignores it is wrong on exactly the values a test suite is most likely to contain — 1.0, 0.5, 2.0, every 2^k — which is why the corpus in §5 contains all 2046 of them rather than a sample.

3.2 No division, which is why std.bignum is ninety lines

A digit is R / S for two bignums. That sounds like it needs bignum division, which is the single nastiest routine in an arbitrary-precision library — Knuth 4.3.1 algorithm D, normalisation, quotient estimation, the correction step.

It does not, because the digit is 0 to 9. Nine subtractions settle it:

var digit = 0;
while bignum.compare(r, s) >= 0 {
    bignum.subtract(r, s);
    digit = digit + 1;
}

std.bignum therefore has no division at all. It has compare, subtract, add_into, mul_small, shift_left, copy and set, all in-place over a fixed-length [int] of base-2³² limbs, all allocation-free — which is what lets the caller keep five of them in one region and never touch the heap.

Base 2³² rather than 2⁶³ for one reason: a limb has to survive being multiplied by ten. 10 * (2^32 - 1) is about 2^35.7 and fits; the same product in base 2⁶³ would trap on overflow.

3.3 The boundaries are inclusive for an even mantissa — a measured digit

Reading a decimal back uses round-to-nearest-even. So when the mantissa is even, a decimal sitting exactly on the midpoint between this float and its neighbour still reads back to this float: the tie goes to the even one, which is this one. The boundary is inclusive.

That turns four comparisons from < into <=, and it is not cosmetic. Without it, 5.299064834871378e16 printed as 5.2990648348713776e16 — seventeen digits where sixteen round-trip. Correct, and one digit too long, which is precisely the failure the shortest-printing contract exists to rule out. Two of the first 4000 corpus values caught it.

3.4 A dead-level tie, where two answers are both right

Once in a while the exact value sits exactly halfway between two shortest decimals. 2^-25 is one: its exact value is

0.0000000298023223876953125

and truncating to seventeen significant digits leaves exactly 5 with nothing after it. Both 2.9802322387695312e-8 and 2.9802322387695313e-8 are seventeen digits, and both read back to 2^-25. Neither is more correct.

So the rule is a choice, and the choice is observable:

2^-25 prints as
Rust’s {:e}2.9802322387695313e-8
Python’s repr2.9802322387695312e-8
cancho2.9802322387695312e-8 (was ...313e-8 until the tie rule below)

The first version of float_into followed Steele and White — round the tie up — which is what Rust does. Python rounds the tie to the even digit. Over the 9000-value corpus in §5 the two disagreed on three values, all of them exact ties, and every one of the six spellings round-trips.

That was a defect, not a choice, and it is fixed. A table tool that wrote floats and was checked against Python found the same double printing as two different strings depending on which tool wrote it (...86.125 is ...86.12 in Python’s repr and was ...86.13 here), which is the one thing a shortest printer is for: one answer. The rule now is the shortest decimal that reads back; among those the closest to the exact value; and an exact tie goes to the even last digit. That is a definition with one answer for every double, and it is the rule of Ryu, of Python’s repr, and of Java since 19. Only Rust’s {:e} (and the first version here) differs, and only on exact ties: 1,543 of 100,000 doubles of the check corpus (which is built to be tie-rich: j / 2^k with j odd and 17 or 18 digits ending in a 5) printed differently from Python before; 0 of 1,002,109 after. In the test corpus of §5 it is the same three values: 2^-25 prints 2.9802322387695312e-8 now.

This is worth a section rather than a footnote because it is the kind of thing a document usually asserts and nobody checks. Both rules were implemented and both were run against both oracles; the numbers above are measurements, not recollections.


4. 1080 bits, measured rather than bounded

The working numbers get large: near the bottom of the subnormals the numerator picks up a factor of 10³²³ on the way to the first digit, while the denominator is already 2¹⁰⁷⁶.

The first draft of this comment claimed “about 2200 bits”. Instrumenting the algorithm over the corpus says otherwise:

widest 1080 bits, at 4.3374988771064606e-308  →  34 limbs

So limbs() is 80 — more than double the widest observed, because the cost of the slack is one bump of an arena pointer and the cost of being wrong is a silent carry off the top. But the comment now says 1080, because the comment that said 2200 was made up.


5. Verified against an oracle

A printer is exactly the kind of code that passes every example someone thought of and fails on the 4000th random bit pattern. So it is checked against Rust’s {:e} over a generated corpus, in shortest_printing_agrees_with_an_oracle.

(Since the tie rule of §3.4 changed, the oracle is {:e} or, where they differ, the exact tie rounded to the even digit, decided from the exact value: is_rust_or_even_tie in float_text.rs.)

The test has a pleasant property: the expected output is the input. {:e} is the shortest round-tripping form, and the lexer’s f64::from_str is correctly rounded, so writing each value into the generated program as {:e} and expecting the program to print that same string back tests the digits and nothing else. A failure is a disagreement about digits, never about notation.

The corpus is 9000 values:

GroupCountWhy
Every normal power of two2046§3.1’s uneven-neighbour case, exhaustively, not sampled
Every subnormal power of two, and the top of each binade104Where the exponent stops being an exponent
Every power of ten in range616Where the decimal and binary grids line up worst
The ones with a reputation140.1, 1e23, 9.999999999999999e22, f64::MAX, 5e-324
Deterministic pseudo-random bit patternsthe restThe cases nobody thought of. splitmix64 with a fixed seed, so a failure reproduces

Plus the_values_with_no_literal_are_spelled for inf, -inf, NaN and -0.0, which have no literal syntax to generate and so are built from arithmetic inside the program.

At scale, against Python. scripts/float_differential.py prints 1,002,109 doubles through float_of_bits and float_into (random bit patterns, every power of two, the edges, and 100,000 built to be exact ties: j / 2^k with j odd and an exact expansion of 17 or 18 digits ending in a 5) and compares the digits and the exponent with Python’s repr, and that every string reads back to the same 64 bits: 0 differences; before the tie rule changed, 1,543 of the first 100,000. float_text.rs does the same against Rust’s {:e} for 220,000 doubles, accepting only an exact tie rounded to the even digit.

During development this corpus found two real defects: §3.3’s missing inclusive boundary, and §3.4’s tie rule. Both were one-line changes and neither would have been found by an example.


6. What it costs the caller

pub fn float_into[&o](out: &!o [byte], x: float) -> [] int

A [byte] to write into and the value. It answers how many bytes it wrote, or -1 if the buffer was too short — threaded rather than trapped, because the caller chose the buffer.

24 bytes is always enough: a sign, seventeen digits, a point, e, a sign and three exponent digits. The longest output there is is -1.7976931348623157e308, at 23.

No Heap. The five bignums and the digits live in a region, so the whole thing is one arena that dies at the closing brace, and a program with no heap capability can still print a float. That is not an optimisation — examples/newton.cho releases heap in its first three lines, and would not compile otherwise.


7. What is still open

QuestionWhy it waits
Positional notation§1.1. 0.1 as 0.1 rather than 1e-1 needs a switch-over rule, which is a formatting policy and not a digit question. It is a layer on top of this one, and cheap once wanted
Reading a float from bytesThe inverse. std.fmt writes; nothing parses a float. It needs the same bignum and the opposite loop, and no program has asked yet. (f32 has it: std.fmt32.f32_of_text, correctly rounded straight to binary32; f32.md §5.3)
A width or a precision{:.3} — round to a stated number of digits rather than the shortest. Different algorithm (the stopping rule goes away), same machinery. (Built for f32 as std.fmt32.f32_fixed_into, exact ties to even; f32.md §5.3)
Positional notation for f32Built in std.fmt32.f32_into as Rust’s {:?} (positional from 1e-4 to below 1e16); float_into is unchanged
float_of_bitsBuilt (edition 7, floating-point.md §4.1); it is how scripts/float_differential.py feeds a million exact bit patterns

8. The suite

TestRule§
shortest_printing_agrees_with_an_oracle9000 values print exactly what Rust’s {:e} prints1, 3, 5
the_values_with_no_literal_are_spelledinf, -inf, NaN, -0e01.1
a_short_buffer_is_refused_rather_than_overrun-1 rather than a trap or a partial line6
AcceptingShows
examples/newton.choThe residuals printed as floats rather than scaled through truncate — the thing this slice was for

sqrt, and the capability question it was waiting on

Status: settled and built, and the measurement is the argument.

floating-point.md §7 has carried this row since float landed:

std.math over floats — sqrt, sin, exp. Each is either a libc call, gated by Ffi, which would make arithmetic need a capability, or an implementation with its own error analysis. The capability question has to be settled first.

The capability question turns out to be the wrong question for sqrt, and answering the right one took measuring what the programs that hand-rolled it actually compute. One of them is wrong by 143 orders of magnitude.


1. Two programs asked, by writing it themselves

line-reading.md §1 set the rule and then enforced it against a library function that had one asker. This one has two, counted the same way — by reading them:

what it hand-rollswhy
examples/newton.choa square root by Newton’s method, five stepsthe program is the demonstration
benches/game/spectral.chosqrt_of, twenty stepsspectral-norm needs one, and the source says so

spectral.cho names the blocker in its own header: “sqrt is written here because floating-point.md §7 leaves std.math over floats open — the capability question comes first.”

Two askers, two different algorithms, and neither is right.


2. Both hand-rolled roots are wrong, and one is spectacularly wrong

sqrt_of against a correctly-rounded square root, over 40,008 values — the specials, twenty thousand uniform in 10⁻⁶…10⁶, and twenty thousand spread across the exponent range:

not correctly rounded23,362 — 58.4%
worst error2.15 × 10¹⁸ ulp

That last number is not a rounding error. It is a different answer:

xsqrt_of(x)correct
10¹⁰⁰4.768372 × 10⁹³10⁵⁰
10²⁰⁰4.768372 × 10¹⁹³10¹⁰⁰
10³⁰⁰4.768372 × 10²⁹³10¹⁵⁰

The first guess is x / 2, and a Newton step roughly halves the distance to the root, so twenty steps move 5 × 10²⁹⁹ down by a factor of 2²⁰ and stop — 143 orders of magnitude short. The function does not converge and does not say so.

Its comment reads: “Twenty is past the point where binary64 stops changing.” That is true of the inputs spectral-norm feeds it, which sit near 1.27, and false of the function, which takes any float and returns without complaint.

This is cut’s long line again (line-reading.md §2): a shipped program, checked against a published expected output, correct on every input anyone tried, and wrong on the dimension the test never varied. The benchmark’s answer is right — 1.274219991 — because it never asks for a root outside a narrow band. The function is wrong.

newton.cho is better and still not correct: five steps from a fixed guess of 2.0 gives √2 to 1 ulp. That is fine for what it demonstrates — it prints residuals and is about convergence — and it is not a square root anyone should call.


3. So the capability question was the wrong question

§7’s row assumed two options: a libc call gated by Ffi, or an implementation with its own error analysis. Neither is what sqrt is.

sqrt is one instruction: sqrtsd on x86-64, fsqrt on aarch64. Cranelift emits it directly. So:

  • It reaches no library, so there is no Ffi to gate, so arithmetic does not acquire a capability. The fear in §7’s row was real and does not apply here.
  • Its row is [] and cancho authority reports nothing, which is correct: a square root observes nothing outside the program.

And the error analysis option is closed by §2 rather than by taste: IEEE-754 requires sqrt to be correctly rounded, the instruction is, and no sequence of +, -, * and / in cancho reliably is — which the 58.4% measures.

3.1 Which is why this one is a builtin and printing is not

float-printing.md made the opposite call for the same-shaped question, and both calls are right for the same reason: put it where it can be correct.

Printing a float is a decision procedure — Steele and White over exact integers — and cancho can express it, so std.fmt.float_into is library code and the compiler’s whole contribution is bits_of, a bitcast (plus a select that gives every NaN one pattern, differential.md §4). A correctly-rounded square root is not expressible here, because the only correct implementation is an instruction. One goes in the library because it can; the other goes in the compiler because it cannot.


4. What is not in this slice

Not sin, exp, log or pow. Those are the half of §7’s row that is genuinely about error analysis: none is a single instruction, each is a routine with an argument reduction and a polynomial, and each would be library code with a stated accuracy. No program here has asked for one, and §1’s rule is the rule.

Corrected (§7 below). exp, log and pow are now built, once three programs asked for them by name. sin stays open — nothing has asked, and it needs its own range reduction, not the one the other three share.

Not float abs, min or max. One asker between them — newton.cho’s magnitude — and it has an alternative that works: if x < 0.0 { return -x; } is three lines and correct.

That is the same test line-reading.md §4 used to admit buffer.clear with one asker: the question is not how many programs want it but whether the ones that want it have a working alternative. clear had none — a buffer could not be reused at all. magnitude has one, so it stays in the program that needs it.

Not a std.math module for floats at all. sqrt is a builtin, so there is nothing to import and nothing to put in one. std.math stays integer-only until something earns a place beside it.

Corrected (§7). exp, log and pow earned that place, and std.math is where they went — the same module sqrt itself never needed to join.


5. What it fixed

beforeafter
benches/game/spectral.choits own 20-step Newton, 58.4% not correctly roundedsqrt, and the benchmark still answers 1.274219991
examples/newton.chokeeps its Newton loop — on purpose, it is the demonstrationprints sqrt’s answer beside its own, so the program now shows what five steps are worth

sqrt_agrees_with_the_hardware checks the builtin against Rust’s own f64::sqrt over the same 40,008 values, including the four exponents where sqrt_of was off by 10⁴³ and more.


6. Open

QuestionWhy it waits
exp, log, powBuilt — §7
sin (and cos)Built — §8, with its own reduction by quarter turns, to a stated domain of `
Float abs, min, maxBuilt as fabs/fmin/fmax — §8 says why the §1 rule was set aside
A total orderfloating-point.md §7’s other row, untouched here
sqrt of a negativeAnswers NaN, which is what the instruction does and what IEEE-754 says. Not a trap: floating-point.md §2.1 already settled that NaN announces the absence of a value rather than lying about one, and a square root of −1 is exactly that case

7. exp, log and pow, closed the way §6 said they would be

Superseded by §9. The algorithms and the accuracy figures below describe the first version — a Taylor series for exp, an atanh series for log, exp(y * log(x)) for pow — which was off by up to a hundred ulp. They are kept because §7.1’s bug and the reasoning behind the guards are still true; the numbers are not, and §9 has the measured ones. exp, log and pow are now fdlibm’s algorithms and a double-double product, within 1, 1 and 4 ulp of the C library.

Three askers, the two-per-half bar §1 already used, each wanting more than one of the three: examples/growth.cho (continuous and discrete compound growth, plus a doubling time — exp, pow and log in one program), examples/decay.cho (a half-life table, computed the differential-equation way and the definitional way side by side — exp and pow again, cross-checked against each other), examples/entropy.cho (Shannon entropy of standard input’s byte distribution — log, the third caller). Between them: log three askers, exp and pow two each.

Library code with a stated accuracy, exactly as §4 said it would be, in std/math.cho:

  • exp(x): range reduction to x = k*ln2 + r with |r| <= ln2/2 (ln2 split into a high part and a low residual, the standard technique, so k*ln2_hi loses no precision for the k this produces), a 14-term Taylor series for e^r, and pow2(k) — 2^k by exponentiation by squaring on ordinary multiplication — to rescale.
  • log(x): pull the unbiased binary exponent e out of x’s own bits with bits_of so x / pow2(e) is a mantissa m in [1, 2), then a 14-term series in y = (m-1)/(m+1).
  • pow(x, y): exp(y * log(x)) for x > 0, which is most of what a caller wants it for; x <= 0 gets its own cases, matching the two conventions C’s pow already settled rather than reinventing them.

None of the three needs bits_of’s other half — a builtin that builds a float back up from bits. (Corrected: it did not exist when this was written; float_of_bits is edition 7, floating-point.md §4.1, and these routines still do not use it.) Scaling by an integer power of two is exact under ordinary multiplication as long as it does not overflow, so pow2 gets there by squaring rather than by bit construction. The one place that bit missing, if it existed, would have simplified something: exp’s own scaling still had to split its exponent in half before multiplying (below), where a direct ldexp would not have.

Measured, not asserted: crates/cancho/tests/conformance/floats.rs checks all three against Rust’s own f64::exp/f64::ln/f64::powf over roughly 4,500 generated values, plus specials, within 1e-9 relative error — two orders of magnitude looser than what was actually measured while writing this (2.4e-14 worst case for exp, 6e-14 for log, away from where relative error stops meaning anything, the same caveat §2 already states for sqrt’s own tails). Not correctly rounded, and not claimed to be — §2 already found that unreachable for a hand-rolled sqrt, and nothing about exp/log/pow makes it more reachable.

7.1 The bug the differential test found

The first version of exp answered infinity for exp(709.5), which is finite (≈1.3549863 × 10³⁰⁸, comfortably under f64::MAX). The cause was pow2(k) computed as one call: at x = 709.5, k = 1024, and 2^1024 alone overflows a float even though sum * 2^1024 (sum always sitting in [0.5, 2)) would not have. Splitting the exponent — sum * pow2(k - k/2) * pow2(k/2) — keeps every intermediate value in range up to the true overflow point (≈709.7827) and reaches infinity correctly exactly there, through ordinary IEEE overflow rather than a guard. log’s own pow2(e) call never hits this: a normal float’s unbiased exponent never reaches 1024, only an infinite input’s raw bits do, and that is caught earlier by an explicit check.

The other two guards each answer a different hazard than the arithmetic does: is_nan(x) in both exp and log, because truncate — which both use internally, to round to the nearest integer k or to check whether y is a whole number in pow — traps on NaN (docs/floating-point.md §4), and a library function should not trap on an input its own domain does not exclude. exp’s |x| > 750 guard and log’s x > f64::MAX guard exist for the same reason, one step further out: an infinite x would also send x / ln2 or x / pow2(e) somewhere truncate traps on.


8. fabs, fmin, fmax, floor, ceil, round, sin, cos

Not asked for by a program. Added anyway, and the reason is the change. §1’s rule — a function is earned when programs write it for themselves — was the right rule while the question was what a handful of example programs needed. The question this repository is answering now is what an agent writing a program here would reach for and not find, and rounding to a whole number is the first thing on that list: floor and ceil are what every “how many pages” and “which bucket” sum is written with, and the only way to get one before this was float_of(truncate(x)), which is wrong for every negative non-integer (it rounds toward zero) and traps near 2⁶³. §4 declined abs/min/max because if x < 0.0 { return -x; } is three lines; that stays true, and it is also three lines every caller gets subtly wrong at -0.0 and NaN, which is what a library function is for.

All eight are library code in std/math.cho, not builtins — a few lines of float and truncate arithmetic each, like exp/log/pow.

  • fabs: C’s. fabs(-0.0) is +0.0, a NaN stays a NaN.
  • fmin, fmax: C’s. A NaN is missing data — the other argument is the answer, and the answer is NaN only if both are.
  • floor, ceil, round: exact, not approximate. For |x| < 2⁵² the distance x - truncate(x) is computed without rounding, so the three compare it against zero or one half directly. Every float at or past 2⁵² is already whole and is returned as it is, which keeps truncate away from its own trap near 2⁶³; NaN and the infinities come back unchanged. round is ties-away-from-zero, as C’s; the one-line floor(x + 0.5) is wrong at 0.49999999999999994, where the addition itself rounds up to 1.0, and tests/accept/math_floats.cho pins that case. One difference from C: a zero result is +0.0 where C’s floor(-0.0) keeps the minus sign, which only bits_of can see.
  • sin, cos: fdlibm’s two kernel polynomials on [-π/4, π/4] (minimax coefficients, chosen to minimise the worst error over the interval, not a Taylor series’s), and an argument reduction by quarter turns, with π/2 subtracted in three 33-bit pieces so that k × piece is exact for |k| < 2²⁰. That bound is the domain: |x| ≤ 10⁶; outside it, it traps. Corrected, §10.2: that was the first version. Past |x| = 10⁶ it now uses Payne–Hanek reduction and answers for every finite x; only an infinity, which has no sine, gives NaN. A NaN argument is answered with that NaN.

Measured against the C library, not asserted. A foreign signature cannot carry a float (opaque-pointers.md), so libm cannot be called from a cancho program; the comparison is made from outside. tests/programs/math_samples.cho prints the bit pattern of a function’s answers at a fixed sample, conformance/mathfn.rs replays the same arguments (a fixed linear-congruential sequence, every step exact or correctly rounded, so Rust and cancho compute the same argument bit for bit) and measures the distance from glibc’s answer in units in the last place. The first measurement used 20,000 arguments per range; it is now the same harness as §9’s, 10,000 per range, and the worst error is 1 ulp on [-10³, 10³] and below and on both neighbourhoods of a multiple of π/2, and 2 ulp on [-10⁶, 10⁶] — sin and cos alike, between 8% and 28% of answers differing from libm’s at all (and none for cos near a zero of cos, where the answer is exactly the reduced argument).

Not correctly rounded, and not claimed to be — up to one answer in four is one float away from the library’s, which is the usual state of a small hand-written sin and is what a test bound of “within a few ulp” is for. The test allows one ulp more than measured, because the reference is itself a library and another platform’s rounds differently from glibc’s in the last place.

8.1 What this does not do

  • No tan, atan, atan2, asin, acos. Built — §10.
  • No reduction past 10⁶. Built — §10.2.
  • No π constant. 3.141592653589793 is what a caller writes.

9. exp, log and pow fixed; expm1, log1p, log2, log10 and the hyperbolics added

§7 shipped exp, log and pow with an accuracy stated honestly as “2.4e-14 relative” and “6e-14”. That is a hundred ulp, and the first thing measured against the C library in the unit that matters (ulps, not a relative tolerance two orders of magnitude loose, which is what floats.rs checks) was worse than the figure suggested for pow: 60 ulp at 5e5 ^ 3.7, and 275 at 1.7 ^ -552. A program that computes pow(x, 2.0) and compares it to x * x would not have noticed; one that compares pow(7.0, 2.0) to 49.0 would, because it was not 49.

9.1 What changed

  • exp is fdlibm’s: reduction by ln2 in two pieces, a degree-4 minimax polynomial in a rational form, scaling by 2^k. 1 ulp.
  • log is fdlibm’s: the exponent out of the float’s own bits, a mantissa in [√2/2, √2], a degree-14 minimax polynomial in s² with s = f/(2+f). 1 ulp, including near x == 1, where the series it replaces had an absolute floor of 1e-12, and for subnormal x, which the old one got wrong: log(5e-324) is -744.4400719213812, to the bit. (A subnormal is scaled by 2^54 first, which is exact.)
  • pow carries y * log(x) as a pair of floats. A relative error d in the exponent is a relative error d in the result, and the exponent reaches 700, so rounding it to one float was the whole error. log(x) is kept as hi + lo (two_sum keeps what k * ln2 loses) and the product through two_product, Dekker’s, which needs no fused multiply-add. What remains is log’s own error times y — about y/3 ulp, small for the exponents programs use. Four cases are answered exactly rather than approximately: y == 1 (x), y == 2 (x * x), y == -1 (1 / x) and y == 0.5 (sqrt); and a whole exponent whose power is representable is computed by repeated squaring with every multiplication checked for exactness, so pow(7.0, 2.0) == 49.0, pow(10.0, 15.0) == 1e15 and pow(2.0, 100.0) are exact, as C’s are. Infinite exponents and bases and the overflow and underflow edges are settled before the pair is formed (pow(1.0, inf) is 1, as C says; it was NaN).
  • New: expm1, log1p (Kahan’s identities over the new exp/log: u = exp(x), (u - 1) * x / log(u), in which u’s rounding error cancels), log2 and log10 (k + log(m)/ln2, so log2 of a power of two is exact, which log(x)/ln2 is not; log10 with log10(2) in two pieces), and sinh, cosh, tanh, asinh, acosh, atanh — fdlibm’s, built on expm1/log1p because (e^x - e^-x)/2 and log(x + √(x²-1)) lose the whole answer to cancellation for small arguments.

9.2 Measured

conformance/mathfn.rs replays a fixed sample (a linear-congruential sequence, exact at every step, so Rust and cancho agree on every argument bit for bit — tests/programs/math_samples.cho prints the answers’ bit patterns, one process per function and range) and measures the distance from glibc’s answer in ulps. 10,000 arguments per range, three to six ranges per function, chosen to include the places each is hard: near zero, near 1, the overflow and underflow edges, and 5e299.

functionrangesworst erroranswers that differ from libm at all
exp±1, ±10, ±700, ±1e-3, ±1e-9, [-745, -695]1 ulp0 – 9.7%
log[0.5, 1.5], [1, 100], [0, 1e6], 1 ± 1e-3, (0, 1e-3), up to 1e3001 ulp0 – 6.4%
expm1±1, ±10, ±30, ±1e-3, ±1e-92 ulp19 – 31%
log1p±0.999, (0, 1e-3), ±1e-9, [1, 100], [0, 1e6]2 ulp8 – 38%
log2as log1 ulp0 – 28%
log10as log2 ulp0 – 16%
sinh±1, ±10, ±30, ±1e-3, ±1e-9, [0, 710]3 ulp0 – 38%
cosh±1, ±10, ±30, ±1e-3, [0, 710]2 ulp0 – 9.5%
tanh±1, ±10, ±30, ±1e-3, ±1e-93 ulp0.8 – 25%
asinh±1, ±10, ±1e6, ±1e-3, ±1e-9, up to 1e3002 ulp0 – 37%
acosh[1, 3], [1, 100], [0, 1e6], up to 1e3002 ulp0 – 14%
atanh±0.999, ±1e-3, ±1e-9, ±12 ulp0 – 38%
pow(x, 3.7)[0.5, 1.5], [1, 100], [0, 1e6]2 ulp (was 60)31 – 40%
pow(x, 0.3)same2 ulp4.5 – 38%
pow(1.7, x)±1, ±700, ±1e-34 ulp (was 275)0 – 78%
pow(x, 2)same as pow(x, 3.7)1 ulp0.1 – 0.6%
pow(x, 7)[0.5, 1.5], [1, 100]3 ulp43 – 49%
pow(x, -3)same as pow(x, 3.7)2 ulp28 – 39%

Not correctly rounded, and not claimed to be. expm1/log1p are Kahan’s identities, not fdlibm’s long rational approximations, which is why they sit at 2 ulp where exp/log sit at 1; the hyperbolics inherit that through expm1. The test bounds are the measured worst plus one, because the reference is itself a library: another platform’s rounds differently in the last place. pow(1.7, x) at x = ±700 is the case the y/3-ulp rule is about, and 4 ulp is what it costs.

tests/accept/math_floats.cho holds the claims that are not about ulps: exact powers, log2 of a power of two, log10 of a power of ten, specials (NaN, ±0, ±inf, the overflow and underflow edges) for every function, and the inverse pairs.

9.3 What this still does not do

  • No tan, atan, atan2, asin, acos. atan2 is the one most likely to be asked for next; each is its own reduction and its own table.
  • pow is not fdlibm’s. fdlibm computes log2 to about 68 bits internally, which gives < 1 ulp for every y; this gets there for |y| up to a few dozen and degrades as y/3 ulp beyond. Closing it is a longer log_pair, not a new design.
  • A subnormal result is rounded twice (once in exp, once by the scaling), so the last place can be a ulp off in that range. The [-745, -695] row above is it: still 1 ulp, but it is not the whole range’s guarantee.
  • floats.rs’s old sweep still checks exp, log and pow to 1e-9 relative, which is what §7 claimed. It is a floor under the ulp table, not the claim: that is mathfn.rs.

10. The rest of the trigonometric family, and a reduction that has no limit

§8 shipped sin and cos with two stated gaps: no tan/atan/ atan2/asin/acos, and a trap for |x| > 10⁶. A trap is the honest answer to “there is a right answer and this does not compute it”, and it is still a hole: sin(1e22) is a perfectly good question with a well-known answer (-0.8522008497671888), and an agent that computes a phase from a timestamp times a frequency will reach it.

10.1 The functions

  • atan, asin, acos: fdlibm’s — a minimax rational/odd polynomial, and the half-angle identities that keep 1 - x from cancelling near 1. asin and acos take |x| ≤ 1 and answer NaN beyond; atan takes any float, and is ±π/2 to the last bit from 2⁶⁶ up. fdlibm clears the low 32 bits of a float to get a “high half” of a square root; there is no way to write one here, so this clears the low 27 by Veltkamp’s split (pow already needed it), which does the same job.
  • atan2(y, x), argument order as in C: every case C settles is settled the same way — the sign of a zero decides the half-plane (atan2(+0, -1) = π, atan2(-0, -1) = -π; told apart by bits_of, because x < 0.0 cannot), an infinity gives one of eight multiples of π/4, and otherwise atan(|y/x|) is moved to the quadrant the two signs name, with π in two pieces so the subtraction does not round. A ratio past 2⁶⁰ is π/2 outright. (The first version fell through to π - (π/2 - tiny) there and was a float off for every x < 0, which 5e299’s sweep showed as 10,000 of 10,000 differing.)
  • tan: sin(r)/cos(r) on the same reduced argument, or -cos(r)/sin(r) in an odd quarter turn. A quotient of two answers each good to a fraction of an ulp is good to about two; fdlibm’s own __kernel_tan gets one by a longer path, which is a separate piece of work and is listed in §10.4.

10.2 Payne–Hanek, and what it took

remainder_of subtracts k·π/2 in three 33-bit pieces, exact while k < 2²⁰. Past that, the product x · 2/π has to be taken with enough bits of 2/π that its fractional part survives: for the worst-case double (6381956970095103 × 2⁷⁹⁷) the fraction starts 62 zero bits down, so the reduction needs 115+ bits after them.

reduce_large does it in base 2²⁴ so that every partial product fits an int: x = mant × 2^e is four 24-bit limbs, 2/π is a 52-limb static table, 1,248 bits, generated here with integer arithmetic (Machin’s formula; its first nine limbs match fdlibm’s ipio2), and the limb of the product at each weight is a four-term sum plus a carry. Limbs of weight 2²⁴ and up are multiples of 4 and are never computed; limbs below 2⁻¹⁹² are dropped. Limb 0’s low two bits are the quadrant; limbs -1..-8 are the fraction. If the fraction is a half or more, the quadrant is rounded up and the remainder is the negative complement, formed from the limbs by subtraction with a borrow — not 1 - f in floating point, which would throw away exactly the bits the rest of this was done to keep. The limbs become a float by Horner’s rule from the low limb up, so a fraction with leading zero limbs still gets all 53 bits of those below.

It answers for every finite x. An infinity has no sine; it is NaN, as sqrt(-1) is, which is the trap’s replacement: a trap was for “right answer exists, not computed”, and now it is computed.

10.3 Measured

Same harness as §9 (conformance/mathfn.rs, 10,000 arguments per range, ulps against glibc’s own functions). The sweeps cover the old domain, the new one ([0, 10⁹], ±10¹⁵, [0, 10³⁰⁰]), and the neighbourhoods of multiples of π/2 where reduction cancels the most.

functionrangesworst erroranswers that differ from libm at all
sin±1 … ±10⁶, near 100·π/2 and 10⁵·π/2, [0, 10⁹], ±10¹⁵, [0, 10³⁰⁰]2 ulp (1 below 10⁶)8 – 28%
cossame2 ulp (1 below 10⁶)0 – 24%
tansame, and π/2 ± 10⁻³4 ulp (2 below 10⁶)36 – 52%
asin±0.999, ±1, ±10⁻⁹, [0.981, 0.999]1 ulp0 – 6%
acossame1 ulp0 – 8%
atan±1, ±10, ±10⁶, ±10⁻⁹, up to 10³⁰⁰1 ulp0 – 6%
atan2(x, 1.7)±1, ±10, ±10⁶, up to 10³⁰⁰1 ulp0 – 24%
atan2(1.7, x)±1, ±10, ±10⁻³, ±10⁻⁹1 ulp16 – 24%
atan2(x, -0.9)±1, ±10, ±10⁶, up to 10³⁰⁰1 ulp0 – 45%

The worst case. x = 6381956970095103 × 2⁷⁹⁷ is the double whose cos is closest to zero (-4.687165924254627611…e-19, 62 bits down). It is pinned in tests/accept/math_floats.cho against that literature value, to 3e-16 relative. The C library on the machine this was measured on (through Python’s math.cos) answers -4.68716592425462e-19 for it — 8 ulp off the true value, which this reduction gets to the digit. It is in no sweep because a random sample will not find it; it is exactly why the reduction keeps 115 bits.

10.4 What this still does not do

  • tan is 2 – 4 ulp, not 1. fdlibm’s __kernel_tan (a degree-13 polynomial with a reciprocal correction for |x| > 0.67) is the one to port if a program needs better.
  • sin/cos are 2 ulp past 10⁶, 1 below it: the reduced argument is one float, so its own rounding is carried into the result. Carrying the tail through the kernels (fdlibm’s __kernel_sin(x, y)) would cost the 2 back.
  • No sincos, sinpi/cospi, atanpi, hypot, cbrt. Not asked for, and the three *pi forms are the better answer to “an angle in turns” than a large argument to sin.

Flags

Status: settled and built.

ROADMAP.md has carried “flag parsing” under ordinary work, blocked by nothing for several slices, on the assumption that the two programs that parse flags would each be a little shorter with a library. That is not what the probe found. They are not too long. They are wrong, in the same way, and one of them is wrong silently. std.flags exists because the fix is the same fix twice, and because a third program would have written the same guess again.


1. Six of twelve spellings

porting.md calls examples/base64/ and examples/cut/ ports of the GNU programs, “checked against the program it ports”. Run each spelling GNU accepts through both:

GNUours
cut -d, -f2bb
cut -f2 -d,bb
cut -d, -f1,3a,ca,c
cut -d , -f2busage, exit 1differs
cut -d, -f 2busage, exit 1differs
cut --delimiter=, --fields=2busage, exit 1differs
cut -d, -f2 --busage, exit 1differs
base64 -dhihi
base64 -d -ihihi
base64 --YUdrPQo=YUdrPQo=
base64 --decodehiYUdrPQo=differs, silently
base64 -dihiYUdrPQo=differs, silently

Every row above is the state before this slice. After it, every row agrees except the three §4 keeps on purpose, and both_ports_match_gnu_on_every_spelling checks all eighteen against the real coreutils binaries on every commit.

cut fails loudly: an unrecognised argument sets its usage flag and it exits 1 with a message. That is a bad port and an honest program.

base64 fails silently. --decode is not two bytes, so the test len(flag) == 2 && flag[0] == '-' && flag[1] == 'd' is false, the flag is ignored, and the program encodes its input and exits 0 — the answer to a question nobody asked, indistinguishable from success. That is the failure mode this language exists to remove, arrived at through the one part of a program no type system was watching.

1.1 And the tests passed

Every base64 conformance test invokes the program with exactly -d. Twelve input sizes, both directions, three malformed inputs, checked byte for byte against GNU — through one spelling of one flag. The same shape line-reading.md §1 found in cut, where the dimension the test never varied was the length of a line; here it is the spelling of an argument.


2. What the syntax actually is

Argument syntax is a specification, not a convention, and the reason both programs got it wrong is that each reimplemented a guess at it. What GNU accepts, and what std.flags therefore has to:

-d              a short flag
-di             bundled: -d then -i
-d,             a short flag's value, attached
-d ,            a short flag's value, as the next argument
--decode        a long flag
--delimiter=,   a long flag's value, after `=`
--delimiter ,   a long flag's value, as the next argument
--              everything after this is positional
-               a positional (conventionally standard input)
file.csv        a positional

Nine shapes. base64 implements one of them and cut implements two.


3. A cursor, not a table

The obvious design is getopt_long’s: hand the parser a table of options and let it drive. It is the wrong shape here, for a reason worth writing down rather than re-deriving.

A table has to say which options take values, which needs a slice of { name, letter, takes_value } — expressible — and then, to be GNU, an abbreviation resolver: cut --del=, works because --del is an unambiguous prefix of one entry. That is a second search over the table and a third failure mode, for two callers.

So the parser answers one step at a time and the program says when it wants a value:

pub enum Step {
    // A short flag's letter: `-d` gives 'd'.
    Short(int),
    // A long flag's name, without the dashes and without any `=value`.
    Long(&static [byte]),
    // An operand: a file, a `-`, or anything after `--`.
    Operand(&static [byte]),
    Done,
}

Driven by a Cursor, which is a val struct of three integers — the argument index, the offset inside a bundled run, and whether -- has been seen — so it copies, needs no region and allocates nothing:

var c = flags.start();
borrow args as &g in {
    var going = true;
    while going {
        let (next, step) = flags.step(g, c);
        c = next;
        match step {
            Step::Short(letter) => { ... }
            Step::Long(name) => { ... }
            Step::Operand(text) => { ... }
            Step::Done => { going = false; }
        }
    }
}

The pair-returning shape is examples/sort/’s (grown, got) again: no &! on the cursor, because a val that is copied and reassigned is simpler than a reference that has to be borrowed through a match.

A value is asked for, never guessed:

let (after, value) = flags.value(g, c);

taking the rest of the current argument (-d, → ,, --delimiter=, → ,) or, if there is none, the whole next argument (-d ,). It answers an empty slice when the arguments run out, which is the one case a program has to check.

That is the whole interface: start, step, value. A program that never calls value sees every letter as a flag, which is what base64 wants; one that calls it after 'd' sees -d, and -d , alike, which is what cut wants.


4. What it does not do, and one of them is a divergence

  • No abbreviation. cut --del=, works in GNU and is refused here. §3 is why: resolving it needs the table this design does not have. This is the one place the ports stay unfaithful, and it is now written down rather than discovered by someone’s shell history.
  • No option after an operand, ever — but neither program needs it, and GNU’s own permutation is a POSIXLY_CORRECT switch away from not happening either. cut -f2 file -d, is refused here and accepted by GNU.
  • No +flag, no optional-argument options, no --flag= meaning “present but empty” distinct from absent.
  • base64 -i and -w are now refused, where before this slice they were accepted and ignored. Neither is implementable in this port — the decoder refuses garbage and the wrap is fixed at 76 — so accepting one is §1’s silent lie in a second place. Refusing is a divergence from GNU that a user sees; ignoring is one they do not. Implementing -i is a change to the decoder and wants its own tests against GNU on garbage input, which is why it is not here.
  • Nothing about what a flag means. flags reports the shape of an argument. Whether -d is legal, required, or contradicts -f is the program’s, and stays in the program.

5. The suite

TestShows
both_ports_match_gnu_on_every_spellingeighteen rows, stdout and exit status, including the three §4 keeps as divergences — asserted to still diverge, so a row that quietly starts agreeing is moved rather than forgotten
every_argument_shape§2’s nine shapes, read back as themselves
tests/accept/flags.chothe fixture, which is the driver every_argument_shape runs: §2’s table and the program that prints it are one file

5.1 The expectation is written down, and GNU is a second opinion

That test began by reading the answer off /usr/bin/cut, and went red on darwin: macOS ships BSD cut, which has no --delimiter at all, so the comparison was against the wrong specification on one of the two supported targets. The base64 test in the same file already says this — “macOS ships BSD base64, which prints a newline for empty input where GNU prints nothing” — and the fact cost a red build before it was applied here.

So the table carries GNU’s answer and that is the assertion, on every target. Where the reference is GNU — probed with --version, which BSD’s does not have — it is checked against the same table, so a wrong expectation fails on linux rather than being believed everywhere. The same rewrite caught two: base64 -d answers hi with no trailing newline, and the first version of this table had one, because a shell $(...) had eaten it.

A line reader: measured, and the answer is no

Status: a documented no, with one function that was earned anyway.

ROADMAP.md carried a row asking for std.lines, on the grounds that “examples/cut/ and examples/tally.cho both read getchar into a fixed buffer, and two programs hand-rolling the same loop is how the last four library functions were found.”

tally.cho has no buffer. It streams — one byte in, counters up, nothing kept. The row was written from a memory of what those programs do rather than from reading them, and reading them says the bar this repository sets was never met.

Writing it down anyway, because the row pointed at something real: examples/cut/ was silently wrong on a long line, in a way worse than a length limit.


1. Counting the askers, by reading them

Five programs call getchar. What each does with it:

readskeeps
tally.choa byte at a timenothing — counters only
wordcount.choa byte at a timenothing; its one alloc_slice is a needle
base64.choa byte at a timean output buffer, 4096, not input
sort.choa byte at a timethe whole input, growing on the heap
cut.choa byte at a timeone line, and it was the only one

So the shape while c >= 0 { … c = getchar(io) } is in five programs and means something different in every one. What is not in five programs, or two, is “read until a newline into a buffer”: one program does that, and a library function needs a second asker.

standard-library.md’s rule is a function earns its way in when a program asks for it, and the last nine arrived that way. The rule is only worth anything if the count is taken by reading the programs.


2. What the row was pointing at, and it is worse than a limit

examples/cut/ read a line into a 60,000-byte arena slice — what fits beside a 1,025-byte field bitmap in one 64 KiB chunk — and dropped everything past it. The source said “a longer line is truncated”, as though that were the whole story.

It is not. A truncated line loses its delimiters, and cut without -s passes a line with no delimiter through whole. So the program did not merely lose the tail of a field — it switched to a different rule:

first fieldGNU cut -d, -f2this program
59,998 bytessecondsa field, cut short
59,999 bytessecond(empty)a field, gone
60,000 bytessecond60,001 bytes of athe wrong rule
70,000 bytessecond60,001 bytes of a

Every row exits 0.

That is a silently wrong answer, which is the one thing defined-behaviour.md §2.1 says this language exists to refuse — in a program shipped as an example and checked against GNU on eight field specs, none of which had a long line.

The suite was not weak; it was short. Eight specs is a lot of coverage of which fields, and none of how long a line. A test that varies one dimension is silent about the others by construction, and that is not a fact about this suite in particular.


3. The fix, and what it costs

cut now grows on the heap, the way sort.cho already did. There is no limit left to get wrong, and it matches GNU at every boundary above.

Measured on 14.7 MB, 400,000 lines, median of seven:

arena, 60 KiB limit (wrong)152.9 ms
heap, growing (correct)181.1 ms1.18×
GNU cut38.1 ms

Correctness costs 18%, and it buys an answer that is right on every input rather than on every input anyone happened to test.

3.1 It also costs authority, and that is the right way round

cut released its Heap before; now it holds one. Its report gains heap, and cancho authority says so.

bulk-io.md §3.2’s rule is a program must not look more powerful for having been written better. This is that rule meeting its mirror image and not contradicting it: the program is not better-written, it is correct, and correctness here genuinely needs an allocator. A row that stayed at [] while the program grew a buffer would be the lie.


4. The one function that was earned: buffer.clear

A program reading a line at a time has to reuse its buffer, and std.buffer had no way to move used back — filled only goes forward. So the alternative was drop plus empty on every line:

buffer.clear, keeping the allocation181.1 ms
drop + empty per line1,984.7 ms13.0×

Thirteen times, on the same input. That is not a convenience being argued for on taste — it is the difference between reusing an allocation and making 400,000 of them, and it is why clear is a gap in the library rather than a nicety.

One asker, which is below the bar §1 just enforced against read_line. The difference is that read_line had an alternative that worked, and clear did not: cut could not reuse a buffer at all.


5. What would change the answer on read_line

A second program that reads a line at a time. wordcount.cho and tally.cho will not become it — they stream on purpose, and a line reader would make them slower and no clearer.

The candidates are the ports not yet written: anything with records, uniq, paste, a CSV reader. When one of them arrives and writes the same loop cut now has, the function is earned and its shape is already known — cut’s loop, with the buffer lent in.

Until then this is a no, and the cheapest way to keep it honest is that cut’s loop is eleven lines and visible.


6. Open

QuestionWhy it waits
read_line in std§5. One asker, and the bar is two
A test that varies line length, not only field spec§2’s real lesson. cut_reports_a_long_line_like_gnu_cut covers this program; whether the other ports have a dimension nobody varied is a question this document raises and does not answer
buffer.truncate(n)clear is truncate(0) and is what a program asked for. The general one has no asker, and §1 is about what that is worth

Bulk output

Status: settled and built.

benchmarks-game.md §2.1 parked this: “io.write_all is one putchar per byte, so [fasta and reverse-complement] would mostly measure a libc call per character. That is a real finding and a separate slice.”

It is worth 12.8× on output, and the interesting part is not the number. The fast path existed all along and needed more authority than the slow one — which is backwards for a language whose whole argument is that authority is the thing you count.


1. The measurement

Eight megabytes of output, sixty-four bytes at a time, to /dev/null, fifteen runs:

minmedian
cancho, io.write_all (one putchar per byte)40.5 ms42.3 ms
C, putchar per byte32.5 ms33.7 ms
C, fwrite per line2.8 ms3.0 ms

Two things fall out of those three rows, and they point in different directions:

  • Per-byte output costs 11× in C too. 33.7 ms against 3.0 ms is libc’s own per-call overhead, not anything cancho does. A language that emits one putchar per byte is paying what C pays for the same shape.
  • The 42 against 34 is the ordinary backend gap — 1.25×, inside benchmarks-game.md §1’s range and unremarkable.

So the cost is not that cancho writes badly. It is that cancho could not write any other way.

And it shows up in a real program. examples/base64/ encoding 4 MB:

minmedian
cancho54.3 ms55.1 ms
GNU coreutils base645.8 ms5.9 ms

9.3×, on a program whose actual work is a shift and a table lookup.


2. The authority asymmetry, which is the actual bug

examples/serve/serve.cho already does bulk output:

extern fn write[&f, &b](ffi: &f Ffi("libc"), fd: int, buf: &b [byte])
    -> [ffi("libc")] int;

A &r [byte] crosses the foreign boundary as a pointer and a length (reach.md §3), so libc’s write takes a whole slice and always could.

Which means the fast path was reachable, and it cost Ffi("libc"). reach.md §5 is explicit about what that grant is worth: a library is not an authority domain, so Ffi("libc") is every authority at once — the filesystem, the network, exec, all of it.

So a program that wanted to print quickly had to ask for everything, and a program that asked only for Io was held to one byte per call. The incentive ran exactly the wrong way: the cheap thing to grant was the expensive thing to run. A capability language that charges more authority for better performance is teaching the wrong lesson every time someone profiles.

That is the argument for fixing it in the primitive rather than in the library. No amount of std.io cleverness helps: a function holding &!i Io can only call what Io authorises, and until this slice the only thing it authorised was one byte.


3. The rule

write_bytes[&i, &b](io: &!i Io, bytes: &b [byte]) -> [io_write] int

write_bytes rather than write, because a builtin’s name is taken from every module at once: std.buffer already had a write, and write is a builtin and cannot be redefined is what the first build said. That is a real cost of reaching for a primitive rather than a library function, and it is worth paying here for §2’s reason and worth noticing every other time.

The same capability as putchar, the same effect label, the same erasure — the Io is not passed to libc and has no runtime representation. It answers how many bytes it wrote.

The IR’s own comment on putchar said this was coming:

“M0/M1 scaffolding: putchar is how a program produces output before there is any FFI.”

It was half right. What replaced it is not a capability-gated foreign call — that is what serve.cho had, and §2 is why it was the wrong shape. It is a second primitive behind the same capability, which changes what a grant of Io is worth without changing what it permits.

3.1 putchar stays

It is one byte, it is what getchar mirrors, and a parser emitting a single delimiter should not have to build a slice to do it. docs/strings.md §2’s rule that a byte has no arithmetic is the reason putchar takes an int, and none of that changes.

3.2 Nothing about the effect row changes

write_bytes performs io_write, exactly as putchar does. A program’s authority report says the same thing before and after — byte for byte, which is what the test checks (authority.md §2): a faster program must not be a more powerful one.

foreign_symbols stays empty, which is the half worth stating out loud. write_bytes does reach libc, but the program neither declared that call nor gets to choose it: it is the builtin’s implementation, exactly as putchar has always been libc’s putchar. Naming fwrite in the report would ask a reader to audit something they cannot influence, and would make the report’s foreign list mean two different things at once. What foreign_symbols lists is extern declarations — the calls a program went out and asked for.

3.3 The input side stays open, on purpose

getchar is still one byte in, and there is no read(io, into) here. The reason is standard-input.md §3.1’s unfinished business: getchar answers -1 at end of input, and a bulk read has to distinguish short read, end of input and error — which is the same three-way question filesystem.md §3 defers for fs_read and porting.md §9.1 put a real program behind.

Output has no such question: it wrote the bytes or the process is gone. So the two sides are not symmetric and pretending otherwise would ship the harder one half-answered.

Corrected (agent-toolbox.md §2.1). “it wrote the bytes or the process is gone” is false for a stream that fails. write_bytes is fwrite on the C stdout stream (crates/cancho-codegen-llvm/src/body/expr.rs), which is buffered, and the count it answers is what was handed to the buffer, not what reached the file. Measured with the prebuilt compiler: a program that writes hello\n through io.write_all is answered 6 and exits with its own status when standard output is closed (>&-) and when it is /dev/full, where strace shows the one write failing with ENOSPC at exit and nothing looking at the result. (Once the stdio buffer fills, a failing flush does surface, as a short count: 1,000 sixty-five-byte writes saw 15 short to /dev/full. So the failure is detectable per call, except for the last buffer’s worth.) So output does have a failure outcome, it is unobservable from the program, and a tool that reports success after a failed flush is the silent-wrong answer this language exists to remove. The same buffer is why a trap loses everything not yet flushed (standard-error.md §3.3 measures the same fact from the other side). Not fixed here: it is a language gap, and agent-toolbox.md §2.1 carries the reproducer and the slice that would close it.

Now observable (checked-output.md, #215). flush_out(io) (edition 5) flushes standard output and answers Done::Failed(errno) when the flush fails or when any earlier write to the stream failed (ENOSPC on /dev/full, EBADF on a closed descriptor, EIO for a failure the buffer already discarded), on both backends. write_bytes still answers the buffered count, and a program that never calls flush_out still loses the result at exit; a trap still loses the buffer.


4. What it is worth

Re-running §1 with std.io.write_all on the new primitive, same machine, same runs:

beforeafterC / coreutils
8 MB of output42.3 ms3.3 ms3.0 ms
examples/base64/ encode, 4 MB55.1 ms34.7 ms5.9 ms

Medians. The first row is the primitive on its own: 12.8× faster, and within 1.1× of C’s fwrite on the same loop. Writing is no longer a thing cancho is slow at.

The second row is why the first one is not the headline. base64 got 1.6× faster, not 12.8×, and the missing time is the buffer. encode does not write straight out any more: it fills a 4 KiB slice and flushes it, so every byte it produces is now a bounds-checked store instead of a call.

That store is not free. Running §1’s loop a third way — same 8 MB, same one fwrite per 64 bytes, but filling the line byte by byte from another slice each time rather than writing a prepared one:

median
cancho, prepared line3.0 ms
cancho, filled byte by byte9.2 ms
C, filled byte by byte3.0 ms

So 8 M bounds-checked byte stores cost 6 ms where C’s cost nothing measurable — cc -O2 turns a 63-byte copy into vector moves and cancho emits the loop. base64 buffers 5.4 MB, so about 4 ms of what the bulk write saved went straight back into filling the buffer. That is a real cost and it is still a fifth of what the calls cost.

So the honest shape of this slice:

  • The thing §2 called a bug was a bug, and it is fixed: no program has to buy Ffi("libc") to print at a reasonable speed any more.
  • The 12.8× is real for a loop that does nothing but write. What a real program gets is §4.1, and it is much less.
  • A program that also computes gets the output part only, minus what the buffer costs. base64 is still 5.9× off coreutils afterwards, which is now a question about the encode loop rather than about printing: benchmarks-game.md §3’s question, not this document’s.

4.1 A correction: volume written is not time spent writing

An earlier version of this section said the 12.8× “is real for output. A program whose time is output — fasta, reverse-complement, anything that formats more than it computes — gets most of it.”

examples/sort/ falsifies that, and it is the fairest test available: it already wrote through io.write_all, so it moved onto the bulk path with no change to the program at all. Sorting 9 MB and writing all 9 MB back out, medians of nine:

median
per-byte write_all (pre-#54)438.9 ms
bulk write_all360.1 ms
GNU sort62.6 ms

1.22×. A program that writes nine megabytes — as output-heavy by volume as anything here — got a fifth of one doubling.

The mistake was conflating how much a program writes with how much of its time it spends writing. sort writes a lot and spends its time merging. Lined up, the three measurements say the same thing:

what it does besides writegain
§1’s loopnothing12.8×
base64a shift and a table lookup per byte1.59×
sorta merge sort over 300,000 lines1.22×

So the rule is the dull one: the gain is the share of the runtime that was libc call overhead, and nothing about writing a lot of bytes makes that share large. A program has to be writing bytes it did almost no work to produce.

Which leaves fasta and reverse-complement — named above as the programs that would “get most of it” — an open question rather than a prediction. fasta computes a linear congruential step and a table lookup per byte, so it is nearer base64 than §1’s loop, and this document should not have guessed. benchmarks-game.md §2.1 is where that gets settled, by measuring.

One thing the numbers do not excuse: sort still makes two libc calls per line on the bulk path, one fwrite and one putchar for the newline. 600,000 calls for 300,000 lines, where a batched buffer would make a few thousand. Whether that is worth the buffer-fill cost §4 measured is exactly the question base64 answered “barely”, and it is not this slice’s to spend.


5. Open

QuestionWhy it waits
A bulk read§3.3. It needs the three-way answer standard-input.md §3.1 and filesystem.md §3 both defer, and that is a milestone rather than a primitive
fs_write of a sliceAlready bulk: it takes the whole [byte]. Nothing to do, and worth saying so — the asymmetry was between Io and Ffi, not between console and file
Buffering in std.ioprint_int still emits digit by digit. A buffered writer is a library question now rather than a language one, which is the point of this slice
fasta, reverse-complementbenchmarks-game.md §2.1’s two programs. They were waiting on this and are now writable without measuring libc
The byte-copy loop C vectorises§4’s third table: 6 ms of scalar stores against C’s nothing. It is the same shape as benchmarks-game.md §1’s finding that the gap tracks how much of the run is code Cranelift generated, and it is now the biggest thing between base64 and coreutils. A slice-to-slice copy primitive would sidestep it; whether that is the right answer or whether the backend should be doing it is not settled

File handles

Status: built (#66). §2.1, §4.1, §4.2 and §6 are what building it settled; everything above them is the design as it stood, which was right about the shape, wrong about one signature, and silent about what three new names cost.

filesystem.md §3 called this “a milestone, not a paragraph” and named three questions it would have to answer. Two of the three are already answered by machinery that exists and is tested — this document’s first job is to retire them. The third is real, and it is the same question standard-input.md §3.1 defers, so settling it here settles both.

The fourth question — the one §3 did not name — is the interesting one, and it is about the authority report rather than about linearity.


1. What the absence costs, measured

examples/sort/ has to read a file of unknown size through fs_read, which “fills as much of into as the file has and returns the byte count”. A file that exactly fills the buffer is indistinguishable from one that was cut short, so read_file reads into 64 KiB and doubles until the answer comes back short.

Under strace, on a 1,160,000-byte file:

reads of the file6
bytes read3,191,616
as a multiple of the file2.75×

porting.md §9.1 says “a 1.2 MB file is therefore read six times”. Six reads is right; six times the I/O is not, and the sentence invites that reading. Each attempt stops at its own capacity, so the total is the sum of the capacities, not six copies:

filereadsbytes readratio
100,0002165,5361.66×
1,160,00063,191,6162.75×
3,000,00077,128,7682.38×
8,388,607816,711,6791.99×

The ratio is bounded by 3 and has no trend — it is worst just after a doubling and best just before one. So the cost is a constant factor under 3, not a factor of six, which is a weaker argument for handles than §9.1 was making. The strong arguments are the next two.

Measured again after the port (#67): porting.md §10. Every one of those ratios is now 1.00×, and the time moved 7% on an 8.4 MB file. Three times less I/O bought single digits, because a sort spends its time sorting — which is bulk-io.md §4.1’s correction arriving from the reading side. The syscall count went the other way (6 reads became 19 on the 1.16 MB file) and costs nothing measurable: 4 KiB, 64 KiB and 1 MiB chunks are within 1.3% of each other.

So this section’s own verdict stands and sharpens. The performance argument for handles was the weak one; what they were worth is in §10.3 — a ceiling deleted rather than raised, a status GNU does not have deleted with it, and an errno finally in reach.

1.1 The ceiling was 8 MiB, and the comment said 16

sort.cho reads:

“Eight doublings from 64 KiB reaches 16 MiB, which is where this gives up rather than growing without bound.”

It does not. Eight attempts starting at 64 KiB reach a largest capacity of 8 MiB, and the file has to come back strictly shorter than the capacity to be believed. Measured:

8,388,607 bytesexit 0
8,388,608 bytesexit 2
9,000,000 bytesexit 2

So examples/sort/ could not sort a file of 8 MiB or more. Not a design decision — an off-by-one in a comment that nothing checked, in the one place a reader would look to find the limit.

Fixed, and it did not need handles. Fifteen attempts reach 1 GiB, which is past where a sort holding the whole file in memory has worse problems; a conformance fixture now sorts 9 MB and fails on the old bound. A ceiling still exists rather than growing until something gives, because heap.md says a failed allocation traps — without one, a file bigger than memory would abort instead of saying so.

1.2 Too big was indistinguishable from missing

Worse than the volume. After eight attempts read_file returns -1, which is the same value it returns when the file could not be opened at all. Both exit 2, and both print nothing:

$ sort just_over.txt   ; echo $?     # 8 MiB file that exists and is readable
2
$ sort /nope/missing.txt ; echo $?
2

Silent because there was no standard error to be loud on. This document filed that under reach.md §6, and reach.md never named it — not in §6, not anywhere; the citation pointed at a table of five rows, none of which was this. standard-error.md §1 is where the gap actually got written down, and closed. So §9.1’s finding is sharper than §9.1 put it: fs_read cannot report truncation, and the workaround cannot report its own failure either. A program that hit the ceiling looked exactly like a typo in a filename.

Half-fixed, and the other half is why this document exists. read_file now answers -2 for “larger than I can grow to hold” against -1 for “could not read it”, and main exits 3 rather than folding it into the 2 GNU uses for a file that is not there. So the two are distinguishable to a script.

They were still not distinguishable to a person, because neither printed anything, and no amount of work inside sort.cho reached that: an exit status was the whole vocabulary this program had.

That is the argument for handles. Not the 2.75×.

Fixed, and again it did not need handles (standard-error.md). sort now says sort: cannot read: <path> and sort: file too large: <path>, on a stream a redirect of the output does not capture. What is still missing is the reason a read failed — GNU names the errno string and fs_read answers a bare -1 — which is §3’s Failed(int) and belongs to this document. Two of the three things §1 was written to motivate have now been fixed without the design it motivates, which is worth noticing twice.


2. Two of §3’s three questions are already answered

§3 said giving a handle a type “means deciding what close consumes, what a half-read file is, and what happens to a handle at the end of a region.”

What happens at the end of a region: nothing new. A linear value that reaches the end of a block is already a compile error, region or not:

region a {
    let f = F { fd: 3 };
}
// error: `f` is still live at the end of this block; nothing consumes it

The worry was that a region’s bulk free would reclaim the memory and leak the descriptor. It cannot, because the checker will not let a res value die by scope exit in the first place. A region frees arena allocations; a linear value has to be consumed by name wherever it lives.

What close consumes: the handle, and the checker already enforces it. That is what res means, and sort.cho’s five owned resources already demonstrate it at scale (porting.md §9.1: eight allocs, eight frees, nothing counting them at runtime).

There is a third thing §3 did not ask about and might have: whether a handle can live inside a Result, since open can fail and handing back a handle and an error means the caller holds a handle it must consume even on the failing path. It can, and std.result’s header already says so in as many words:

“Result[Ticket, int] is a resource in the Ok arm and an ordinary integer in the Err arm, and the type is a resource either way.”

Verified rather than taken on trust — linearity reaches through a generic enum:

let m = Maybe::Some(make_a_resource());
return 0;
// error: `m` is still live here; a `res` value must be consumed on every path

So the shape is available today:

open_read[&c, &a](fs: &c Fs(p), path: &a [byte]) -> [fs_read(p)] Opened
file_close(file: File) -> [] int

(Fs(p) is filesystem.md §3’s own schema notation for “whatever prefix the capability carries”, not literal source — real code spells a concrete one, as sort.cho does with Fs("").)

2.1 That signature cannot be written, and the reason is structural

Result[T, E] is std.result — a library type, declared in std/result.cho and reachable only with --std. A builtin’s signature is fixed in the compiler, in terms of the prelude, and has to type-check in a program compiled without the standard library at all. So open_read cannot return a Result, and no amount of care with the declaration changes that: the type does not exist when the signature does.

This is not a wrinkle in the design so much as the design meeting a rule standard-library.md set and this document forgot — std is opt-in, never a prelude. A builtin that needed std would make it one.

So open_read answers a prelude enum with the same two arms:

enum Opened {
    Ok(File),
    Failed(int)   // errno
}

and std.fs is free to offer into_result(o: Opened) -> [] Result[File, int] for a program that wants the library shape. The linearity argument §2 verified is unaffected — it was about a res payload surviving a generic enum, and Opened is not generic, which is strictly easier.

The cost is one name. What it buys is that a program can open a file without --std, which is the same property every other builtin has.

The milestone is smaller than §3 advertised. What is left is one question about reading, and one about the effect row.


3. What a half-read file is

This is §3’s third question and standard-input.md §3.1’s open row, and they are the same question: a read has three outcomes and int has been carrying two.

getchar answers -1 at end of input. fs_read answers -1 on failure. Those are different meanings on the same sentinel, in the same language, and a bulk read needs both at once plus the ordinary one.

The rule:

file_read[&f, &b](file: &!f File, into: &!b [byte]) -> [file_read] Read
enum Read {
    Got(int),   // 1..=len(into) bytes, at the front of `into`
    End,        // the file is over; `into` is untouched
    Failed(int) // errno; `into` is unspecified
}

Three constructors because there are three outcomes, and an enum rather than a sentinel because a sentinel is how getchar and fs_read came to disagree. Got(0) is not reachable: a read that returns nothing and is not at the end is End or Failed, never a zero.

The row was [?] when this was written; §4.1 settled it as file_read, which is also the builtin’s name for the reason fs_read is both.

match is free here in the sense that matters. Checked rather than assumed — a three-constructor Read with int payloads compiles and runs in a program that has released its Heap, so nothing is allocated and the arms compile to a jump. The cost is three lines at each call site instead of one comparison, and that is the point: the third outcome stops being invisible.

getchar is not changed by this. It is one byte and its -1 is documented; a second spelling of end-of-input in the same program would be worse than one inconsistent one. What changes is that new API does not repeat the mistake.


4. The question §3 did not ask: what the row says

A program that reads /var/log today declares [fs_read("/var/log")], and cancho authority prints that prefix. That precision is the thing authority.md exists to count.

With a handle, where does the prefix go? Four answers, and none is free:

what it costs
read takes the Fs(p) againThe handle is not a capability, just a number. A function given only a handle cannot read from it, which defeats passing one down
read declares bare fs_readA regression in the report. fs_read reads as wider than fs_read("/var/log"), and a program that switched to handles would look like it gained authority
File[p] — a refinement on a user typePreserves everything, and generalises refinement from built-in capabilities to user structs. The largest change of the four
The prefix is spent at openThe row still carries fs_read("/var/log") because open performed it. read performs a path-free label, because the authority was checked once and the handle cannot be widened

The fourth. It is the capability answer — a descriptor is authority you already hold, and re-checking a prefix you cannot change is ceremony — and it keeps the report honest at the place the report is read: the program’s own row still names the directory.

The rule it has to satisfy is the one bulk-io.md §3.2 pinned when the same question came up for output: a program must not look more powerful for having been written better. A conformance test belongs here for the same reason, comparing the authority report of a fs_read-on-a-path program with a handle program over the same directory, and requiring the prefix to survive.

What read performs is then a label that says this touches a file I already opened, which is not fs_read(p) and is not nothing.

4.1 The label is file_read, and the vocabulary already chose it

§6 said this wanted one slice’s argument rather than a guess. The argument is short, because the rule was already there.

Every label in this language is named after the capability that discharges it, not after the operation. io_read and io_write are discharged by owning an Io; fs_read(p) and fs_write(p) by owning an Fs(p); heap by owning a Heap; args by owning Args. The name is the capability, lowercased, plus a direction where the capability has two.

A File is a capability. It is not one split hands out — it is manufactured by open_read, out of an Fs(p) and a path, which is why open_read’s own row carries fs_read(p) and pays for the whole thing once. Afterwards it behaves like every other capability: owning it discharges what it permits, borrowing it declares it, and it is consumed exactly once — by close rather than by release, because ending a descriptor is a syscall and ending a capability is not.

So the label is the capability’s name plus the direction: file_read.

It carries no argument, and that is the same rule rather than a concession. heap and args have none because “a heap has no parts to name and so nothing to narrow”. A file does have a name — and the name was spent at open. Re-attaching it to read is §4’s first option wearing a label instead of a parameter, and it would mean a handle you could not pass to a function that had not been told where it came from.

Two consequences worth stating, because both are the existing rules rather than new ones:

  • Owning a File discharges file_read. read(f, into) borrows, so a function that borrows declares [file_read] and a function that owns the handle outright declares [] — exactly what authority.md §2 established for main and the console.
  • open_read still names the directory. Its row is [fs_read("/var/log")], so the authority report of a handle program and a path program over the same directory agree, which is bulk-io.md §3.2’s rule and the conformance test §4 asks for.

4.2 What three names cost, which nothing here had priced

Adding a prelude type reserves its name in every program, and this milestone wanted the three most ordinary names in the language.

namewhat it collided with
File31 fixtures declared res struct File as a stand-in resource
readexamples/serve/ declares extern fn read — libc’s, on a socket
closethe same 31 fixtures had a close of their own

The type keeps its name: File is what the thing is, the fixtures were using it as a pretend one because a real one did not exist, and they now say Ticket, which is what the rest of the suite already calls a stand-in. That churn is the honest price and it is paid once.

The two verbs do not. read and close are renamed file_read and file_close, which is fs_read/fs_write’s shape — the subject, then the verb — and shares the builtin’s name with the label it performs, exactly as fs_read already does. The deciding case is examples/serve/: a program that reads a socket through Ffi("libc") and a file through a handle is an ordinary program, and the language taking read would have made it unwriteable.

The rule worth carrying forward: a builtin’s name is reserved against extern fn too, so a one-word builtin costs every program that wanted to bind that libc symbol. The prelude has ten of those already; it did not need three more.


5. What this does not do

Not seek, not append, not truncate, not directories, not metadata. A handle that can be read to the end and closed is what examples/sort/ is waiting on, and every other verb can arrive when a program asks for it — which is how vec.set, vec.swap and the bit operators arrived.

(Asked, since: cancho-gpu reads regions of multi-gigabyte weight files. Design in large-files.md: file_size and a positioned file_read_at, with mapping decided by measurement.)

Not a change to fs_read. Whole-file reading is the right call for a file whose size you know and manifests/-shaped work keeps using it. This is the API for a file whose size you do not know, which is the case that currently cannot be written correctly at all.


6. Open

QuestionWhy it waits
What read’s effect label is calledSettled — §4.1: file_read. Every label here is named after the capability that discharges it rather than after the operation, and a File is a capability; it carries no argument for the same reason heap does not, since the path was spent at open
open_read answers Result[File, int]Corrected — §2.1. It cannot: Result is std.result, a builtin’s signature is prelude, and standard-library.md says std is opt-in rather than a prelude. It answers a prelude Opened instead, and std.fs may wrap that
Writing through a handleSymmetric, and deliberately not designed with the read side. bulk-io.md §3.3 declined to design the input half alongside the output half for the same reason, and that turned out right
Whether End can be observed twiceSettled: End again, and it is a fixture rather than a paragraph. POSIX answers 0 at every read past the end, and Failed(int) carries an errno — there is no errno for you already knew, and inventing one would be a sentinel with a constructor around it, which is the thing §3 exists to stop
examples/sort/’s 8 MiB ceilingDone. §1.1 — fifteen attempts reach 1 GiB, and a fixture past the old bound is in the conformance suite. It never needed handles, which is worth noticing: the bug the design doc was written to motivate turned out to be separable from the design
Telling a person that a file failedDone, and not by this document — standard-error.md. §1.2’s half-fix is a whole one: exit 3 against exit 2 for a script, and a named path on standard error for a person. The blocker was cited here as “reach.md §6’s standard-error gap”, which was a gap no document had recorded
Telling a person why it failedDone. Failed(int) carries the real errno, read through __errno_location() on glibc and __error() on macOS — the same per-platform pair stderr/__stderrp already needed. A missing file now answers Failed(2), which is ENOENT, where fs_read answered -1 and nothing else. This was the one argument for handles that survived §1’s other two evaporating, and it is the one that paid
Turning an errno into a sentenceWhat Failed(2) still is not: a program can print the number and not No such file or directory. strerror is one Ffi("libc") call away and that is the wrong shape — it would make a diagnostic cost the authority to call anything. A table in std is the likely answer and no program has asked yet

Files larger than memory: file_size, file_read_at, and whether to map

Status: design. Nothing is built, and no measurement here is of cancho yet.

file-handles.md §5 stopped at a handle that is read to its end and closed: “not seek, not append, not truncate, not directories, not metadata … every other verb can arrive when a program asks for it.” cancho-gpu (cancho#251, slice 9) is that program: an inference runtime opens model weights of several gigabytes, and reads part of them, in an order the file does not dictate. ds4-class engines go further and stream tensors from SSD during a forward pass.

1. What is missing, from the code

Today (filesystem.md §3, file-handles.md):

needwhat existswhy it is not enough
read a whole filefs_read(fs, path, into)the whole file must fit one slice, in memory, at once
read a file of unknown sizeopen_read / file_read / file_closestrictly sequential; reading the last tensor means reading all before it
know a file’s sizenothingthe caller must guess a buffer, or read to the end first
read a regionnothingthe verb is seek or pread, both absent

A GGUF or safetensors file is a small header (tensor names, shapes, offsets) followed by tensors at stated offsets. A runtime reads the header, then touches tensors in layer order, possibly more than once, possibly never. With sequential reads the only way to reach layer 30 is to read layers 0 to 29 into memory.

2. The smallest verbs that answer it

file_size(f: &File) -> [file_read] int                                  // bytes, or a negative error code
file_read_at(f: &File, offset: int, into: &!r [byte]) -> [file_read] int // fills `into` from `offset`; bytes read
  • Positioned reads, not seek. file_read_at is pread: it carries its offset and does not move a position the handle holds. That keeps File free of mutable state a second borrower could disturb, makes concurrent reads from several threads of one handle safe (the reason the verb is pread and not lseek + read), and removes the whole class of “forgot to seek back” errors. seek is not proposed.
  • Same label, same authority. Both perform file_read (file-handles.md §4.1): the path was spent at open_read, and the report of a program that switched from whole-file reads to positioned ones does not widen. A reader of cancho authority sees no new row.
  • Same refusal discipline. An offset that is negative, or past the end, is a returned error, never a trap and never a short read dressed as success: the result is the count actually read, 0 exactly at the end, a negative code on error. A count that does not fill into is not an error, as for file_read. The rule tag for the refusal (file_read_at with a negative offset on a constant) is the build-time case; the dynamic cases are Read-style results (file-handles.md §2), so nothing reaches a panic.
  • Sizes are int. int is 64-bit, so a 100 GB offset is an ordinary number. What is not yet established is the largest slice the heap will give (§5, L0).

This is enough for the first working runtime: read the header, keep the offsets, read each tensor into a buffer it owns when it is wanted, and drop it when it is not. Peak memory is what the program chooses, not what the file weighs.

3. Should it also map? A real question, not a foregone yes

mmap is what ds4-class engines and llama.cpp use for weights: the kernel pages tensors in on demand, evicts under pressure, and two processes share one copy. It is attractive for the same reason it is hard here.

  • It breaks “no input reaches a panic”. A file truncated while mapped delivers SIGBUS on the next touch of a vanished page. That is the file’s doing, not the program’s, but it is a signal at an array index that the language otherwise promises is checked. A language whose claim is that safe code cannot crash on a bad index must either state this exception or not offer the verb. The honest options: (a) no mapping, pread only; (b) mapping, with the hazard written in defined-behaviour.md as the single named exception and a label that makes it visible; (c) mapping that MAP_PRIVATEs and copies on first touch, which removes the sharing that was the point.
  • It is a new kind of slice. A mapped region is a &r [byte] the program did not allocate, with a lifetime tied to a res that must be unmapped. That fits the checker’s existing model (res Mapping closed with unmap, as File with file_close, a forgotten one a compile error), but the slice must be read-only in its type: a &! view of a PROT_READ page is a segfault, not a trap.
  • It buys what pread does not, and no more. Sharing between processes and zero-copy access. Single-process use with an explicit buffer pays one copy per tensor load, which the memory-bandwidth arithmetic (a few GB/s for a copy, against an SSD’s ~1 to 7 GB/s) says is not where a streaming runtime’s time goes. That is a claim to test (§5, L3), not to assume.

Recommendation: build file_size and file_read_at first, and decide on mapping only with a measurement of what the copy costs in cancho-gpu’s runtime. If the copy is not the cost, the exception in (b) is not worth buying. If it is, (b) is the design, as its own document, with the label fs_map(p) so that the authority report distinguishes it.

4. Not here

  • Writes. file-writes.md is a separate story and the runtime does not write weights.
  • Async or direct I/O (io_uring, O_DIRECT). A streaming engine will want them; they are a performance design with their own capability story, and pread from a second thread (thread-payloads.md) is the baseline they would be measured against.
  • Metadata beyond size (mtime, mode). Nothing asked.

5. Plan, each step with its own gate

stepwhatgate
L0measured, §5.1: the largest box_slice the heap gives, and what a request that cannot be met doesdone: the answer is a silent SIGILL, which makes a fallible allocation a prerequisite (§6)
L1file_size, file_read_at, both backends; open_read unchangedan 8 GiB sparse file: size is exact, a read at 6 GiB returns the bytes written there, a read past the end returns 0, a negative offset returns the error, none traps (and a mutant that lseeks instead is caught by a two-handle interleaving test)
L2cancho-gpu reads a GGUF-shaped fixture tensor by tensormemory high-water mark is the largest tensor plus the program’s own, measured, against the file’s size
L3the question of §3: time cancho-gpu loading with file_read_at against the same loop over a mapped file from Cthe copy is, or is not, more than 10% of load time. Only “is” opens the mapping design

5.1 L0, measured

One machine, so the thresholds are this machine’s and only the behaviour is general: Linux 6.18 (Firecracker), 15 GiB of RAM, no swap, vm.overcommit_memory = 0 (heuristic), the Cranelift backend, box_slice(heap, n, byte_of(0)) (so calloc, zeroed-slices.md), calloc observed with an LD_PRELOAD that logs any request of 256 MiB or more.

request (zero-filled bytes)what calloc didwhat the program did
1 GiB, touched one byte per 4 KiB pagereturnedran; peak resident 1,025 MiB (what was touched), 4.1 s
4 GiB, the samereturnedran; peak resident 4,097 MiB, 12.5 s
4 GiB, non-zero fill, every byte writtenmalloc, returnedran; peak resident 4,097 MiB, 5.5 s
16 GiB (more than the 15 GiB of RAM)returned NULLkilled by SIGILL in 0.01 s: no message, no rule tag, exit status 132
1 PiB (2^50)returned NULLthe same SIGILL

What this says.

  • A request the kernel will not grant is a trap, as zeroed-slices.md §2 says (“a null answer traps as malloc’s does”) and as boxed-slices.md says of every allocation. That is a consistent rule, and for a weight loader it is the wrong one: a runtime that wants to try 40 GiB and fall back to reading tensors on demand cannot, because the first refusal ends the process with nothing to tell the user what was asked for. A fallible allocation (§6) is a prerequisite of any loader that sizes itself to the machine, and it needs no new capability: Heap already authorises it.
  • Large untouched zero-filled slices cost nothing resident, as zeroed-slices.md §1 measured, so a runtime may box_slice a whole tensor table lazily; the cost is paid at first touch (about 3 to 4 s per GiB here, which is this VM’s page-fault cost and not the language’s).
  • The method needed a correction. The first probes read a value back and reported success for every size up to 2^63 - 1 bytes, because the compiler removed the allocation altogether (no calloc call was made, which the LD_PRELOAD showed) when its only observers were a store and a load. A probe has to keep the memory live (a loop over it, or a size the compiler cannot see). The same removal means a program whose slice is never really used does not exercise the refusal at all, so a test of §6’s fallible allocation must make the memory observable too. One size, 2^63 - 1, with a touching loop, made no calloc call and ran for ten minutes instead of trapping; it was not run down here and is recorded as unexplained, not as a bug.

6. Open

QuestionWhy it waits
A fallible allocation (try_box_slice(heap, n, fill) answering a value the program can test, or a Result-shaped Opened of its own)new, from L0 (§5.1). Today a refused allocation is a SIGILL with no message. It is the prerequisite for a loader that sizes itself to the machine and falls back to file_read_at; it wants its own short design (what it returns without Result, which is std: file-handles.md §4 hit that wall)
Whether fs_read should be redefined over file_read_atit is the same verb at offset 0 with a size; do it only if the duplicated code is the cost
Direct I/O and alignment[byte] has no alignment guarantee a O_DIRECT read needs; layout.md and zeroed-slices.md are where it would start
A hint verb (file_advise)a kernel read-ahead hint is cheap and pure; nothing has measured the need

File writes: handles that append, sync, and rename

Status: slices 1 and 2 built (§10). The handle verbs and the path operations are in both backends, and 20 conformance tests judge them from outside the program. §2 onward is the design as it stood; where building it showed a claim to be wrong, the section says so where it was made. file-handles.md gave a program a file it can read without knowing its size. This is the other half: a file it can append to, update in place, make durable, and replace atomically. The asker is a durable log (cancho-log, the engine under a job queue and an audit trail), and it is the first program that cannot be written at all today rather than written badly: fs_write rewrites a whole file from byte zero, and a log is the opposite of that.

Two claims are measured here (§1, §4, §6) on one machine and one filesystem, and the places where that is not enough are named.


1. What a log needs, and what a program has today

filesystem.md §3 and file-handles.md §5 give the whole write side as it stands:

a log wants toexists today
append a record to the end of a fileno. fs_write truncates and rewrites
write a record, then know it reached the diskno sync of any kind
read the record at byte offset nno. file_read is sequential from the start, no seek
overwrite 8 bytes at offset n (an index, a header)no
cut a half-written record off the tail after a crashno truncate
publish a new file under an old name atomicallyno rename
delete a sealed segmentno remove
refuse to run twice over the same directoryno lock
learn what segments exist after a restartno directory listing

file-handles.md §5 says “every other verb can arrive when a program asks for it — which is how vec.set, vec.swap and the bit operators arrived.” This is that asking, and it asks for a coherent set, because each row above is useless without its neighbours: an append without a sync is a cache, and a sync without a rename is half a checkpoint.

1.1 What the sync costs, measured

The one number that decides how the engine is built is the price of the guarantee. On this machine (ext4 on a virtual disk, one writer, 128-byte records, 2,000 of them):

per record
write only0.4 µs
write + fdatasync after each193 µs
write + fsync after each217 µs
write, fdatasync every 100 records4.2 µs

Three readings, and the third is the one that matters:

  • Durable is ~500× the price of written. A primitive that hid that, by syncing implicitly, would make every append look like a database commit. So file_write never syncs and file_sync is a separate, visible call (§5).
  • fsync against fdatasync is 11% here (217 against 193). That is not worth a second name in the first slice; §5 says what the second would cost.
  • Batching recovers 46× (193 µs to 4.2 µs a record) for a hundred records of added latency. That is group commit, and it is the engine’s job, not the primitive’s. The primitive’s job is to allow it: one file_sync covering many file_writes.

This is one disk. The ratio is the claim; the absolute figures move with the device, and a laptop SSD with a volatile cache will show a much smaller one that is, for the reason in §6, partly an illusion.


2. Why not Ffi("libc") and be done

It works today, and it is what the networking stack did before native-sockets.md: a program declares extern fn pwrite, extern fn fsync, extern fn rename, and holds an Ffi("libc"). This is the alternative this document has to beat, and native-sockets.md §2 already beat it on the same grounds, which apply unchanged:

  • A descriptor is an int, and an int is forgeable. An extern fn fsync(fd: int) accepts any number, including a socket, a closed descriptor already reused, or 2. The linear File is the thing that makes “this is the descriptor I opened, exactly once, and close ends it” a property of the types.
  • Ffi("libc") is the whole of libc. A program that fsyncs through it can also unlink anything. The report says ffi("libc"), which is true and tells a supervisor nothing. A log that is supposed to touch /var/lib/log and one socket should say exactly that, and cancho authority already prints fs_write("/var/lib/log") for the path operations. under-a-grant.md found the filesystem dimension is the one a lex-os grant can enforce, so this is the dimension worth keeping precise.
  • The variadic trap is waiting. filesystem.md §2.2: open is variadic, a variadic argument is passed on the stack on Apple ARM64, and an extern fn open(path, flags, mode) declared with three fixed arguments created a file with an unreadable mode on one CI target and not on the other. A log needs open with O_CREAT and O_APPEND, and fcntl, both variadic. Doing it from extern fn means every program rediscovers this. §3 shows the builtins can avoid it entirely.

The cost of the alternative is the cost of the engine’s authority report reading ffi("libc"). That is the reason, and it is the same reason as every earlier stage.

2.1 Is this C?

Two different questions are in that word, and the answer differs.

  • For the program: no C at all. A program that opens, appends, syncs and renames through these builtins declares no extern fn and holds no Ffi("libc"). Its authority report names the directory it touches and nothing called ffi. This is what native-sockets.md §7 calls stage 1, and it is what this document delivers for files.
  • For the compiler: still libc, for now. The backend implements each builtin by calling libc, as creat, open, read and close already do. Removing that is native-sockets.md §7’s stage 2, a libc-free Linux runtime issuing raw syscalls, and it is not started. Cranelift cannot emit a raw syscall instruction, so it cannot be a part of this slice, and native-sockets.md §7 is also explicit that macOS cannot leave libSystem at all.

The consequence for the design: the choice in §3 (fopen) is backend-internal and is the stage-1 bridge. No program can see it, in the same way no program sees that fs_write calls creat. When stage 2 arrives on Linux, each builtin below becomes one fixed-arity kernel call (openat, pwrite64, pread64, fsync, ftruncate, renameat, unlinkat, flock), with the Linux flag constants in a table, no FILE object, and no variadic question. The API in §4 does not change when that happens, which is the property that justifies doing the API first.


3. Opening: three flags nobody has to know

The first design question is how to open a file for appending without calling a variadic function and without guessing platform constants.

O_CREAT, O_APPEND, O_TRUNC and O_EXCL are different numbers on Linux and macOS, and open(path, O_CREAT, mode) is variadic. The existing backend avoided both with creat(path, mode) for writing and open(path, 0) for reading (filesystem.md §2.2). creat means exactly O_WRONLY|O_CREAT|O_TRUNC, so it gives the truncating case but not the three a log needs: create-or-append, exclusive create, and read-write on an existing file.

C’s fopen has a mode string for each, and is not variadic. The backend can call it, take the descriptor, and discard the FILE:

FILE *f = fopen(path, "ab");   // or "wb", "wbx", "r+b"
int fd  = dup(fileno(f));      // the open file description is shared
fclose(f);                     // frees the FILE; fd stays open

That fclose closes the original descriptor and the dup survives, so nothing leaks a FILE. It costs two extra syscalls at open, not per write, so the hot path is untouched. Measured on this machine, with strace and fcntl(F_GETFL) on the descriptor after the fclose:

mode stringwhat the kernel sawaccessO_APPENDcreated with
"ab"O_WRONLY|O_CREAT|O_APPEND, 0666writeyes0666 & ~umask (0644 at umask 022)
"wb"O_WRONLY|O_CREAT|O_TRUNC, 0666writenothe same
"r+b"O_RDWR, 0666read-writeno(fails if missing)
"a+b"O_RDWR|O_CREAT|O_APPEND, 0666read-writeyesthe same
"wbx"O_WRONLY|O_CREAT|O_EXCL|O_TRUNC, 0666writenoEEXIST if present, refused

After dup and fclose the descriptor keeps O_RDWR|O_APPEND and accepts a write. Three facts follow, and one of them is a limit:

  • The mode is 0666 masked by the umask. The existing creat(..., 420) is 0644. The new opens are more permissive by default than fs_write, and a program that wants 0600 for a segment file cannot ask. Slice 1 accepts that; §8 lists it.
  • No O_CLOEXEC. The descriptor is inherited across exec (measured: FD_CLOEXEC clear). cancho has no exec builtin, so this matters only to a program that reaches fork/exec through Ffi, and it is the same as open_read today. Corrected (processes.md §4.5): the duplicate taken from fopen’s descriptor is now fcntl(F_DUPFD_CLOEXEC) rather than dup, so it is close-on-exec from the start, as every descriptor a builtin opens now is. fopen’s own descriptor lives only until the fclose straight after, and is not close-on-exec in that window.
  • Per-target flag constants are avoided entirely, which is what the rule from filesystem.md §2.2 asked for: the backend does not call variadic C functions, and here it need not guess a flag either.

fopen is not the only way. The LLVM backend can emit a correct varargs call, and each target’s O_* values could live in the backend, as the Poller’s epoll/kqueue split already does.

Corrected while building it. This paragraph first said the Cranelift backend cannot express a variadic signature. That is wrong: fcntl is variadic too, and body/sockets.rs already shapes the call for Apple arm64 (nine integer parameters, the ninth where va_arg reads it). So a direct open(path, flags, mode) with a per-target flag table is available on both backends, and it is the shorter path: no FILE, no dup, no branch, and exactly the table a libc-free runtime would need (§2.1). fopen was kept anyway, on the maintainer’s call, because it is the same code on both targets today and it is backend-internal either way; the direct open is the first thing to try if fopen shows a cost. Opening is one call per file, not per record, so nothing measured here says it matters.

3.1 One File, or a WFile

A handle opened with "ab" rejects file_read with EBADF, and a handle opened with open_read rejects file_write the same way. The mode is enforced by the kernel, not by the type.

The alternative is a second prelude type, so that file_write cannot be called on a read handle at all. That is the more precise answer and the language’s habit (a Conn is not a Listener), but those are different kinds of descriptor and a file opened r versus a is not. It would double every operation (file_close against wfile_close), and it would spend two more ordinary names (§4.2 of file-handles.md: “a builtin’s name is reserved against every program”). The recommendation is one File, with the failure surfaced as an errno in the result, the same way every other I/O failure here is. Open question in §8.


4. The operations

Names follow file-handles.md §4.2: the subject, then the verb, and none of the three ordinary names (write, read, sync) that a program using Ffi("libc") may already declare. Zero fixtures, examples or packages in this repository use any of the names below (checked with grep over every .cho and .rs file).

open_append[&c,&a](fs: &c Fs(p), path: &a [byte]) -> [fs_write(p)] Opened   // "ab":  create or append
open_write [&c,&a](fs, path)                      -> [fs_write(p)] Opened   // "wb":  create or truncate
open_new   [&c,&a](fs, path)                      -> [fs_write(p)] Opened   // "wbx": create, EEXIST if present
open_rw    [&c,&a](fs, path)                      -> [fs_read(p), fs_write(p)] Opened   // "r+b": existing, both

file_write [&f,&b](file: &!f File, bytes: &b [byte])                  -> [file_write] Done
file_pwrite[&f,&b](file: &!f File, at: int, bytes: &b [byte])         -> [file_write] Done
file_pread [&f,&b](file: &!f File, at: int, into: &!b [byte])         -> [file_read]  Read
file_sync  [&f](file: &!f File)                                        -> [file_write] Done
file_truncate[&f](file: &!f File, len: int)                            -> [file_write] Done
file_size  [&f](file: &!f File)                                        -> [file_read]  Done

Opened is the existing prelude enum, so a program that opens through the new names uses the arms it already knows. The two additions are one prelude enum for write-side results:

enum Done {
    Ok(int),     // bytes written, the size, or 0
    Failed(int)  // errno
}

Why a new enum and not Read: Read has an End arm that a write cannot reach, and file-handles.md §3’s rule is that a sentinel is how getchar and fs_read came to disagree. Why not -1: fsync failing with EIO and failing with ENOSPC need different handling (§5), so the errno has to survive. Done is the second name this document asks the prelude to reserve; zero declarations of it exist in the tree, and it is an open question (§8) whether the name is too ordinary.

4.1 file_write is one write(2), as file_read is one read(2)

It returns how many bytes the call accepted, which can be fewer than asked (an interrupted call, a full disk partway). A loop that writes the rest is std.fs.write_all, the same shape bulk-io.md took for the console and print_nat/write_all took for std. The primitive does no looping because the loop’s policy (what to do on EINTR, whether to retry on ENOSPC) is the program’s.

4.2 file_pwrite and file_pread take an offset and leave the cursor

A record store reads the record at offset n, and an index file updates eight bytes in place. Doing that with a cursor means seek and a race between two readers sharing a handle. pread/pwrite carry the offset and touch no state, so the same handle serves any number of readers.

One limit, measured and not a bug in the design: pwrite on a handle opened for append does not write at the offset. On this machine, a file holding AAAA opened "a+b" and given pwrite(fd, "BB", 2, 0) reads back as AAAABB, not BBAA. That is documented Linux behaviour. It is why open_rw (no O_APPEND) exists as a separate open: an in-place update needs a handle that is not an appender. §3 lists the modes for that reason.

4.3 file_truncate and file_size

A crash can leave a record half-written at the tail. Recovery scans the tail, finds the last whole record, and cuts the rest off with file_truncate. Without it the only recovery is to copy the good part to a new file.

file_size could be fstat, but struct stat’s st_size is at a different offset on different targets (48 on Linux, checked with offsetof here; 96 on macOS, from the headers and not checked). It is instead two lseek calls (SEEK_END is 2 on both) that read the end and put the cursor back, so a handle opened r+b is not left at the end. Two syscalls is not measured to matter here and a log keeps its own length.


5. Sync: the primitive, and what it promises

file_sync(file) -> [file_write] Done

It is fsync(2): when it answers Ok, the data and the metadata needed to read it back (the length) are on stable storage, as far as the kernel and the device report. The first slice does not ship fdatasync; §1.1 puts the gain at 11% and a second name costs a second reservation.

5.1 A failed sync is not retryable

If fsync fails with EIO, the kernel may already have dropped the dirty pages and cleared the error, so a second fsync on the same handle can succeed over data that was never written. This is a well-known behaviour (PostgreSQL’s 2018 “fsyncgate” is the usual citation), and it is not measured here: a test that provoked it would need a failing block device. The consequence for the design is stated plainly instead:

Done::Failed from file_sync means the file’s contents are unknown. The documented response is to stop and recover from the log, never to retry.

That is policy for cancho-log, and it is why Done carries the errno: ENOSPC on a write is an ordinary condition a program may wait out, EIO on a sync is not.

5.2 A directory is synced through the same call

A new file, a rename, a removal is a change to a directory, and is not durable until the directory is synced. That needs no new builtin: open(2) on a directory with O_RDONLY succeeds and fsync on that descriptor returns 0 (measured here: open("/tmp/fw", O_RDONLY) gave a descriptor, fsync gave 0). So a program does open_read(fs, dir), file_sync, file_close.

The authority question is what that function’s row says. file_sync carries file_write, the conservative label: it asks the device to persist, and labelling it file_read would let a program that holds only a read capability claim a write. But file-handles.md §4.1’s rule is that owning a File discharges its label, so a function that opens the directory itself and syncs it may report only [fs_read("/data")]. Syncing a descriptor opened read-only writes no new content, so that is defensible, and it is also a prediction about the checker that is not tested here. Open question 6 asks for a conformance test of the actual report before this is relied on.


6. What file_sync does not promise, by platform

Linux is the target this is designed for and measured on. It is where lex-os runs its Firecracker box and the only place the numbers in §1.1 were taken.

On macOS, fsync does not flush the drive’s own cache. Durability there needs fcntl(fd, F_FULLFSYNC), which is variadic, the exact thing §3 avoids, so it needs the per-target path in §3’s last paragraph, and it is not in slice 1. Until it is, file_sync on macOS means “handed to the drive”, and the document that ships it says so. The rule from lex-os is refuse, don’t downgrade: a design that cannot make the guarantee on a target should say so at the target, not pretend. Whether file_sync refuses to compile for a non-Linux target or carries a documented weaker meaning is open question 4 in §8. A reviewer on a Mac is the person to answer it, and this machine cannot.

A volatile cache can make §1.1 flatter than it should be. A device that acknowledges a flush it has not performed makes fsync fast and the numbers an illusion. The 193 µs here is consistent with a real flush on a virtual disk, and is not proof of one. The correctness of cancho-log cannot rest on this table, and its crash tests (not this document’s concern) do not.


7. Path operations: rename, remove, lock

These take an Fs(p) like open_* and are checked the way filesystem.md §4 checks every path: against the prefix, with .. refused, at run time.

fs_rename[&c,&a,&b](fs: &c Fs(p), from: &a [byte], to: &b [byte]) -> [fs_write(p)] Done
fs_remove[&c,&a](fs: &c Fs(p), path: &a [byte])                  -> [fs_write(p)] Done
file_lock[&f](file: &!f File)                                    -> [file_write] Done
  • fs_rename checks both paths, and both must be inside the prefix; a rename that moves a file out of the granted directory is a write outside it. Atomic replacement of an existing destination is POSIX’s guarantee within one filesystem. Across filesystems it fails with EXDEV and the errno is returned, not retried. That guarantee is not measured here; it is what the standard says and what every database relies on. Durability still needs §5.2’s directory sync, and cancho-log is the program that must do it.
  • fs_remove is unlink. It does not remove directories.
  • file_lock is flock(fd, LOCK_EX|LOCK_NB): a non-blocking, exclusive, advisory lock released when the process ends, however it ends. That last clause is the reason it is better than a lock file: a crashed process leaves a stale lock file and a human to remove it, and leaves nothing behind with flock. A database refuses to open a directory a second process is already writing; this is the primitive for that. flock is not variadic; LOCK_EX is 2 and LOCK_NB is 4 in the Linux headers (read from /usr/include/x86_64-linux-gnu/sys/file.h here), and the BSD headers use the same values, which is unconfirmed on a Mac and is a line for the macOS CI to check before this is trusted there.

Advisory means a process that does not call flock is not stopped. It protects against two copies of this program, not against hostility, which is Fs’s own stated scope (filesystem.md §2.1: honest mistakes, not a sandbox).


8. What this does not do

  • No directory listing. readdir’s struct dirent has d_name at different offsets on different targets, and a listing returns a variable number of names, a bigger design than anything above. A log does not need it in its first version: a CURRENT file naming the live segments, replaced with fs_rename and a directory sync, is how LevelDB finds its files without listing a directory. fs_list waits for a program that cannot do that.
  • No mmap, no O_DIRECT, no sendfile. All are performance features whose cost is not yet measured to matter; the log’s throughput gate decides, not this document.
  • No mode argument. New files are 0666 & ~umask (§3). A secret written this way is readable under a lax umask. A mode parameter is cheap to add (creat takes one) and is held back only so slice 1 is one decision smaller.
  • No O_CLOEXEC (§3). Closed by processes.md §4.5.
  • No durability on a non-Linux target (§6).

9. Open questions, and where each stands

  1. Is Done too ordinary a prelude name? Zero declarations in this repository, but file-handles.md §4.2 priced exactly this mistake. Proceeding with Done unless a reviewer objects; Written is the alternative and renaming before release is cheap.
  2. One File or a WFile? §3.1 recommends one; the cost is that a read/write mismatch is a runtime EBADF, not a type error. Proceeding with one File.
  3. fopen or per-target flag constants for the opens (§3)? Settled: fopen for now. It is backend-internal and is replaced by raw syscalls in stage 2 (§2.1).
  4. Refuse or weaken file_sync off Linux (§6)? Proceeding with a documented weaker meaning (fsync(2), no drive-cache flush on macOS): refusing would stop every program that syncs from compiling on the macOS CI target. F_FULLFSYNC is the follow-up, and a Mac is needed to test it.
  5. A mode argument now or later (§8)? Later.
  6. What does the authority report say for a read-opened handle that is synced (§5.2)? A test, not an argument, settles it. It is written with slice 1.

10. Slices, and how each is checked

The same rules as every earlier stage: both backends, one conformance suite, no source file over 2,000 lines (the largest touched is builtin.rs at 1,229), every refusal with a rule tag.

  1. Handles. Built. open_append, open_write, open_new, open_rw, file_write, file_pwrite, file_pread, file_sync, file_truncate, file_size, and the prelude enum Done, all edition 5. This is enough to write and recover a log. std.fs (write_all, into_result) is not built: nothing has asked for it yet, and the first program that does (cancho-log) will say what shape it wants.
  2. Path operations. Built. fs_rename, fs_remove, file_lock. This is enough to rotate and seal a segment and to own a directory.
  3. Listing, mode, fdatasync, F_FULLFSYNC, each only when a program asks and a measurement says it is worth the name.

10.1 What building slice 1 showed

  • Owning an Fs(p) discharges file_write, as it discharges file_read. PRELUDE_FS, PRELUDE_FILE and PRELUDE_WORLD each gained the label, and the authority report’s “the filesystem” group lists it, so a program that never touches a file still reports the filesystem as untouched. This partly answers open question 6: a function that opens a directory itself and syncs it reports only the Fs row, because the capability that paid the prefix discharges the path-free label that follows.

  • The opens are one lowering. Expr::OpenFile gained an OpenMode, and open_read is the Read case of the same node, so the prefix check, the .. refusal and the row are shared and not copied.

  • The row is exact per mode. open_append, open_write and open_new perform fs_write(p) and no fs_read; open_rw performs both. A test reads the authority report of an append-and-sync program and requires fs_write with the directory, file_write with no argument, and no fs_read anywhere.

  • New names are edition 5 and resolved by edition. An edition-4 file that says open_new gets not a function in this program, which is the same rule connect and conn_read follow, so a file that declares its own extern fn file_write keeps reaching it.

  • One node for both path operations. Expr::PathOp carries the prefix and one path (fs_remove) or two (fs_rename); every path goes through the same check as every other, so a rename whose destination is outside the prefix, or whose either path holds .., traps like an open does. Tested for source and destination separately, because a check on only one of them is the mutant that matters most.

  • file_lock reports EWOULDBLOCK, which is 11 on Linux and 35 on macOS. The test knows both. LOCK_EX | LOCK_NB is 6 in the Linux headers (checked); the macOS value is what the darwin CI job now tests.

10.2 Verification

Twenty conformance tests (tests/conformance/file_writes.rs), each on both backends, judging from outside the program: the bytes on disk, the mode of a created file under umask 027 (0640, read by stat), the errno of each refusal, and the fsync call and its descriptor read from an strace trace.

Mutation checked, in a scratch copy and never committed: 21 mutants for slice 1 and 13 for slice 2, all killed. Per backend: sync that does nothing, pwrite and pread with offset and length swapped, file_size that does not put the cursor back or seeks from the wrong place, a Done that never reports failure, dup omitted (the descriptor closed by fclose), and fopen failing without its errno. In the shared mode table: open_append truncating, open_new not exclusive, open_rw truncating or creating, and open_write appending. The test that kills the sync mutant is the strace one; before it was written, nothing here distinguished a sync that called fsync from one that returned Ok.

Slice 2’s mutants, per backend: unlink that does nothing, a rename that does not check its source or does not check its destination, a rename with its arguments swapped, a lock that is shared instead of exclusive or that does nothing; and in the shared lowering, a rename that performs fs_read instead of fs_write. The lock tests start two processes: a holder takes the lock and waits on standard input, a contender is refused, the holder is killed with SIGKILL, and the contender is admitted. That last step is the property that justifies a lock over a lock file.

What this does not verify is stated in §5.1 and §6: it cannot show that an acknowledged sync survives a power cut, and nothing here ran on macOS.

What each slice’s conformance tests check from outside the program, for the reason filesystem.md §2.2 gave (a read that happens to succeed is not evidence about a mode):

  • the bytes of the file after append, pwrite and truncate, read by the test and not by the program;
  • the open flags and the mode of a created file, from the kernel side (strace, or fcntl on the child’s descriptor), including that open_append of an existing file does not truncate it and open_new of one refuses with EEXIST;
  • that file_sync makes an fsync call appear under strace, because “sync returned Ok” proves nothing about whether it called anything;
  • file_pwrite on an open_append handle appends (§4.2), written as a test so the behaviour is a known fact and not a surprise;
  • the authority report of an fs_write("/d") program against the handle program over the same directory, and that the prefix survives (file-handles.md §4’s rule: a program must not look more powerful for having been written better);
  • for file_lock, a second process refused while the first lives and admitted after it is killed, which is the property that justifies the primitive.

What no test at this layer can check is that an acknowledged sync survives a power cut. That is the log’s crash test, in cancho-log: truncate or corrupt the file at every byte offset and require recovery to yield a valid prefix. This document supplies the primitives that test needs and the policy (§5.1) it must follow.

std.crypto: SHA-256, and only SHA-256

Status: built, first slice of a larger initiative.

This is not a program-asked-for-it addition in the usual sense AGENTS.md §7 describes — there is no second .cho program in this repository that needed a hash function first. It exists because ROADMAP.md’s own investigation into porting lex-os off Rust found crates/lex-os-audit’s hash-chained log calls sha2::Sha256 on every entry, and a hash-chained log with no hash function is not a smaller version of the port, it is a different thing. Named here rather than left implicit, because building ahead of a same-repo consumer is the exception AGENTS.md §7 does not otherwise make.


1. Scope: one algorithm, not a library

std.crypto is SHA-256 and nothing else — no SHA-1 (broken), no MD5 (broken), no AES, no TLS, no key exchange, no random-number generation. Each of those is a separate, harder correctness problem with its own attack surface, and none has a consumer yet. lex-os-audit’s own dependency is exactly one function: Sha256::new() / .update() / .finalize(), called once per log entry. lex-os-capsule needs Ed25519 signing next, and that is a second slice, not this one — hashing and signing are different primitives with different failure modes, and bundling them because both start with “crypto” is the kind of premature scope this document is refusing on purpose.

FIPS 180-4 is the spec. This implementation is checked against it by the only method that means anything for a hash function: byte-exact output on real input, not “the code looks like the pseudocode.”


2. The arithmetic problem: SHA-256 is 32-bit, cancho’s int is not

Every cancho int is a checked, 64-bit two’s-complement value — docs/defined-behaviour.md’s whole point is that +/-/* trap on overflow rather than wrap. SHA-256 is specified entirely in 32-bit modular arithmetic: every addition in the compression function is mod 2^32 by design, not by accident, and a correct implementation depends on that wraparound happening on schedule sixty -four times per block.

The two do not conflict, once stated precisely: nothing in this implementation lets a 32-bit value — always held as an int in the range [0, 0xffffffff], which is a small, unremarkable, very-non-negative 64-bit number — get anywhere near the checked-arithmetic ceiling. mask32(x) = x & 0xffffffff after every addition throws away the bits above 32 explicitly, in source, at the point the spec says to reduce mod 2^32 — so the checked + underneath never traps (the largest sum this code ever forms is five 32-bit terms, ~2^34.3, nowhere near 2^63) and the masking is doing the wraparound instead. No wrapping_add is needed: the trap and the wraparound are solving two different problems here, and this one only has the second.

Corrected by docs/hkdf.md §3. The reasoning above holds: the checked + never traps. But “never traps” still compiles to an overflow test, and once HMAC and HKDF hash secret keys through this function, that test is a branch computed from secret words. The additions are now wrapping_add under the same mask, with the same values and no test. not32 below is now x ^ 0xffffffff for the same reason: its subtraction was a checked one.

The other consequence worth stating: docs/bitwise.md §2 makes >> arithmetic (sign-extending), which would be wrong for SHA-256’s right shifts if they ever ran on a negative number. They never do — every word this code shifts is already masked into [0, 0xffffffff], which as a 64-bit int is always positive, so arithmetic and logical right shift agree on it. rotr32 builds a 32-bit rotate out of two opposite shifts and an mask32-ed |, not a dedicated rotate operator this language does not have. not32(x) = 0xffffffff - x builds the 32-bit bitwise complement out of subtraction rather than cancho’s own ~ (which flips all 64 bits, not 32) — a true identity for any x in [0, 0xffffffff], not an approximation.


3. The round constants are a static, not a coincidence

FIPS 180-4’s eight initial hash words and sixty-four round constants are exactly docs/compile-time-data.md’s own motivating shape: fixed data, known at compile time, read many times and written never. They are declared as two static items (sha256_h0, sha256_k) rather than as sixty-four let bindings recomputed per call or a parameter threaded through every function — the first real consumer of static outside its own design doc and tests/accept/static_data.cho, and exactly the table shape compile-time-data.md §2 used decode_table to illustrate.


4. The API

pub fn sha256[&s, &o](message: &s [byte], digest: &!o [byte]) -> [] int

digest is a caller-provided, unique-referenced output buffer, the same “write into what the caller passed” shape std.bignum’s add_into/copy already use rather than allocating and returning a fresh slice — one write-only pointer, matching crates/lex-os-audit’s own call shape, is what a caller reaches for. digest must be (at least) 32 bytes; a shorter one traps on the ordinary bounds check every other out-of-range write in this language already gets, which is the correct, honest answer rather than a manual length precondition duplicating it.

Corrected by docs/hkdf.md §2: sha256 trapped on any message of 65,536 bytes or more. It copied the whole padded message into one arena, and an arena is 64 KiB. Nothing here said so, and a reader of this section would assume any length works. sha256 is now sha256_init/sha256_update/sha256_final over a 138-word state that holds the buffer and the message schedule. The state is public, for HMAC and for transcript hashes. It hashes any length and allocates nothing in proportion to the message.

The row is []. Nothing here performs an effect — region/ alloc_slice are not effects (docs/linearity-and-effects.md §7.3; every effect needs a capability and this function takes none), and reading a static performs nothing by the same rule that lets it have row [] in the first place. A hash function that could not be called from a pure context would be a strange hash function.


5. Checked, not assumed

Four vectors, none written from memory — computed with the system sha256sum and matched against this implementation’s own output byte-for-byte:

InputDigest
""e3b0c44298fc1c149afbf4c8996fb92427ae41e4649b934ca495991b7852b855
"abc"ba7816bf8f01cfea414140de5dae2223b00361a396177a9cb410ff61f20015ad
"abcdbcdecdefdefgefghfghighijhijkijkljklmklmnlmnomnopnopq" (56 bytes)248d6a61d20638b8e5c026930c3e6039a33ce45964ff2167f6ecedd419db06c1
"The quick brown fox jumps over the lazy dog"d7a8fbb307d7809469ca9abcb0082e4f8d5651e46d3cdb762d02d0bf37c9e592

The 56-byte vector is the one that matters most: "" and "abc" both pad to exactly one 64-byte block, so neither exercises the multi-block path (total + 9 > 64, the padding pushing the message into a second block) at all. 56 is the smallest input that does — 56 + 9 = 65 > 64 — and it is the standard NIST test vector for exactly that reason, not picked here for one.

tests/accept/sha256.cho checks all four, byte-for-byte, through --std, on both backends via the normal accept-fixture path.


6. static was never gated by reachability, and this module found it

AGENTS.md §7’s promise — “a declaration nobody calls emits nothing” — is checked by modules.rs’s std_declarations_cost_nothing_unless_ called: it builds one program with --std and one without and asserts the two object files are byte-identical. Adding sha256_h0/ sha256_k broke it on the first run, and the reason is a real gap static_data.cho could never have exposed on its own: unlike a function, a static was never a root mono’s reachability worklist gated — lib.rs lowered and evaluated every static a program declared, unconditionally, and pushed the result into Program:: statics whether or not any reachable function ever named it. A std module with a function nobody calls already cost nothing, by construction, before this slice; a std module with a static nobody’s reachable code reads did not, and nothing had ever declared one to find out.

Fixed in cancho-ir, not in this module: fold::collect_static_refs/ collect_static_refs_body walk every Expr a reachable function holds — exhaustively, with no wildcard arm, for the same reason body/ expr.rs’s own match lost its wildcard in the LLVM backend’s §7.25 — and record which static indices it actually names. Because a static may only read one declared before it (compile-time-data.md §2’s own acyclic rule), closing over a used static’s own body for its earlier references is one pass per used index, not a fixed point search. What survives is renumbered into a compact range and every Expr:: Static reference — in program.funcs and in a kept static’s own body alike — is rewritten to match before evaluate_static ever runs, so its evaluated: &[Vec<i64>] lookup by that same index still lines up. Checked with a static that reads another, earlier static (static_data. ls’s own doubled reading squares, unaffected) and, freshly, with three statics where the middle one is dropped and the third has to renumber past it — both backends produce the arithmetically correct answer, not just a program that links.

static_data.cho itself was never in a position to catch this: a program written specifically to exercise a static also, necessarily, reads it. The gap needed a static sitting in a library nobody had called yet, which is exactly what this module’s own status note — built ahead of a same-repo consumer, rather than after one asked for it — turned out to supply.


7. What is next

lex-os-capsule’s Ed25519 signing is the next real consumer, and it is a separate slice: a different primitive, its own correctness surface (a signature scheme has a private key to get right, a hash function does not), and nothing here should be read as scoping it in advance. lex-os-guest’s vsock/HTTP needs are further out still and belong to Net/Fs, not std.crypto.

std.crypto: SHA-512, and the logical shift it needed

Status: built, second slice of the initiative docs/crypto.md opened.

crypto.md §7 named this as the next real consumer’s dependency — lex-os-capsule’s Ed25519 signing needs SHA-512, not SHA-256, per RFC 8032 — and said nothing here should be read as scoping Ed25519 itself in advance. It still isn’t. This document is SHA-512 alone, and §5 below is explicit about how much of Ed25519 remains unstarted after it.


1. Same algorithm, different word width

SHA-512 is FIPS 180-4’s other hash: eighty rounds instead of sixty-four, 128-byte blocks instead of 64, eight 64-bit running words instead of eight 32-bit ones, and a 128-bit big-endian bit-length field instead of a 64-bit one. compress512 in std/crypto.cho is structurally compress again — same message-schedule shape, same eight-variable round, same eight-word finalization — at the wider width, and sha512’s own padding is sha256’s padding with 9 replaced by 17 (1 byte for 0x80, 16 for the length, not 8) and 64 replaced by 128.

sha512_h0 and sha512_k are FIPS 180-4’s own words: the first eight primes’ square roots and the first eighty primes’ cube roots, fractional part, 64 bits each. Independently derived here — an integer square root and a binary-searched integer cube root over p * 2^128 and p * 2^192 respectively, not a memorised or copied table — and then checked the only way that means anything for a hash function: run through Python’s own hashlib.sha512 on four inputs and matched byte for byte, and separately against the system sha512sum on the same four inputs. Both agree with each other and with §4 below.


2. The word width that helped SHA-256 is exactly what breaks here

crypto.md §2’s whole design was possible only because a SHA-256 word lives in [0, 0xffffffff], comfortably inside this language’s 64-bit int — with 32 bits of headroom above it to mask into and mask out of. A SHA-512 word is the int width. There is no headroom, and two real consequences follow from having none.

wrapping_add replaces mask32(checked +) outright, and this is the easy consequence. mod 2^64 is not a mask applied after a checked add any more — it is what wrapping_add already computes, on the exact bits a 64-bit word has. Nothing here needed not32’s workaround either: ~e is the true 64-bit complement the round function wants, because unlike SHA-256’s masked word, a SHA-512 word has no bits above it for ~ to wrongly flip.

Every rotate and every shift SHA-512 needs is now a logical one — zero-filling — and this language’s >> is arithmetic, sign-extending, by design (docs/bitwise.md §2: “it will be arithmetic; int is signed”, with unsigned integers, and a logical >> on them, named as future work). SHA-256’s rotr32 never had to face this: every word it rotates is confined to [0, 0xffffffff] by mask32, which as a 64-bit int is always non-negative, so an arithmetic and a logical shift agree on it by construction — the gap was there the whole time, invisible because nothing SHA-256 does ever sets the sign bit of a value it then shifts. A SHA-512 word is the full 64 bits and routinely has its own top bit set (sha512_h0[1], 0xbb67ae8584caa73b, is one), and rotr64/s0/s1/S0/S1 all need the true bit pattern shifted, not a sign-extended one.

The fix does not need a new operator. x >> n already has the correct low 64 - n bits of a logical shift regardless of x’s sign — sign-extension only ever writes the top n bits, which a logical shift would have zeroed. So:

fn low_mask64(k: int) -> [] int {
    if k == 63 { return 0x7fffffffffffffff; }
    return (1 << k) - 1;
}

fn lshr64(x: int, n: int) -> [] int {
    return (x >> n) & low_mask64(64 - n);
}

fn rotr64(x: int, n: int) -> [] int {
    return lshr64(x, n) | (x << (64 - n));
}

masking off exactly the bits sign-extension might have gotten wrong turns the arithmetic shift already available into the logical one needed. rotr64’s two halves need no mask of their own the way rotr32’s masked | does: lshr64(x, n) zero-fills its own top n bits and x << (64 - n) zero-fills its own bottom 64 - n bits (docs/bitwise.md §4 — a left shift never traps on the bit pattern it produces), so the two ranges are disjoint and the | just is the rotate.

low_mask64’s own k == 63 branch is the one place this still bites: (1 << 63) - 1 is not 0x7fffffffffffffff, because 1 << 63 is int::MIN’s bit pattern and subtracting one from it is ordinary checked arithmetic, which underflows and traps (docs/defined-behaviour.md). rotr64(x, 1) is the one call site in this file that reaches low_mask64(63), so the trap is not hypothetical — it is written as the literal instead, which is what the value always was.

A related, and separate, gap: a 64-bit literal with the top bit set does not parse. sha512_h0’s and sha512_k’s tables include values like 0xbb67ae8584caa73b, which is int::MIN-or-past as a plain literal — and this language refuses it at the literal, the same way (1 << 63) - 1 is refused at the subtraction, not accepted and then misread. Every such constant here is written as (hi << 32) | lo instead, splitting it into two 32-bit halves that are always comfortably positive literals — a mechanical, checkable transform (each half reconstructed and compared against the original 64-bit value before being written down), not a judgement call made per-constant. Twenty-two of the eighty-eight words in sha512_h0 and sha512_k need it; the other sixty-six could have been written as plain literals and are spelled the same way anyway, so the file has one rule rather than two.


3. The length field is 128 bits; this language’s int is not

FIPS 180-4 §5.1.2 appends a 16-byte big-endian bit-length. sha512 writes only the low 8 of those 16 bytes — total * 8 is a 64-bit int, and the high 8 bytes of the field stay at alloc_slice’s own zero fill. This is exact, not truncated, for every message this language can hold at all: a slice’s length is itself an int, so total is already bounded by the same 64-bit ceiling bit_len = total * 8 would need eleven more bits of headroom to approach, and * is checked — a message long enough to need those upper 8 bytes traps computing total * 8 before sha512 could write a wrong length field, rather than silently wrapping past it. The honest statement is not “this cannot happen,” it is “this language could not hand sha512 a message big enough for it to matter, and if it somehow could, sha512 would refuse rather than mismeasure.”

Corrected by docs/hkdf.md §2. The length field is right, but “every message this language can hold” was false: sha512 copied the padded message into a 64 KiB arena and trapped at 65,536 bytes, long before the length field could matter. It streams now (sha512_init/update/final), and FIPS 180-4’s million-a example passes.


4. Checked, not assumed

Four vectors, matched against Python’s own hashlib.sha512 and, separately, the system sha512sum — not transcribed from either:

InputDigest
""cf83e1357eefb8bdf1542850d66d8007d620e4050b5715dc83f4a921d36ce9ce47d0d13c5d85f2b0ff8318d2877eec2f63b931bd47417a81a538327af927da3e
"abc"ddaf35a193617abacc417349ae20413112e6fa4e89a97ea20a9eeee64b55d39a2192992a274fc1a836ba3c23a3feebbd454d4423643ce80e2a9ac94fa54ca49f
112 bytes of "a"c01d080efd492776a1c43bd23dd99d0a2e626d481e16782e75d54c2503b5dc32bd05f0f1ba33e568b88fd2d970929b719ecbb152f58f130a407c8830604b70ca
"The quick brown fox jumps over the lazy dog"07e547d9586f6a73f73fbac0435ed76951218fb7d0c8d788a309d785436bbb642e93a252a954f23912547d1e8a3b5ed6e1bfd7097821233fa0538f3db854fee6

The 112-byte vector is the one that matters most, the same reason crypto.md’s 56-byte SHA-256 vector does: "" and "abc" both pad into exactly one 128-byte block, so neither exercises the multi-block path. 112 + 17 = 129 > 128 is the smallest input that does, mirroring SHA-256’s 56 + 9 = 65 > 64 at the wider width.

tests/accept/sha512.cho checks all four, byte-for-byte, through --std, on both backends — the_two_backends_agree_on_sha512 in crates/cancho/tests/conformance/backends.rs.

std_declarations_cost_nothing_unless_called (docs/crypto.md §6) needed nothing new here: sha512_h0/sha512_k are reachability-gated by the same fold::collect_static_refs pass §6 built for sha256_h0/sha256_k, which is exactly the case it was built to generalize rather than a second one to find.


5. What is next, and what this slice deliberately does not touch

SHA-512 is a hash function: no key, no secret, and getting it wrong produces a wrong digest that the vectors above would have caught. Ed25519 is a signature scheme with a private key to keep secret and a curve to get right, and crypto.md §1 already drew this line once — “hashing and signing are different primitives with different failure modes” — before there was a second hash function to make the point with a second example.

What Ed25519 needs beyond this slice, named rather than started:

  • Field arithmetic over GF(2^255 - 19). std.bignum (std/bignum.cho) is the closest thing this language has to the bignum Ed25519’s field arithmetic needs, and it is not that thing yet: mul_small multiplies by a machine-word multiplier, not by a second bignum, and there is no modular reduction at all, let alone one shaped for this prime’s own reduction trick (2^255 ≡ 19 mod p). Full multiply and a reduction step are new code, not an extension of what is there.
  • Point arithmetic on the twisted Edwards curve, and the encode/ decode of a compressed point (a sign bit folded into the top bit of the y-coordinate’s byte representation) that Ed25519’s own wire format is built on.
  • Scalar multiplication and clamping, and the deterministic nonce derivation RFC 8032 specifies (itself a SHA-512 call — the one concrete link back to this slice).
  • A constant-time posture worth stating honestly, not assuming. ed25519-dalek, what lex-os-capsule calls today, is written to make key-dependent branching and key-dependent memory access patterns hard to introduce by accident. Nothing forces the same property here yet — this language’s checked-arithmetic traps and ordinary if/match control flow are not designed against timing side channels, and a first correct-by-vectors implementation should not be read as a constant-time one until that is checked specifically, the same way low_mask64’s trap and the literal-parse gap above were each checked specifically rather than assumed away.

None of this is scoped further here on purpose, for the same reason crypto.md §1 gave for not scoping Ed25519 in general: each is its own correctness surface, and bundling the scoping with the one primitive this slice actually finishes is exactly the premature-scope mistake both documents are refusing.

std.ed25519: sign and verify, checked against real openssl

Status: built. docs/sha512.md §5 named this as the slice SHA-512 was a prerequisite for and deliberately did not start: field arithmetic std.bignum could not do, curve point operations, and a constant-time posture nothing here provides. All three are answered below — the first two built, the third answered no, honestly, not deferred a second time.


1. What this is for, and what it is not for

lex-os-capsule signs a capability contract with Ed25519 (ed25519-dalek, in Rust) so a consumer can verify what it is installing before trusting it. Porting that runtime onto cancho (docs/crypto.md §1’s own opening move) eventually needs the same primitive reachable from .cho code — a future lex-os-guest written in cancho, not cancho’s own Rust tooling.

This module is not what crates/cancho-vcs uses to seal its attestation chain. That is Rust code sealing a Rust data structure; ed25519-dalek is already the right tool there, the same way lex-os-audit already uses it, and reaching for this module instead would mean trusting hand-rolled, non-constant-time arithmetic for a job an audited crate already does correctly and safely. This module exists for the day a .cho program itself needs to sign or verify — which today means only the port target above, not yet a real consumer in this repository.

Corrected (#209, review finding C-2): this module has a consumer in this repository. packages/x509 (verify.cho) calls ed25519.verify for a certificate signed with Ed25519 (RFC 8410), in the chain path the pure TLS client relies on. That use is verification only, on values an attacker can already see (the issuer’s key, the to-be-signed bytes and the signature), so §6’s missing constant-time execution does not weaken it. Signing (sign) is still not constant time and is not on the TLS path; #290 tracks making it so.


2. Representation: correctness over speed, throughout

Curve25519 field elements are usually packed into a handful of wide limbs (radix 2^25.5 or 2^51) so a multiply’s cross terms fit efficient hardware multiplication with room for lazy carrying. That representation is exactly the kind of carry-chain arithmetic docs/sha512.md §2 already found one real, non-obvious bug in for a simpler operation (a 64-bit rotate). Ed25519’s own arithmetic — field inversion, point addition, scalar multiplication — is sha512.md’s bar raised by orders of magnitude: hundreds of field operations, each one a fresh chance to get a carry-chain detail wrong in a way four vectors might not catch.

Changed by docs/x25519.md (#200): the field arithmetic is no longer what this section describes. X25519 needs a field with no branch on its data, and #200 asked for one copy shared by both curves. So gf_add/gf_sub/gf_mul/gf_invert/gf_pow2523, bn_modpow and the two exponent tables are gone. Points are now std.field25519 elements (16 limbs of 16 bits, TweetNaCl’s representation), a point is a [int] of 64, and scalar multiplication is a cswap ladder. What follows still describes the scalar arithmetic mod L, which kept the bit-serial bignum. Signing and verifying became 31 and 35 times faster (docs/x25519.md §6), and every vector below gives the same bytes.

So this module makes one deliberate trade, everywhere: a “big number” is a little-endian [byte] slice — usually 32 bytes — and every operation on one is bit-serial. bn_reduce_wide reduces a wide value mod a fixed modulus one bit at a time: shift the running remainder left, bring in the next bit, subtract the modulus back out if the remainder reached it. bn_modpow is ordinary left-to-right square-and-multiply, walking every bit of the exponent whether or not it is set. Both are asymptotically worse than a radix-limbed implementation and neither has a carry chain to get subtly wrong — the whole toolkit is compare, add, subtract, shift-by-one, and schoolbook multiply, four operations simple enough that bn_test’s own hand-computed cases (200 * 300 mod 1000, 3^4 mod 7, 5 - 3 mod 7) were enough to catch the one real bug it had (§4).

Field arithmetic (gf_add/gf_sub/gf_mul/gf_invert/gf_pow2523) is this toolkit fixed to the field modulus p = 2^255 - 19. Scalar arithmetic (k*a mod L, r + k*a mod L) is the same toolkit fixed to the group order L instead — one set of primitives, two moduli, rather than a second implementation for the scalar side the way a performance-first design would need.

A point is one 128-byte buffer — extended coordinates (X, Y, Z, T) as four consecutive 32-byte fields, X*Y = T*Z — so a point is a single region parameter to pass around rather than four kept in sync by hand, and p[0..32]/p[32..64]/etc. read a coordinate the same way any other subslice does. point_add is the one unified formula, used for both addition and doubling (point_scalarmult’s own double-and-add), checked independently in Python before any of this was written: repeated doubling-and-adding of the base point by the group order L returns the identity.


3. Constants, derived, not transcribed

p, the group order L, the curve parameter d, a square root of -1 mod p, and the base point B’s own compressed encoding are each a 32-byte static. None were copied from a reference implementation or from memory: d = -121665/121666 mod p, B’s coordinates from y = 4/5 mod p and the curve equation, both computed directly (modular inverse via Python’s pow(x, p-2, p)) and checked before being written down — B is confirmed on the curve, and L * B computed by repeated doubling-and-adding reduces to the identity, the same check §2 already described as this module’s own point-addition proof.

The two literal-syntax findings docs/sha512.md §2 made — a full 64-bit word with its top bit set does not parse, and (1 << 63) - 1 traps rather than producing int::MIN’s low 63 bits — do not recur here: every constant is stored byte by byte (byte_of(0x..) on a [byte], never a 64-bit int literal), so no individual value ever approaches either ceiling.


4. The one real bug, and how it was found

bn_modpow and sign/verify reuse one scratch buffer across many calls — once per bit of an exponent, once per step of a signature. bn_reduce_wide’s first version assumed its output buffer arrived zeroed and left it to the caller, the same contract alloc_slice’s own zero fill gives a fresh buffer. A reused buffer is not fresh: 3^1 mod 7, computed by hand-inlining bn_modpow’s own loop with the actual scratch buffers it uses, returned 1 instead of 3 — correct in isolation (bn_mulmod on freshly-allocated buffers, tested first, gave the right answer every time), wrong the moment the same scratch buffer was asked to hold a second, unrelated reduction without being cleared first.

Fixed by zeroing o at the top of bn_reduce_wide itself, so the function’s own contract no longer depends on which of its callers remembered to clear a buffer before reuse. 3^0 through 3^4 mod 7 (1, 3, 2, 6, 4) and the original three-case hand check both pass after the fix, and every downstream layer built on bn_reduce_wide — field arithmetic, point arithmetic, sign, verify — was re-checked against real openssl output afterward rather than assumed fixed by association.


5. Checked against real openssl, in both directions

Four fresh Ed25519 keypairs, generated with openssl genpkey -algorithm ed25519 and signed with openssl pkeyutl -sign -rawin — over a 1-byte, an 11-byte, a 43-byte and a 512-byte message — each confirmed with openssl pkeyutl -verify before being used as a fixture:

WhatChecked how
Public key derivationpublic_key_from_seed on each seed matches the real key openssl derived from the same seed, byte for byte, all four vectors
Signingsign on each seed+message matches openssl’s own signature, byte for byte, all four vectors
Verificationverify accepts each of openssl’s own signatures — including the 512-byte one this module never produced itself — and rejects it once either the signature or the message is tampered with by one bit

The 512-byte case is the one worth naming: it is verified against a signature sign never touched, over a message longer than one hash block, the strongest check available without a second independent implementation to compare every intermediate value against. tests/accept/ed25519.cho carries the first three; docs/ed25519.md’s own history (this document) is where the fourth, cross-direction check is recorded, since a fixture can only assert what one program computes, not what an external tool already agreed to.

Both backends agree — the_two_backends_agree_on_ed25519 in crates/cancho/tests/conformance/backends.rs.

Added by docs/x25519.md §4.2: two bugs Wycheproof found. None of the vectors above has a signature of the wrong length, and verify trapped on one (it read sig[32..64] unchecked). And an R with x = 0 and the sign bit set was accepted, because RFC 8032 §5.1.3 step 4 was not applied: -0 is 0. Both are fixed, and all 151 cases of Wycheproof’s ed25519_test.json now pass.

Added by docs/hkdf.md §2. None of these messages was long enough to find that sign copied prefix || msg into a 64 KiB arena: a 65,000-byte message trapped. sign and verify now stream their hashes. tests/accept/ed25519_long.cho signs 70,000 bytes, matches OpenSSL’s signature and verifies it.


6. What this module deliberately does not provide

Corrected by docs/x25519.md §3.1, measured with Valgrind. The field arithmetic and the point ladder no longer branch on data. The scalar arithmetic mod L still does: under the ctgrind check, with the seed marked secret, every report from sign is in bn_mulmod, bn_reduce_wide or sign itself, and none is in the field or point code. So signing is still not constant-time, for the reason below, now confined to bn_*.

Not constant-time. Every branch here — bn_sub_into’s borrow, bn_modpow’s bit test, point_unpack’s sign correction — is an ordinary conditional, and an ordinary conditional’s timing can depend on the secret data it branches on. ed25519-dalek (what lex-os-capsule uses today) is written specifically to make that hard to introduce by accident; nothing here makes it hard at all. A timing-oracle attacker with repeated access to signing under a fixed key is the threat model this matters for, and this module is not written against it. Whether that threat model applies depends on where a future consumer runs this — that is a decision for whoever reaches for this module next, not one this document can make in advance.

No key generation from randomness. public_key_from_seed/sign take a caller-supplied 32-byte seed; cancho has no CSPRNG capability today (no random in the capability list linearity-and-effects.md settles), so there is nothing this module could call to generate a fresh one. A caller gets a seed from wherever it already trusts one to come from.

No batch or double-scalar multiplication. verify’s S*B == R + k*A check does two full scalar multiplications and a point add, where a real implementation typically combines them into one double-scalar multiplication for speed. Correct either way; slower than it needs to be, which is this module’s trade throughout, made once more.

std.chacha20: ChaCha20, Poly1305 and the AEAD (RFC 8439)

Status: built. Not independently reviewed. This is sub-issue 2 (#199) of the self-contained TLS 1.3 client (#197): the one cipher suite the pure client will offer, TLS_CHACHA20_POLY1305_SHA256, needs this AEAD and nothing else from symmetric cryptography. Nothing here should be called production-ready before #209’s review, and §7 lists what this slice did not do.


1. Where it lives

std/chacha20.cho, module std.chacha20. It is a file of its own, not part of std/crypto.cho. #197 asks for that until the design document (#198) settles whether these primitives belong in std or in a package: a file of its own can move to packages/ with a one-line change to its module header, and it does not collide with the SHA-384 and HKDF work (#201), which edits std/crypto.cho.

The cost #197 names is real and is paid here: std/ is compiled into the compiler (STD in crates/cancho/src/main.rs, one include_str! per file), so this file ships with a compiler release. It costs nothing in a program that does not import it (std_declarations_cost_nothing_unless_called, docs/crypto.md §6).

The functions, each answering 0 or a negative refusal code:

FunctionWhat it does
block(key, counter, nonce, out)one 64-byte keystream block (RFC 8439 §2.3)
xor(key, counter, nonce, input, output)ChaCha20 encryption and decryption (§2.4)
poly1305(key, msg, tag)the one-time authenticator (§2.5)
seal(key, nonce, aad, plaintext, out)AEAD encryption; out is ciphertext then tag (§2.8)
open(key, nonce, aad, sealed, out)AEAD decryption; checks the tag first, and writes nothing if it is wrong
refusal_tag(code)the stable name of a code

The output is a buffer the caller passes in, as for crypto.sha256 and ed25519.sign. Every refusal has its own tag, and each is one the caller can act on:

CodeTagWhen
-1chacha20-key-lengththe key is not 32 bytes
-2chacha20-nonce-lengththe nonce is not 12 bytes
-3chacha20-output-lengthout is not exactly the size the operation writes
-4chacha20-counter-exhaustedthe message would need the 32-bit block counter to wrap
-5aead-too-shortsealed is shorter than a tag
-6aead-tag-mismatchauthentication failed

A wrong length is refused rather than left to a bounds trap so that no input from the network reaches a trap through this module. A -4 is refused rather than encrypted because a wrapped counter repeats keystream. RFC 8439 §2.8 caps one message at 2^32 - 1 blocks.


2. Representation

ChaCha20 is 32-bit arithmetic on masked 64-bit ints, as in crypto.sha256 (docs/crypto.md §2). Every word lives in [0, 2^32). An addition is m32(wrapping_add(a, b)), and a rotate is m32(x << n | x >> 32 - n), where the right shift is logical because the word’s sign bit is never set (docs/sha512.md §2).

Poly1305 works modulo 2^130 - 5 in five 26-bit limbs, the layout of poly1305-donna’s 32-bit code. The bound that makes it safe on a signed 64-bit int: clamping leaves each limb of r under 2^26, so 5r is under 2^29. Between blocks every limb of the accumulator h is under 2^26, except h1, which takes the last carry and is under 2^26 + 2^10. After a message limb is added, every limb is under 2^28. A product is then under 2^57, and the sum of five is under 2^60. The carries out of those sums are under 2^34, and 5 * c folded into h0 is under 2^37. No addition or multiplication can overflow on any input.

Superseded (#381, wide-multiply.md §12): Poly1305 is now three limbs of 44, 44 and 42 bits, the layout of poly1305-donna’s 64-bit code: nine products a block, each taken whole with mul_wide into a two-word accumulator, against 25. s1 = 20 r1 and s2 = 20 r2 are made once per key. With a block added, h’s limbs are under 2^45 (the top under 2^43), a column under 2^92, a carry out under 2^48, and h1 under 2^44 + 48 between blocks (computed from the clamped r). The block loop keeps h in registers across blocks (poly_blocks).

The arithmetic is still written wrapping_add and wrapping_mul, which #199 asks for. A checked + compiles to an overflow test whose condition is computed from the operands. Here the operands are secret, so the test would be a branch on secret data, never taken but present. wrapping_* emits no test (§3).

The final reduction (poly_finish) computes g = h + 5 - 2^130. It takes keep = g4 >> 63, which is all ones when g is negative (that is, when h < p), and selects h & keep | g & ~keep. There is no if on the accumulator. Corrected (review finding B-1, #209): the mask is now value_barrier(g4 >> 63), under edition 6;. x >> 63 is exactly the spelling LLVM proves to be 0 or -1 and turns back into a select (docs/value-barrier.md §2), and the rule that every secret mask passes through the barrier where it is made (§4 there) postdates this module.


3. Secrets

The key, the keystream, the one-time Poly1305 key and the plaintext are secret. The rule, from #199: no branch and no memory index depends on a secret byte. Lengths, counters, slice indices and the result of the tag comparison are public, and the code branches on them.

What was checked: the object code. scripts/chacha20_branches.py disassembles every function that touches secret words (quarter, rotl32, m32, le32, dot5, poly_block, poly_finish, and bytes.store_le32, which writes key-dependent words) and lists every conditional jump with the instruction that sets its flags. On the LLVM backend (x86-64), every conditional jump goes to a ud2 trap. Each one follows a compare of a slice length with a constant, a compare of a constant index with a length, an overflow check on index arithmetic (at + 3), or a range check on a shift amount. None of these reads secret data. The masked select in poly_finish was compiled into five cmov instructions, not a branch. cmov takes the same time whichever way it goes on current x86 cores. That is a property of the processor, not one this language promises. No function uses a table indexed by data. Command:

cancho build --std tests/programs/aead_bench.cho --emit obj -o bench.o
python3 scripts/chacha20_branches.py bench.o     # 0 conditional jumps that are not traps

Corrected (review findings B-1 and B-6, #209). “Every function that touches secret words” was wider than the check: the eight functions above are where the secret arithmetic is, but block_into, xor, poly_init, poly_padded, poly1305, aead_tag, seal and open also hold the key, the keystream or r, and were not disassembled. The script now lists them too (LOOPS). They cannot meet the rule above, since they loop over a message on purpose, so their conditional jumps other than traps are printed for the reader and not counted. Read on LLVM, x86-64 (the object built for x86_64-unknown-linux-gnu from aead_driver.cho, which unlike aead_bench.cho calls poly1305 and open): 37 such jumps, each after a compare of a key, nonce, message or output length with a constant or another length, a loop counter, an overflow check on index arithmetic jumped over (jno), a pointer difference (xor’s vectorised loop checks whether input and output overlap), or, once in open, the result of the 16-byte tag comparison (diff != 0, public). None reads a secret byte. And with the barrier, poly_finish compiles to no cmov at all: the select is the &, | and ~ written in the source. The command is now:

cancho build --std tests/programs/aead_driver.cho --emit obj -o aead.o
python3 scripts/chacha20_branches.py aead.o      # 0 conditional jumps that are not traps

What is not claimed: constant time beyond that. No statistical timing test (dudect-style) has been run. #208 runs one over the assembled stack. The Cranelift backend’s object code was not read. The check is for one compiler version: a later LLVM could turn the masked select back into a branch, and only rerunning the script would show it.

3.1 open never hands out a forged plaintext

open computes the expected tag over the associated data and the ciphertext. It compares all sixteen bytes by OR-ing their XORs, so the time does not depend on where the tags first differ. Only then does it decrypt. On a mismatch out is not written at all, not even partly (refuse, don’t downgrade). Every refused case in §4 checks this: the driver fills out with 0xaa first and prints it back.

3.2 Erasing keys: best effort, not a guarantee

aead_tag overwrites the one-time Poly1305 key before its region ends. That is all this module does, and it is not a guarantee. A region’s memory is freed without being cleared. The language has no “secure zero” whose stores the optimiser must keep, and LLVM may delete stores to memory that is freed straight afterwards. The keystream buffer in xor and the caller’s own key are not erased. #205 has to decide what “no secret outlives the connection” can mean in this language. Without a guaranteed-store builtin, the honest answer is “it cannot be promised”, and that is an open question for #198. Corrected (region-chunk-cache.md §3.1): a region’s memory is now cleared when the region ends, by the runtime, on both backends, before its chunk is kept or freed, and the optimiser cannot delete that clearing (the chunk is kept for reuse). What is still not promised: buffers outside regions (the heap, the stack, the caller’s key), and a process that traps.


4. Checked, not assumed

tests/programs/aead_driver.cho drives every function from standard input, one case per line. crates/cancho/tests/conformance/aead.rs runs it in cargo test:

  • RFC 8439, 28 vectors (tests/vectors/rfc8439.txt): §2.3.2, §2.4.2, §2.5.2, §2.6.2 and §2.8.2, and Appendix A.1 (5), A.2 (3), A.3 (11), A.4 (3) and A.5. All pass on both backends. The RFC’s own text could not be reached from the machine this was written on, so the vectors came from two independent transcriptions of RFC 7539 (whose vectors RFC 8439 keeps): tlslite-ng’s unit tests and pyca’s cryptography_vectors. §2.6.2 and A.4 were written down from the RFC and checked against OpenSSL. Every line was checked against OpenSSL before it was committed. §2.1.1 and §2.2.1 test the quarter round and the state before the final addition, which are private here. They are covered only through §2.3.2 and A.1, which they produce.

  • Wycheproof, every case of chacha20_poly1305_test.json (325 cases, committed with its licence in tests/vectors/wycheproof/):

    • 256 valid cases each seal to the expected ciphertext and tag, and open to the expected message;
    • 60 modified tags are refused with aead-tag-mismatch;
    • 9 invalid nonce sizes are refused with chacha20-nonce-length by both seal and open;
    • no refused open wrote its output.

    The test asserts these counts exactly.

  • Every refusal tag is reached by its own case, and so is the last block before the counter wraps, which is allowed.

  • One flipped bit anywhere: §2.8.2’s sealed message with each of its 1,040 bits flipped in turn, and each of the 96 bits of its associated data. All 1,136 are refused, and none writes output. A tag check that skipped a byte, or ignored part of the authenticated data, would let at least eight of them through.

4.3 Against OpenSSL

scripts/aead_differential.py uses OpenSSL’s EVP implementation through pyca/cryptography, which bundles its own OpenSSL (4.0.1 here). For random (key, nonce, aad, message) it checks three things:

  • a message sealed here opens in OpenSSL, and the sealed bytes are equal to OpenSSL’s, since the AEAD is deterministic;
  • a message sealed by OpenSSL opens here;
  • OpenSSL’s sealed message with one random bit flipped is refused by both.

Messages are 0 to 300 bytes, with one case in ten up to 4,100 bytes.

BackendCasesChecksDifferences
LLVM100,000300,0000
Cranelift10,00030,0000
cancho build --std tests/programs/aead_driver.cho -o aead
python3 scripts/aead_differential.py ./aead 100000

#199 named openssl enc or a C harness. openssl enc has no AEAD mode, and pyca’s AEAD is a thin binding over EVP_aead/EVP_CIPHER, so it is OpenSSL’s code that gives the answers. The script is not run in CI because pyca/cryptography is not a dependency of this repository.


5. Mutants

A test that cannot fail proves nothing (CONTRIBUTING.md). scripts/chacha20_mutants.py builds 18 copies of std/chacha20.cho, each with one deliberate bug, as a local module beside the driver. It runs each against the RFC table, every Wycheproof case, the bit flips, the counter edge, and 2,000 differential cases. It first checks that the unmutated file passes.

MutantKilled by
rotation 16 becomes 15; rotation 7 becomes 8§2.3.2
nine double rounds, not ten§2.3.2
a wrong constant; the input words not added back§2.3.2
a skipped Poly1305 carry; r not clamped; a wrong product§2.5.2
never reduced below p; h - p computed as h + 5§2.5.2
the carry into the tag’s second word droppedA.3 #4
the 2^128 bit missing from a pad16 block§2.8.2
the lengths block in the wrong order§2.8.2
encryption from counter 0, not 1§2.8.2
a one-byte-short tag compareWycheproof (modified tag)
plaintext released on a bad tagWycheproof (open wrote its output)
the block counter allowed to wrapthe counter edge
a nonce longer than 12 bytes acceptedWycheproof (nonce size)

18 mutants, 18 killed, in 9 seconds: python3 scripts/chacha20_mutants.py target/release/cancho.

The first run had one survivor, the long nonce. The script then accepted any refusal for an invalid Wycheproof case, while aead.rs already checked the exact tag: a 16-byte nonce was refused with aead-tag-mismatch instead of chacha20-nonce-length, and the script let that pass. The script now checks the tag, as the test does, and the mutant is killed.


6. Throughput

tests/programs/aead_bench.cho seals one message repeatedly, each round over the previous round’s ciphertext, so no round can be skipped. The time is the difference between 1 round and many (median of 5). The copy back into the input is inside the timed loop, so these numbers slightly understate seal itself. One core of an Intel Xeon at 2.80 GHz:

MessageLLVM backendCranelift backendopenssl speed -evp chacha20-poly1305
16,384 bytes135 MB/s (121 µs)26.6 MB/s (616 µs)2,290 MB/s
1,024 bytes123 MB/s (8.3 µs)25.0 MB/s (41 µs)2,129 MB/s
64 bytes62 MB/s (1.04 µs)12.5 MB/s (5.1 µs)533 MB/s
cancho build --std tests/programs/aead_bench.cho -o aead_bench
time ./aead_bench 1 16384; time ./aead_bench 4096 16384
openssl speed -evp chacha20-poly1305 -seconds 2 -bytes 16384

OpenSSL is about 17 times faster on full records. It uses SIMD assembly (AVX2/AVX-512 on this machine), and this is scalar code. The cost that matters for #197 is per record: a full 16 KiB TLS record costs 121 µs to seal on the default backend. A webhook delivery of a few kilobytes costs tens of microseconds, small next to a handshake’s public-key operations. #210 measures that against OpenSSL end to end. Nothing here vectorises: quarter is called through a function, not inlined across the four columns of the state. That is the first place to look if the cost ever matters.


7. Not done here

  • A statistical timing test (§3). Deferred to #208, which runs one over the X25519 ladder and the AEAD together.
  • A guaranteed erase of key material (§3.2). The language has no primitive for it. That is an open question for #198, not something this module can fix alone.
  • The std-or-package decision (§1). That belongs to #198. This file is placed so either answer costs one line.
  • CI for the differential and mutant scripts (§4.3, §5). Both need Python’s cryptography. The Wycheproof cases, the RFC vectors, the refusals and the bit flips run in cargo test, with no network.
  • An independent review. #209.

SHA-384, HMAC and HKDF for the TLS 1.3 key schedule

Status: built. Not independently reviewed. Sub-issue 4 (#201) of the self-contained TLS 1.3 client (#197). The client’s key schedule (RFC 8446 §7.1) is HKDF over the suite’s hash: SHA-256 for TLS_CHACHA20_POLY1305_SHA256, and SHA-384 if a SHA-384 suite is ever added. Building it found that every hash in std trapped on a message of 64 KiB, and so did ed25519.sign; §2 covers that, the fix, and the two documents it corrects. §8 lists what is not done, including the cancho-hooks half of #201. §9 (#446) keeps a key’s padded blocks hashed, so an HMAC under a key used again hashes no pad again, and HKDF and the TLS key schedule use it.


1. Where it lives

WhatWhereWhy there
SHA-384, and the streaming SHA-256/384/512std/crypto.choSHA-384 is SHA-512’s compression (FIPS 180-4 §6.5) with other initial words and a shorter digest. #201 asks for “reuse, no second copy”, and compress512 is private to this file, so SHA-384 goes beside it. It adds eight initial words and three short functions.
HMACstd/hmac.cho, module std.hmacits own file, as #197 asks, so it can move to a package
HKDF, Expand-Label, Derive-Secretstd/hkdf.cho, module std.hkdflikewise

The hash is named by its digest length: 32 means SHA-256 and 48 means SHA-384. Anything else is refused with hash-unsupported. That is the number HKDF and the key schedule already carry (Hash.length), so a second name for the same choice would only be a chance for the two to disagree.

crypto.sha256 / sha384 / sha512(message, digest)            one-shot, as before
crypto.sha{256,384,512}_{init,update,final}(state, ...)     streaming
hmac.sha256(key, msg, out)   hmac.sha384(...)   hmac.mac(hash_len, ...)
hmac.init / update / final(hash_len, state, ...)            streaming
hkdf.extract(hash_len, salt, ikm, prk)
hkdf.expand(hash_len, prk, info, okm)
hkdf.expand_label(hash_len, secret, label, context, okm)    RFC 8446 §7.1
hkdf.derive_secret(hash_len, secret, label, transcript_hash, out)

An output’s length is the length of the slice the caller passes, so there is no second length to contradict it. derive_secret takes the transcript’s hash, not the messages: the transcript hash runs for the whole handshake, so its state belongs to the caller (#205). It is the streaming SHA-256 state below.

Every HMAC and HKDF function answers 0 or a negative code, and hkdf.refusal_tag(code) names it. Each code corresponds to one rule a caller can act on:

CodeTagWhen
-1hash-unsupportedhash_len is not 32 or 48
-2hash-output-lengthan output that must be one digest long is not
-3hmac-state-lengtha streaming state is not hmac.state_len(hash_len) words (since #446, §9: or a prepared key not hmac.prepared_len(hash_len), or a state fed since init given to hmac.keep)
-4hkdf-length-too-largemore than 255 * hash_len bytes asked of Expand (RFC 5869 §2.3), or more than 65,535 of Expand-Label
-5hkdf-prk-lengtha PRK shorter than the hash (RFC 5869 §2.3)
-6hkdf-label-lengtha label of 0 or more than 249 bytes ("tls13 " + label is <7..255>)
-7hkdf-context-lengtha context over 255 bytes
-8hkdf-transcript-hash-lengtha transcript hash that is not one digest long

A length outside the structure is refused rather than truncated into some other, valid structure. The hashes keep their old contract (docs/crypto.md §4): an output buffer that is too short traps on its bounds check. That is a mistake in a program, and no input can cause it.


2. The 64 KiB trap, and the streaming hash that removes it

crypto.sha256 built its padded copy of the whole message in an arena: alloc_slice[m](padded_len, ...). An arena is one 64 KiB chunk, and asking it for more traps (docs/defined-behaviour.md). So SHA-256 of 65,536 bytes trapped (SIGILL, status 132), and so did SHA-512. ed25519.sign trapped earlier, at 65,000 bytes, because it copied prefix || msg into an arena before hashing. Measured, not inferred:

sha256 of 1,000 / 65,000 / 65,536 / 70,000 bytes of `a`:  exit 0 / 0 / 132 / 132
ed25519.sign of 1,000 / 65,000 / 70,000 bytes:            exit 0 / 132 / 132

This corrects three claims made in place:

  • docs/crypto.md §4 implied sha256 hashes any message.
  • docs/sha512.md §3 said the length field is exact “for every message this language can hold”. The length field was right; the message never reached it.
  • cancho-hooks src/sign.cho boxes its ipad || message copy on the heap because “a payload can be 64 KiB and an arena is not”. It then passes that copy to crypto.sha256, which copied it into an arena again. A delivery whose signed content is 65,536 bytes or more would trap the service (§7).

The fix is a streaming hash. A state is a [int] the caller owns: the eight running words, the byte count, the buffer, and the message schedule. update compresses whole blocks straight from the caller’s slice, and copies only a partial block into the buffer. final pads inside the buffer. The one-shot functions are init, update and final on a state in one small arena (1.1 KiB for SHA-256, 1.7 KiB for SHA-512), so they hash any length. ed25519.sign and verify stream prefix, R, A and the message into one state rather than concatenating them. HMAC and HKDF are built on the same states, so nothing in this slice allocates in proportion to its input.

The schedule and buffer live in the state, not in an arena per call. The first version of the streaming code opened an arena in update, in final, and in each compress (for the message schedule w), as the old code did. That made a 64-byte SHA-256 cost 19 µs against the old 10.7 µs, because every arena is a malloc. Moving the buffer and the schedule into the caller’s state removed every allocation but the one-shot wrapper’s. §6 has the numbers: the same 64-byte hash now takes 0.99 µs.


3. Secrets

HMAC and HKDF run over secret keys: the PSK, the ECDHE shared secret, and every traffic secret. The rule from docs/chacha20.md §3 applies here too: no branch and no memory index may depend on a secret.

  • SHA-256’s additions are now wrapping_add under the mask. docs/crypto.md §2 had reasoned that a checked + under mask32 never traps, which is true. But the check is still an overflow test on the hashed key’s words, so it is gone.
  • not32 is now x ^ 0xffffffff, where it was 0xffffffff - x. The value is the same on [0, 2^32), but the subtraction was checked and the XOR is not.
  • The object code was read. scripts/chacha20_branches.py, with function names, disassembles compress, compress512, rotr32, rotr64, lshr64, low_mask64, mask32 and not32 from the LLVM build of tests/programs/kdf_driver.cho. Every conditional jump goes to a bounds trap, except six loop back-edges. Those compare the round counter with 64 or 80, or a byte offset with 512 or 640. There are also three compares of a shift amount with a constant (the low_mask64(63) case of docs/sha512.md §2), and every shift amount is a constant at its call site. None reads the data being hashed. Before the not32 change, compress had an overflow jump (jo) after a subtraction on the round word e; after it, there is none.
  • HMAC’s branches are on public lengths. It branches on the key’s length (hashed first, or padded) and on the hash’s block size. The key is padded to a block. The ipad block is cleared before its arena ends, and the opad block stays in the caller’s state until final clears it. Corrected (#446, §9): the state now keeps the outer hash’s eight words after the opad block, not the block, and final clears them; both padded blocks are cleared before init‘s region ends. HKDF-Expand clears its last T(i). All of these are best effort, for the reasons in docs/chacha20.md §3.2. The hash states’ own buffers and schedules, which hold key-derived words, are not cleared. Corrected (region-chunk-cache.md §3.1): those in a region are now, since every region’s bytes are cleared when it ends.
  • Not claimed: constant time measured statistically (that is #208), or anything about the Cranelift backend’s object code.

4. Checked, not assumed

tests/programs/kdf_driver.cho drives every function from standard input. crates/cancho/tests/conformance/kdf.rs runs it in cargo test, offline.

4.1 SHA-2: NIST CAVP

Every case of the byte-oriented CAVP response files (tests/vectors/cavp/, NIST’s files as pyca’s cryptography_vectors carries them):

FileCases
SHA-384 short messages / long messages / Monte Carlo129 / 128 / 100 checkpoints (100,000 hashes)
SHA-256 short / long / Monte Carlo65 / 64 / 100 checkpoints
SHA-512 short / Monte Carlo129 / 100 checkpoints

All pass. The long-message file for SHA-512 (1.7 MB) is left out: the SHA-384 file runs the same compression and the same streaming code, and the differential (§4.3) covers SHA-512 at up to 200 KiB. Beyond the files, the test also checks:

  • FIPS 180-4’s million-a examples for all three hashes, streamed;
  • 65,536 and 200,000 bytes, one-shot, which trapped before (§2);
  • every length from 0 to 300 bytes, one-shot and streamed in pieces of 1, 7, 63, 64, 65, 127, 128 and 129 bytes, all equal.

tests/accept/ed25519_long.cho signs 70,000 bytes. The signature equals OpenSSL’s and verifies, and a tampered one does not.

4.2 HMAC, HKDF and the TLS 1.3 key schedule

All 62 rows of tests/vectors/kdf.txt pass on both backends (59 when this was written; #205 added RFC 8448’s three application-stage secrets):

  • RFC 4231: HMAC-SHA256 and HMAC-SHA384, test cases 1–4, 6 and 7. Case 5, the truncated tag, is missing from the transcription used.
  • RFC 5869: A.1 to A.3, extract and expand separately, PRK included. Plus a 1,200-byte HKDF output from pyca’s vectors.
  • The TLS 1.3 key schedule from three sources.
    • RFC 8448 §3, the “Simple 1-RTT Handshake”. The early secret, the derived secrets, the handshake secret, both handshake traffic secrets with their keys and IVs, the server’s finished key, the master secret and the resumption master secret. The RFC’s text was not reachable from the machine this was written on, so these values were written down from the RFC. Each was kept only if Python’s hmac/hashlib reproduces it from the trace’s own inputs. The client and server application traffic secrets and the exporter secret did not reproduce from the transcript hash as written, so one of the two was written wrong. They were left out rather than “fixed” to match. Settled since, in #205 (docs/tls-core.md §6.1): AWS s2n-tls transcribes RFC 8448 §3 in tests/unit/s2n_tls13_secrets_rfc8448_test.c, with the transcript hash through the server’s Finished (9608102a…). The three secrets reproduce from it in Python, and they are now rows of kdf.txt. So was every other value in that file, from the X25519 keys onwards. The values first remembered were not kept, so which of the two was written wrong is not known.
    • NIST ACVP’s TLS-v1.3-KDF-RFC8446 case, as Go’s crypto/tls TestACVPVectors carries it. This is a PSK handshake, and it covers every secret from the early traffic secret to the resumption master secret, the application traffic secrets included.
    • OpenSSL’s test/tls13secretstest.c, the draft-06 vectors that OpenSSL’s TLS 1.3 passes. They cover every traffic secret, key and IV for both sides.

Wycheproof, every case (tests/vectors/wycheproof/):

FileValidInvalidResult
hmac_sha256_test.json66108all pass
hmac_sha384_test.json66108all pass
hkdf_sha256_test.json833all pass
hkdf_sha384_test.json803all pass
  • For HMAC, a truncated tag is compared with the front of the full tag, and every modified tag differs from it.
  • For HKDF, every valid case gives its output. The three oversized requests per hash are refused with hkdf-length-too-large, and the largest legal size (255 * hash_len) is accepted.

Every refusal tag in §1 is reached by its own case, and none writes its output. The edges just inside each limit are accepted:

  • an output of exactly 255 * hash_len;
  • a 249-byte label with a 255-byte context;
  • outputs of zero bytes.

4.3 Against OpenSSL and Python

scripts/kdf_differential.py compares against Python’s hashlib and hmac (OpenSSL’s digests underneath), pyca’s HKDF (OpenSSL’s EVP_KDF), and RFC 8446’s HkdfLabel built in Python. Each round checks eight things:

  • SHA-256, SHA-384 or SHA-512, one-shot and streamed in random pieces. One round in fifty uses 64 to 200 KiB, past the old trap.
  • HMAC-SHA256 and HMAC-SHA384, with keys of 0 to 300 bytes, which crosses both block sizes.
  • A whole HKDF under each hash, with the output size up to the RFC’s maximum on one round in twenty.
  • An Expand-Label under each hash, with random labels and contexts.
BackendRoundsChecksDifferences
LLVM100,000800,0000
Cranelift2,00016,0000

5. Mutants

scripts/kdf_mutants.py builds 19 mutants of the three files, as local modules beside the driver. It runs each against the evidence of §4: the vector table, the CAVP short and Monte Carlo files, every Wycheproof case, the streamed-against-one-shot cases, the refusal edges, and 1,000 differential rounds. 19 mutants, 19 killed, in 23 seconds.

FileMutants
crypto.choSHA-384 started from SHA-512’s words; one wrong SHA-384 word; the SHA-256 length in bytes, not bits; the SHA-512 length field one byte early; the padding-room check off by one; a round adding temp1 twice; a buffered SHA-512 byte dropped
hmac.chothe wrong inner pad; the wrong outer pad; a block-length key hashed first; SHA-384’s block taken as 64 bytes; the inner digest left out of the outer hash
hkdf.chothe counter off by one; T(i-1) not chained; 255 * HashLen allowed past; the label prefix misspelt; the output length’s high byte dropped; a 250-byte label accepted; a transcript hash of any length accepted

What the run found:

  • A buffered SHA-512 byte dropped survived every fixed vector. Fixed vectors are hashed in one call, so the code that fills a partly full buffer never runs. Only the differential’s random pieces killed it. The cargo test’s streamed-against-one-shot cases now cover that path, the mutant script runs them too, and the mutant is killed there.
  • Three hkdf mutants were killed by a trap, not by a wrong answer. With the 255 * HashLen check loosened, or a 250-byte label allowed, byte_of(256) traps building the counter or the length byte. The language’s checked conversion stood behind the missing refusal, so even without the check no input could produce wrong bytes. But a trap is not a refusal (“no input may reach a panic”), so the checks stay, and the refusal test is what pins them.

6. Cost

One core of an Intel Xeon at 2.80 GHz, LLVM backend, median of 5. The time is the difference between 1 call and many. “Before” is main‘s std/crypto.cho from before this change, and hooks’ hmac_sha256 over it, built beside the new code in one program.

64 bytes1 KiB16 KiB
SHA-256, before → after10.7 → 0.99 µs—113 → 137 MB/s
SHA-512, before → after9.6 → 0.59 µs—211 → 251 MB/s
HMAC-SHA256: hooks’ sign.hmac_sha256 → hmac.sha25621.8 → 2.6 µs32.7 → 9.9 µs184 → 120 µs

The small-message gain is §2’s arenas. The old code opened four for a 64-byte message (the state, the padded copy, and one per block, of which there are two), and the new code opens one. A TLS 1.3 key schedule is a few dozen HMAC calls over short inputs, so tens of microseconds. A webhook signature over a 1 KiB body costs about 10 µs.


7. cancho-hooks

#201 asks for cancho-hooks to use this HMAC instead of its own (src/sign.cho), and for its tests/sign_test.py to still pass against the reference Standard Webhooks library. That is a second repository, pinned to a compiler revision (cancho.toml), and the revision has to contain std.hmac. So it lands after this change: a PR on cancho-hooks that deletes sign.hmac_sha256, calls hmac.sha256, moves the pin, and records the 64 KiB trap of §2 with a test that reaches it.


8. Not done here

  • The hooks switch (§7). It follows this change’s merge, as its own PR on cancho-hooks.
  • RFC 8448’s application traffic secrets (§4.2). Added by #205 from s2n-tls’s transcription of the RFC (§4.2).
  • A statistical timing test. That is #208.
  • SHA-512 long messages from CAVP. They are covered by SHA-384’s file and the differential, not by NIST’s own file (§4.1).
  • CI for the differential and mutant scripts. Both need Python’s cryptography. Everything else runs in cargo test.
  • An independent review. That is #209.

9. A key’s pads hashed once (#446)

Built, with the SHA-256 builtin (sha256-builtin.md), as lever D4 of tls-performance.md §10.8.

9.1 What was asked

hmac.init hashed the key’s inner padded block on every call, and final the outer one, and each opened a region for its buffers. HKDF-Expand-Label calls HMAC with the same key several times in a key schedule: the handshake secret gives three secrets, each traffic secret a key and an IV. tls-performance.md §10.4 measured hmac.init at 9.9% of a full server handshake inclusive, 8.1 points of it the regions’ memory, so at most 1.8 points were the two compressions; the regions were A1’s to remove (§10.11) and the compressions this section’s.

9.2 What a prepared key is

After K0 XOR ipad and K0 XOR opad (RFC 2104 §2) each fill exactly one block, the inner and the outer hash are each eight words and a count of one block. A prepared key is those sixteen words: hmac.prepared_len(hash_len) is 16 for SHA-256 and SHA-384 alike (SHA-384’s words are SHA-512’s, 64 bits each, in an int each).

hmac.prepare(hash_len, prepared, key)            the two pads hashed once (a region, for the scratch state)
hmac.init_prepared(hash_len, state, prepared)    an HMAC started from it: two copies of eight words, no compression
hmac.mac_prepared(hash_len, prepared, msg, out)  one tag under it (one region, the state's)
hmac.keep(hash_len, state, prepared)             the prepared key of a state `init` has just started
hmac.forget(prepared)                            cleared
hkdf.prepare(hash_len, secret, prepared)         `hmac.prepare`, with HKDF-Expand's PRK-length refusal
hkdf.expand_label_prepared(...), hkdf.derive_secret_prepared(...)
hkdf.prepared_len(hash_len), hkdf.forget(prepared)

std.crypto gives the operations this needs, with no other knowledge of its layout leaving it: sha256_save (the eight words of a state at a block boundary; -1 and nothing written if bytes are waiting), sha256_resume (a state that goes on from them after a given number of bytes), sha256_finish (the padding, the digest left as the running words) and sha256_update_words (big-endian words fed at a word boundary), and the sha512_ four.

The HMAC state changed with it. It was the hash state and then the outer padded block, block_len words kept for final to hash; it is now the hash state, the outer hash’s eight words, and eight words for the inner digest, so hmac.state_len is the hash state’s length plus 16 (154 for SHA-256, 234 for SHA-384, from 202 and 346). init hashes both pads, keeping the outer words, so a plain HMAC still costs two pad compressions, now both in init. final opens no region and writes nothing but the tag: it finishes the inner hash (sha256_finish, the padding without the bytes out), keeps its digest words in the state, resumes the outer hash from the kept words, feeds it the digest as words (sha256_update_words; sha512_ for SHA-384, six 64-bit words), finishes into out, and clears the sixteen words. (The first version wrote the inner digest into the caller’s out and hashed it from there; the timing test found that it made the time depend on whether out already held the same bytes, sha256-builtin.md §8.4.)

9.3 Where a key is used again

  • hkdf.expand prepares its PRK once (from the state its init just made, hmac.keep) and starts every T(i) from it, so an output of n digests hashes the pads once, not n times. Its regions went from one a call plus two a block (init’s and final’s) to two a call; expand_label from four a call to two.
  • The TLS key schedule, kept minimal (packages/tls): tls_slot.traffic_keys derives a traffic key and an IV from one secret, so it prepares the secret once and expands both labels from it; and the server’s and the client’s handshake secrets (handshake_secrets in server.cho, the same block in client.cho) prepare the handshake secret once for its three Derive-Secrets. Nothing else in the package changed, and nothing new is kept in a slot: each prepared key lives in the calling function’s region and is cleared before it ends (and the region itself is cleared when it ends, region-chunk-cache.md §3), so tls.residue has nothing new to count, and the pool’s checks that it stays 0 pass unchanged.
  • Not changed: the master secret’s three Derive-Secrets are in three functions that run at different points of the handshake (keeping a prepared master secret would mean keeping it in the slot); the Finished keys and the binder are used once each; TLS 1.2’s PRF (record.cho, which #447 and #448 are changing) calls hmac.init with one secret in a loop and is the next user if it matters.

9.4 Tested

conformance/kdf.rs runs every HMAC, Expand-Label and Derive-Secret row of tests/vectors/kdf.txt and every Wycheproof HMAC case again under a prepared key, on both backends; the driver (tests/programs/kdf_driver.cho, ops P, Q, R) also checks that a tag from mac_prepared equals one streamed after init_prepared in pieces of 1 to 129 bytes, and that a prepared secret gives the same label twice. The prepared functions’ refusals each have a row. scripts/kdf_differential.py adds a prepared HMAC and a prepared Expand-Label to every round, against Python’s hmac; scripts/kdf_mutants.py has mutants of the resume, the kept words, the copies and the words fed (sha256-builtin.md §8.2). What it cost and saved is sha256-builtin.md §8.5.

A TLS 1.3 client with no C library: the design

Status: design, before any protocol code (#198, sub-issue 1 of #197). Two primitives it needs are already built, under the epic’s provisional placement rule (§1): ChaCha20-Poly1305 (docs/chacha20.md, #212) and SHA-384/HMAC/HKDF (docs/hkdf.md, #229). Everything else here is a decision about code that does not exist yet. Each decision gives the alternatives and the reason. Each claim about existing code names the file it was read from. The things a person has to decide are listed in §10, not settled by default.

Nothing built from this design may be called production-ready before #209’s independent review. Until then cancho-hooks keeps the OpenSSL backend (docs/tls-nonblocking.md) as its default.


1. Where the code lives

1.1 The decision

WhatWhereStatus
ChaCha20-Poly1305std/chacha20.chobuilt (#212)
SHA-384, streaming SHA-2, HMAC, HKDF, Expand-Labelstd/crypto.cho, std/hmac.cho, std/hkdf.chobuilt (#229)
X25519std/x25519.cho, sharing a field module with std/ed25519.chobuilt (#231)
a modular bignum, RSA verificationstd/bigmod.cho, std/rsa.chobuilt (#234)
ECDSA P-256/P-384 verificationstd/ecdsa.cho, on std/bigmod.chobuilt (#239)
DER, X.509 parsing, chain building, name matchingpackages/x509parsing built (#233); the rest #206
the handshake, the record layer, the key schedulepackages/tls#205

Primitives with fixed test vectors stay in std. Protocol and policy go in packages.

1.2 The alternatives and their costs

everything in stdthe split (chosen)everything in packages
how a change shipsa compiler release, since std/ is compiled in (STD in crates/cancho/src/main.rs, one include_str! per file)primitives with the compiler; protocol and policy on their own scheduleon their own schedule
who must move a pinevery user of the change: cancho-hooks pins one compiler commit (cancho.toml, [package] cancho), so a root-store or cipher-policy fix becomes a coordinated two-repo bumpa CVE fix in the handshake or in name matching is a package bump; a primitive fix is a compiler bumpa package bump
test-vector and differential checksin cargo test, offline (crates/cancho/tests/conformance/aead.rs, kdf.rs)the same for primitives; packages carry their own harnesses (as examples/tls_nb/test/ does)each package must build its own harness
what other programs geteverythingprimitives anyone can use: cancho-hooks already moved its HMAC to std.hmac (cancho-hooks#23)packages they must find and pin
cost when unusednothing (std_declarations_cost_nothing_unless_called, docs/crypto.md §6)nothingnothing

The cost #197 named, written down. Every primitive in std is a compiler change, and each fix to one needs a coordinated compiler bump in every consumer. Two things make that cost acceptable for primitives and not for protocol code:

  • Primitives are fixed by their specifications and their vectors, so they change rarely. RFC 8439 and FIPS 180-4 do not move; hardening (constant-time work) changes how they compute, not their interface.
  • Protocol policy changes often: cipher suites, root stores, name rules, CVE fixes. Its users need to take a fix without taking a compiler.

One cost was paid already, and it argues for the split. The 64 KiB trap of docs/hkdf.md §2 was a bug in std/crypto.cho. It reached cancho-hooks through its compiler pin, and its fix reached hooks the same way, with no change to hooks’ own code. A primitive in std is shared, and so is its fix.

What is not settled by this, and goes to §10: whether the bignum and the curves (#203, #204) belong in std too. They will change while they are hardened, which is the argument for a package. They have fixed vectors, and RSA/ECDSA verification is useful outside TLS (cancho-hooks verifies nothing today), which is the argument for std. This document assumes std, in their own files, so that a later move is mechanical.


2. The API of packages/tls: bytes in, bytes out

2.1 What it has to fit

The OpenSSL backend already chose memory BIOs over a descriptor (docs/tls-nonblocking.md §3.2, decision D1). Its program reads and writes the socket and hands OpenSSL bytes. That is a sans-io interface: OpenSSL’s BIO_write(rbio) is “bytes in”, and BIO_read(wbio) is “bytes out”. So one interface fits both backends with no adapter, and a consumer (hooks’ attempt.cho) switches backend by dependency, not by code (#210’s gate). Corrected (#210, docs/tls-hooks.md §2.1 and §2.2): the byte movement is the same, the interfaces are not. Hooks’ tls.cho owns the socket I/O and a connection’s state is the caller’s integers, where this engine owns its slots, so hooks needs an adapter module. And hooks’ functions carry Ffi rows that one source cannot also carry without them (docs/effect-polymorphism.md), so a pure build cannot share attempt.cho and hooks.cho unchanged.

The second constraint is how cancho-hooks holds connections. It keeps 64 attempts as slots: integer arrays indexed by slot, the Conns in a std.conns.Table, one Poller (docs/tls-nonblocking.md §4.2 and §10.3, read from src/attempt.cho). A connection cannot be its own linear value inside a container, because containers hold only copyable things (examples/tls_nb/gaps/g10_thread_in_container.cho, the same rule). So the engine owns its slots, as rtcp.Resolver does (examples/tls_nb/rtcp.cho).

2.2 The interface

// Once, in main.
tls.open(heap, slots) -> Engine                           // owns every slot's state: one Box of ints, one of bytes
tls.trust(heap, engine, pem_bundle) -> (Engine, int)      // roots from a PEM bundle the caller read; count, or a refusal
tls.seed(engine, seed)                                    // 32 bytes of entropy the caller read (§6)
tls.close(heap, engine)

// Per connection, in slot s.
tls.start(engine, s, host, now_unix_ms) -> 0 | refusal   // ClientHello queued; `host` is checked against the certificate
tls.feed(engine, s, bytes) -> consumed | refusal         // bytes the socket gave; may complete handshake steps
tls.take(engine, s, out) -> n                            // bytes for the socket (0: none waiting)
tls.send(engine, s, plaintext) -> accepted | refusal     // once established: one record at a time
tls.recv(engine, s, into) -> n | 0 (close_notify) | refusal
tls.finish(engine, s)                                    // queue close_notify
tls.event(engine, s) -> want_read | want_write | established | closed | failed
tls.failure(engine, s) -> code;  tls.refusal_tag(code) -> name
tls.drop(engine, s)                                      // the slot is free (keys overwritten as far as the language allows, §7.3)

As built (docs/tls-core.md §10.1), three differences:

  • tls.trust(engine, pem_bundle) -> int borrows the engine rather than moving it;

  • tls.eof(engine, s) tells the engine the socket ended: without a close_notify first, the connection fails tls-peer-closed;

  • tls.recv answers tls.would_block() when nothing is waiting, as 0 already means close_notify.

  • No capability is taken. Time is a number the caller passes (now_unix_ms, from clock_unix_ms). Entropy is bytes the caller passes. The root store is bytes the caller read. So the pure backend’s authority row is empty, and #210’s gate (the pure backend needs no Ffi at all) is a property of the signatures that the checker enforces (rows are exact in both directions: examples/tls_nb/gaps/a3_row_exact.cho, docs/tls-nonblocking.md §6).

  • event is the only thing a poller loop needs. It returns:

    • want_write when take has bytes;
    • want_read when the engine needs input;
    • established once the client’s Finished is queued;
    • closed after a close_notify;
    • failed with a code.

    This is the done | pending | failed shape of tls.handshake in the spike (examples/tls_nb/tls.cho), with the direction made explicit.

  • The OpenSSL backend fits it. feed = BIO_write(rbio) and then SSL_do_handshake or SSL_read. take = BIO_read(wbio). send = SSL_write with partial writes. recv = SSL_read. failure maps (stage, detail) of docs/tls-nonblocking.md §3.6 onto the tags of §8. That backend takes Ffi("tls"), and the pure one takes nothing. That difference is the point.

2.3 Alternatives considered

why not
the engine owns the socket (tls.connect(net, ...), as SSL_set_fd does)it puts Net and the poller inside the package, it cannot be tested without a network, and it is the shape #211 measured and rejected for OpenSSL (SIGPIPE, §3.2 there)
one value per connection, returned and threaded like buffer.Buffer64 of them cannot sit in a container (§2.1), and hooks would need 64 named locals
callbacks (on_read, on_write)a callback cannot capture the caller’s state (docs/function-values.md), and a function value cannot be named across modules as module.function (examples/tls_nb/gaps/g9_qualified_function_value.cho)

3. Cipher policy

3.1 The decision

offeredrefused, with a tag
protocolTLS 1.3 only (supported_versions = 0x0304)a ServerHello for 1.2 or below; the downgrade sentinel in ServerHello.random (RFC 8446 §4.1.3). Corrected (#208): in a ServerHello for TLS 1.2 or below, as the RFC says; docs/tls-assurance.md §4
cipher suiteTLS_CHACHA20_POLY1305_SHA256 only. Since #207: all three TLS 1.3 suites (docs/tls-parity.md §3.3)any other
key exchangeX25519 only, one key share. Since #207: one X25519 share, and a HelloRetryRequest to P-256 or P-384 followed (docs/tls-parity.md §3.3)a HelloRetryRequest (§3.3)
signatures in CertificateVerifyecdsa_secp256r1_sha256, ecdsa_secp384r1_sha384, rsa_pss_rsae_sha256/384/512, ed25519the rest
signatures on certificates (signature_algorithms_cert)the above, plus rsa_pkcs1_sha256/384/512 (what most public CAs sign with)SHA-1 anywhere in a chain that is verified

3.2 Why ChaCha20 only, and what it costs

Superseded (#207, docs/tls-parity.md): the maintainer requires parity with the OpenSSL backend, so AES-GCM (constant-time), P-256/P-384 key exchange with HelloRetryRequest, and TLS 1.2 with ECDHE and AEAD suites are added. §3.2 to §3.4 record the first decision.

AES-GCM is what most servers prefer, and every server that speaks TLS 1.3 must implement TLS_AES_128_GCM_SHA256 (RFC 8446 §9.1). But constant-time AES and GHASH in software are slow:

  • A table-based AES leaks its key through cache timing.
  • A bitsliced AES is about an order of magnitude slower than table-based.
  • GHASH needs a carry-less multiply, and this language has no intrinsics at all, so a constant-time GHASH is a long sequence of masked shifts and XORs.

ChaCha20-Poly1305 is constant-time with nothing but adds, XORs and rotates (docs/chacha20.md §3), and runs at 135 MB/s here (§6 there). Cost: a server that does not offer ChaCha20-Poly1305 refuses the handshake. ChaCha20 is mandatory only as a “SHOULD” (RFC 8446 §9.1), and FIPS-mode servers and some load balancers offer AES-GCM only. How many receivers that is has not been measured. #207 measures it before anything is built for it. The OpenSSL backend, which offers AES-GCM, stays the default in the meantime.

3.3 X25519 only, and HelloRetryRequest

Superseded (#207, docs/tls-parity.md §3.3): a HelloRetryRequest to P-256 or P-384 is followed, with std.ecdh (docs/ecdh.md). This section records the first decision.

A server that does not support X25519 answers the client’s single key share with a HelloRetryRequest for another group, typically P-256. A P-256 key exchange handles a secret scalar, so it needs constant-time P-256 arithmetic. #204 builds only verification, which is variable-time and public-data only.

Decision: a HelloRetryRequest is refused (tls-hello-retry). A server that supports X25519 never sends one to a client that offered an X25519 share. Alternative: implement HRR and P-256 ECDHE, which costs a constant-time P-256 implementation and is deferred with #207’s measurement.

3.4 What a TLS 1.2 fallback would add (#207)

Superseded (#207, docs/tls-parity.md §3.4): TLS 1.2 is built, with ECDHE, AES-GCM and ChaCha20-Poly1305 and the extended master secret required. This section records the first decision.

It would add the TLS 1.2 handshake (a second state machine), the PRF, the extended master secret (required), both downgrade sentinels, and ECDHE on X25519 with ChaCha20-Poly1305 only. It would not add RSA key exchange, CBC or renegotiation. It is justified only by a measured number of receivers that need it (#207’s gate). It is not part of this design.


4. Threat model

4.1 Who the attackers are

attackercanthe design’s answer
the network (active, on path)read, drop, delay, reorder, inject and replay bytes; downgrade attempts; redirect to another serverauthenticated key exchange and the transcript in Finished; AEAD on every record with sequence numbers; the downgrade sentinels; certificate and name verification
the server itself (hooks delivers to customer URLs: the receiver is untrusted input)send any bytes: malformed messages, a 64 KiB certificate chain, endless alerts or KeyUpdates, nothing at allstrict parsing with a tag for each refusal; every size bounded (§5); no input reaches a trap (CONTRIBUTING.md); the caller’s deadline bounds the time (docs/tls-nonblocking.md §10.3)
a certificate (mis-issued, or for the wrong name, or a CA’s key misused)present a chain that a lenient verifier acceptsSAN-only name matching with strict wildcards, basicConstraints and pathLen, keyUsage/EKU, name constraints, no SHA-1, minimum key sizes (§5)
a timing observer (remote, or another process on the same host)measure how long secret-dependent work takesconstant-time rules on the secret path only (§4.2)

Out of scope, and said so:

  • revocation (a stolen key with a valid certificate is accepted until it expires);
  • a compromised root CA;
  • an attacker with code execution in the process;
  • physical and power side channels;
  • Certificate Transparency.

4.2 Which parts handle secrets

handles secrets: constant-time rules applyhandles only public data: correctness and refusal apply
the X25519 ladder (#200); HKDF and the key schedule (docs/hkdf.md §3); the AEAD (docs/chacha20.md §3); the DRBG (§6); the Finished MAC comparisonDER and X.509 parsing; chain building; signature verification (RSA, ECDSA, Ed25519); name matching; record framing and handshake parsing (lengths are public)

The rule for the left column is the one docs/chacha20.md §3 applied and checked in object code: no branch and no memory index depends on a secret. The right column may branch freely. Being fast and being correct is what matters there, and #203’s “compare the whole encoded block” is a correctness rule, not a timing one. Finished is compared over all its bytes, as chacha20.open compares a tag.


5. Trust and certificates

5.1 The root store

  • Where it comes from. It is the system PEM bundle (/etc/ssl/certs/ca-certificates.crt on Debian-family systems) or a file the operator names, read by the caller once at start and passed as bytes (§2.2). It is never embedded in a package. cancho-hooks would read it as it reads its endpoints file (src/hooks.cho, read_endpoints_file, Fs("")).
  • What happens if it is unusable. An unreadable or empty store stops the start, with the reason. It never falls back to an unverified client. That is #211’s rule for OpenSSL (docs/tls-nonblocking.md §10.5).
  • How roots are treated. Their self-signatures are not checked. A root is trusted because the operator put it in the store, not because it signed itself. Corrected (docs/x509-verify.md §8.3): this said a root is accepted as an anchor whatever its own validity dates, “which matches OpenSSL’s default”. It does not: OpenSSL 3.0.13 refuses an expired root (x509-limbo’s rfc5280::validity::expired-root, error 10 at the root’s depth). The verifier checks a root’s dates as OpenSSL does. §10’s question 4 is answered by that.

5.2 Building the chain

  • Which certificates are used. Only the certificates the server sent, plus the roots: no AIA fetching and no intermediates cache.
  • Order. The leaf is first, and the rest may come in any order, as RFC 8446 §4.4.2 allows.
  • Limits:
    • depth at most 8 certificates, leaf included;
    • the Certificate message at most 64 KiB (§7.1);
    • each certificate at most 16 KiB;
    • no certificate used twice in a path, so a loop is refused, not followed. Corrected (review finding D-2, #209): no position in the Certificate message used twice; two copies of one certificate can both stand in a path, bounded by the signature budget and the depth (docs/x509-verify.md §3).
  • Per certificate in the path:
    • the signature, with the issuer’s key;
    • the validity: notBefore <= now <= notAfter, both inclusive (RFC 5280 §4.1.2.5);
    • basicConstraints cA and pathLenConstraint on every issuer;
    • keyUsage keyCertSign on issuers and digitalSignature on the leaf, when present;
    • extendedKeyUsage containing serverAuth on the leaf, when present;
    • name constraints (dNSName and iPAddress subtrees, permitted and excluded), where present on an issuer;
    • an unknown critical extension is refused.
  • Keys:
    • RSA from 2048 to 4096 bits (#203’s capacity), with any other size refused with its own tag;
    • EC on P-256 and P-384;
    • Ed25519.

5.3 Name matching

  • Source. subjectAltName only, and no common-name fallback. A certificate with no SAN does not match anything.
  • DNS names:
    • ASCII, compared without case;
    • a wildcard only as the whole left-most label (*.example.com). It never matches across a dot, never matches the bare parent (*.example.com does not match example.com), and is never partial (f*.example.com is refused, as OpenSSL’s NO_PARTIAL_WILDCARDS in docs/tls-nonblocking.md §3.5);
    • a wildcard needs at least two labels after it (*.com and *.co never match). That is weaker than a public-suffix list (*.co.uk would pass), and it is §10’s question 5;
    • internationalised names are compared as A-labels, byte for byte, and U-labels are not supported.
  • IP addresses. An iPAddress SAN, exact match. No SNI is sent for an IP (RFC 6066 §3).

5.4 Not checked, and the API says so

  • Revocation: CRL, OCSP, stapled OCSP.
  • Certificate Transparency.
  • Policy constraints and certificate policies beyond refusing them when they are critical.

tls.trust’s documentation and the package README say “revocation is not checked”. #211 said the same for OpenSSL (docs/tls-nonblocking.md §3.5): a revoked certificate that is otherwise valid is accepted.


6. Randomness

The client needs entropy for two things:

  • its X25519 private key, 32 bytes per handshake, which is secret;
  • the ClientHello random and the legacy session id, 64 bytes per handshake, which are public but must not be predictable.

What exists. There is no randomness builtin; crates/cancho-ir/src/builtin.rs lists none. Two things work already:

  • Fs reading /dev/urandom. Measured for this document: fs_read(fs, "/dev/urandom", b) on an Fs("/dev/urandom") fills a 32-byte buffer and returns, and cancho authority reports fs_read("/dev/urandom") and nothing else.
  • Precedent in hooks. cancho-hooks already reads its PostgreSQL SCRAM nonce this way, with its Fs("") (src/history.cho, fresh_nonce).

Decision. The caller seeds the engine once (tls.seed, 32 bytes from /dev/urandom). The engine draws everything else from a fast-key-erasure DRBG: ChaCha20 keystream under a 32-byte key, and the first 32 bytes of each draw replace the key, so a later compromise of the state does not reveal earlier outputs. It uses std.chacha20.block, already built and tested, and needs no capability. If the engine was never seeded it refuses to start a connection (tls-no-entropy); it never runs on a zero key.

Alternatives:

  • Read /dev/urandom per handshake. This puts Fs in the package and a system call in every handshake.
  • A random builtin over getrandom(2). This is a compiler change with its own effect label. It is the cleaner end state, and §10’s question 1.
  • libc through Ffi. This is UNBOUNDED in the authority report (docs/tls-nonblocking.md §6), which is what the pure backend exists to avoid.

Not handled: a process that forks after seeding would share DRBG state between parent and child. cancho-hooks does not fork. The package README must say that a forking program reseeds in each child.


7. The protocol, concretely

7.1 Messages and limits

  • ClientHello extensions:

    • server_name (only for a DNS name);
    • supported_versions;
    • supported_groups (x25519);
    • key_share (one X25519 share);
    • signature_algorithms and signature_algorithms_cert (§3.1).

    It also sends a 32-byte legacy session id and accepts one change_cipher_spec record before the encrypted handshake (RFC 8446 Appendix D.4, middlebox compatibility).

  • Not offered: PSK, early data, max_fragment_length, status_request. Corrected (#386, docs/tls-parity.md §6.6): ALPN is offered when the caller sets an offer (set_alpn_offer, start_alpn), and not otherwise; the ClientHello is unchanged when none is set. (PSK is offered for resumption since #286.) An extension in the ServerHello or in EncryptedExtensions that the client did not offer is refused (tls-unsupported-extension, RFC 8446 §4.2).

  • Records:

    • a plaintext record is at most 2^14 bytes, and a ciphertext record at most 2^14 + 256, with anything larger refused (tls-record-overflow);
    • the 64-bit sequence number is per direction and per key, and a connection that would wrap it is closed.
  • Handshake messages:

    • they are reassembled across records, and several may share one record;
    • one message is at most 64 KiB, the reassembly buffer per slot;
    • a message must not span a key change (RFC 8446 §5.1).
  • Order. ServerHello, EncryptedExtensions, Certificate, CertificateVerify, Finished, and anything else at that point is tls-unexpected-message.

  • CertificateRequest. It is answered with an empty Certificate, since there are no client certificates (§4.1), and the server decides. Corrected (#386, docs/tls-parity.md §6.3): with an identity configured for the host (add_client_identity), a request whose signature_algorithms lists ecdsa_secp256r1_sha256 and whose certificate_authorities is empty or names our issuer is answered with the chain and a CertificateVerify; anything else, and every request when no identity is configured, is answered with the empty Certificate as before.

  • After the handshake:

    • NewSessionTicket is parsed and dropped (no resumption, §7.2); since #286’s build (docs/tls-resumption.md): kept, the newest one, for the engine to save;
    • a received KeyUpdate is applied, and answered when update_requested. The client never initiates one. Alternative: refuse KeyUpdate, which would break long connections to servers that rotate keys; the cost of supporting it is one more key derivation;
    • more than 32 KeyUpdates, or more than 16 warning alerts, on one connection is refused as a hostile peer (tls-too-many-messages).
  • Alerts. Every received alert ends the connection, except close_notify, which is a clean end, and user_canceled, which is ignored until the following close_notify. Corrected (review finding E-3, #209): a close_notify is a clean end only once the handshake is done. Before that it may come in the clear, from anyone on the path, and nothing was authenticated to end, so the connection fails with tls-peer-closed, as a socket closed mid-handshake does.

7.2 Not built, and why

  • Session resumption and 0-RTT. These are a separate decision (#197’s non-goals). The OpenSSL spike measured resumption halving the handshake CPU (docs/tls-nonblocking.md §8.5), and that saving is given up here until it is designed with its hazard: a resumed session skips verification, as §10.4 there says. Since #286: TLS 1.3 resumption with (EC)DHE is designed and built, with eight rules for that hazard (docs/tls-resumption.md). 0-RTT, PSK-only resumption and TLS 1.2 resumption stay out, refused by that design, not deferred.
  • Client certificates. Since #386: built for P-256 keys, TLS 1.3 and TLS 1.2 (docs/tls-parity.md §6.2 to §6.5). Post-handshake authentication, which is refused if requested, because it is not offered.
  • Revocation and the post-quantum hybrid share are decisions, not code: docs/tls-parity.md §6.8 and §6.9.

7.3 Secrets in memory

The language guarantees no erasure. A region’s memory is freed without being cleared, and the optimiser may delete stores to memory that is about to be freed (docs/chacha20.md §3.2). tls.drop overwrites keys, IVs and DRBG output it owns, which is best effort, and the package says exactly that. A guaranteed-store primitive is §10’s question 2.

7.4 Memory per connection

bytes
handshake reassembly (released into the slot’s pool after Finished)65,536
one record in and one record outabout 2 × 16,640
the transcript hash state (crypto.sha256_state_len() = 138 words)1,104
keys, IVs, sequence numbers, the HMAC state for Finished (hmac.state_len(32) = 202 words), the X25519 secretabout 2,000
total, during the handshakeabout 100 KiB

That is against 26 to 48 KiB for OpenSSL (docs/tls-nonblocking.md §8.3), so for 64 slots it is about 6.3 MiB. As built (#205, docs/tls-core.md §9.1), a slot is about 179 KiB, or 11.2 MiB for 64: the estimate left out room for three outgoing records and the separate buffers for an opened record, received data and the leaf certificate. Resumption (#286, docs/tls-resumption.md §4) adds 4,496 bytes a slot (a ticket offered, the newest received with its PSK and host name, and two secrets), and the engine’s ticket table 2,440 bytes a ticket. Shrinking the reassembly buffer to the largest certificate message actually seen is §10’s question 6. The number is a design estimate and #208 measures it.

Corrected (#383, docs/tls-memory.md, measured): (1) the reassembly buffer was never “released into the slot’s pool after Finished”: there was no pool, and the buffer was compacted and never freed or cleared; and it is not what made the slot large, since a page of it nobody writes is not resident. (2) The slot was not 179 KiB but 262.8 KiB, 10,241 words and 187,191 bytes: std.ecdh’s work area (75 KiB) had been moved into it (a region holds at most 64 KiB) and this figure was not revisited. (3) What was resident for an established connection in examples/tls_echo was 119 KiB, because a handshake writes the work area and a page or two of each buffer, and a record in flight writes four pages of four buffers more. (4) As built, an established connection holds its core, 4.5 KiB for a server and 8.3 KiB for a client, and leases the rest from a pool of work areas for the length of a handshake or of the bytes in flight (tls-memory.md §7).


8. Refusal tags

Each tag is one failure a caller can act on: retry, alert an operator, or fix a configuration. A tag is stored in the attempt’s history, as cancho-hooks stores attempts.status today (docs/tls-nonblocking.md §10.1).

Protocol (packages/tls):

tagwhenwhat an operator does
tls-peer-closedthe connection ended before the handshake didretry
tls-alert (detail: the alert number)the server sent a fatal alertread the alert: handshake_failure and protocol_version usually mean no shared suite or version
tls-protocol-versionthe server chose TLS 1.2 or below, or set a downgrade sentinel (since #208, in a TLS 1.2 ServerHello)the receiver needs TLS 1.3, or the OpenSSL backend
tls-no-shared-cipherthe ServerHello names a suite that was not offeredthe receiver lacks ChaCha20-Poly1305 (§3.2). Since #207: it lacks all three TLS 1.3 suites
tls-hello-retrya HelloRetryRequest (§3.3). Since #207: one the client cannot follow (it changes nothing, or its cookie is over 2,048 bytes), or a ServerHello after it with another suitethe receiver lacks X25519. Since #207: a broken peer, or a cookie the limit should allow
tls-unexpected-messagea message out of ordera broken or hostile peer
tls-decode-errora malformed messagethe same
tls-unsupported-extensionan extension that was not offeredthe same
tls-record-overflowa record or a handshake message over its limit (§7.1)the same
tls-bad-record-macan AEAD tag did not verifythe path is corrupting or tampering; retry
tls-key-sharean X25519 share that gives the all-zero secreta hostile peer
tls-extended-master-secret#207: a TLS 1.2 server without the extended master secret (RFC 7627)the receiver’s TLS 1.2 is old; it needs TLS 1.3, or the OpenSSL backend
tls-renegotiation#207: a TLS 1.2 HelloRequestthe receiver renegotiates; none is done
tls-bad-certificate-verifythe server’s signature over the transcript does not verifya hostile peer, or the wrong key
tls-bad-finishedthe server’s Finished does not verifythe same
tls-too-many-messagesthe KeyUpdate or warning-alert limits (§7.1)the same
tls-illegal-psk#286: a pre_shared_key in a ServerHello that names an identity other than the one offered, or comes with a suite whose hash is not the ticket’s (one answering a ClientHello that offered none is tls-unsupported-extension, RFC 8446 §4.2) (docs/tls-resumption.md §5)a broken or hostile peer
tls-alpn-selected#386: the server chose an ALPN protocol that is not in the offer (docs/tls-parity.md §6.6)the server is broken or hostile, or the offer was not what the program meant
tls-extension-repeat#386: signature_algorithms or certificate_authorities twice in a CertificateRequest, or ALPN twice in a server’s answera broken or hostile peer
tls-alpn-list#386: set_alpn_offer or start_alpn given a name that is empty or over 255 bytes, or a list over 256a program bug
tls-client-key-type, -key-format, -key-mismatch, -cert-expired, -chain, -names, -identities-full, -no-identity#386: add_client_identity, replace_client_identity and remove_client_identity refusing what they were given (docs/tls-parity.md §6.2, §6.7)fix the files or the call
tls-client-sign#386: the signer refused or its check failed while signing a CertificateVerify: a fault, not an inputreport it
tls-no-entropythe engine was never seededa program bug: seed it in main
tls-slota slot number out of range, or a slot already in usea program bug
tls-poolevery work area of an engine opened with open_with_areas is leased: a connection cannot start (an established one that needs an area is not refused: it waits, feed and send taking nothing) (tls-memory.md §7.3)size the pool for the connections in a handshake or holding a record at one instant; the default has one for every slot, and never refuses

Certificates (packages/x509, through packages/tls):

tagwhenOpenSSL’s code for the same thing (X509_V_ERR_*, docs/tls-nonblocking.md §5)
x509-decodea certificate is not strict DER (#202)6 (UNABLE_TO_DECODE_ISSUER_PUBLIC_KEY), 13 and 14 (a bad validity field)
x509-unknown-issuerno path to a trusted root2, 18, 19, 20
x509-expirednow > notAfter10
x509-not-yet-validnow < notBefore9
x509-bad-signaturea signature in the path does not verify7
x509-name-mismatchno SAN matches the host (§5.3)62
x509-not-caan issuer without cA79 (INVALID_CA)
x509-path-too-longover pathLenConstraint, or over depth 825
x509-name-constrainta name outside a permitted subtree, or inside an excluded one47, 48 (and 51 to 53, a constraint it cannot read)
x509-key-usagekeyUsage or EKU forbids this use26 (INVALID_PURPOSE), 32 (KEYUSAGE_NO_CERTSIGN)
x509-unsupported-algorithma key or signature algorithm outside §5.2, SHA-1 included68 (CA_MD_TOO_WEAK), 76 (UNSUPPORTED_SIGNATURE_ALGORITHM)
x509-key-sizean RSA key outside 2048 to 4096 bits66 (EE_KEY_TOO_SMALL), 67 (CA_KEY_TOO_SMALL)
x509-critical-extensionan unknown critical extension34 (UNHANDLED_CRITICAL_EXTENSION)
x509-chain-too-largea certificate over 16 KiB, or a chain over 8none: OpenSSL refuses differently

The right-hand column is the mapping the OpenSSL backend’s failure uses (§2.2), so hooks’ history means the same thing under either backend. #210’s gate checks that the two backends give equal outcomes. The numbers were checked against OpenSSL 3.0.13’s /usr/include/openssl/x509_vfy.h when this was written (a first draft from memory had three of them wrong). #206 tests the mapping by running both backends on the same chains.


9. Gates, per sub-issue, and what they guard

Each sub-issue (#199 to #210) states its own gate as a command. This design adds three that span sub-issues:

  1. The two backends agree. For the certificate matrix of docs/tls-nonblocking.md §5, both backends give the same tag, plus #206’s wildcard and constraint rows (#210).
  2. No capability in the pure backend. cancho authority on a hooks build with the pure backend shows no ffi(...) and no foreign symbols. This is checked by a test, not asserted (#210). Corrected (#210, docs/tls-hooks.md §2.4): hooks as a whole also holds libc (statx, prctl: the modes of the data directory), so “none at all” cannot hold for it. The check is no libssl and no libcrypto scope, none of the 32 symbols, and the libc entries unchanged.
  3. No input reaches a trap. It is fuzzed at the record, handshake, DER and chain levels (#208), as dns.cho was over a million damaged answers (docs/tls-nonblocking.md §7). #208’s plan and results, for this and for the rest of its bar, are in docs/tls-assurance.md. Fuzzing is in §3.6 (no crash and no hang). The differential and interop matrices are in §4.1 and §5.1. Timing is in §6.1: X25519 and ChaCha20-Poly1305 pass on both backends, and three tests fail on an Apple M4 with its data-independent-timing bit clear. Resource bounds are in §7.1.

10. Open questions, for a person

Each is a decision this design does not make by default, with the default it assumes until someone decides:

  1. A randomness builtin. Add random(rng: &Rng, into) over getrandom(2), with its own capability and effect label, instead of Fs and /dev/urandom (§6)? Assumed: no; the caller seeds through Fs.
  2. A guaranteed erasure. Add a store the optimiser may not remove (a secure_zero builtin), so §7.3 can promise erasure instead of attempting it? Assumed: no; best effort, said so.
  3. Where the bignum and curves go. std or a package (§1.2)? Assumed: std, in their own files.
  4. Expired roots. Accept an expired root as an anchor (OpenSSL’s default) or refuse it? Assumed: accept, matching OpenSSL, so the two backends agree. Corrected (docs/x509-verify.md §8.3): OpenSSL’s default refuses an expired root, so matching it means refusing, and the verifier does. The two backends agree, which was the point of the assumption.
  5. Public-suffix wildcards. Is “two labels after the wildcard” enough, or does the package carry a public-suffix list (which then needs updating)? Assumed: two labels; mis-issuance of *.co.uk is a CA failure the rest of the ecosystem polices.
  6. The 64 KiB handshake buffer per slot. Keep it per slot (100 KiB a connection, §7.4), or share a pool across slots, which makes the code harder? Assumed: per slot until #208 measures. Answered (#383, tls-memory.md): shared, and not only that buffer: the handshake’s scratch and the record buffers too, as work areas leased for as long as a handshake runs or bytes are in flight. Measured, the buffer alone was never the cost (§7.4’s correction).
  7. ChaCha20 only. Is losing AES-GCM-only receivers acceptable until #207’s measurement? Assumed: yes, with OpenSSL the default until #209.
  8. Who reviews (#209). A person or a firm, named before the work it reviews is called finished.

11. Corrected elsewhere

Nothing yet. This document makes claims about code that is not written. Where the code turns out different, the code’s PR corrects the claim here, in place.

X25519, and the field arithmetic it shares with Ed25519

Status: built. Not independently reviewed. Sub-issue 3 (#200) of the self-contained TLS 1.3 client (#197). The client’s key exchange is X25519 alone (docs/tls-pure.md §3.3), and X25519 handles a secret scalar. So this slice is as much about how it computes as what: a field module with no branch on data, shared with std.ed25519, which moved onto it. Moving Ed25519 onto it found two bugs in Ed25519 that predate this work (§4.2) and made it 31 to 37 times faster (§6).

Update (#381): std.x25519 no longer uses the field this document describes. It runs on std.field25519_51, five limbs of 51 bits on the mul_wide and add_carry builtins, 10 to 23 times faster (Cranelift on a loaded x86-64 to LLVM on arm64) (docs/wide-multiply.md §10). §2, §3.1 and §6 below describe std.field25519, which std.ed25519 still uses; §5’s mutants of the X25519 ladder now run against the new field.


1. What was built

FileWhat
std/field25519.cho, module std.field25519arithmetic mod p = 2^255 - 19: add, sub, mul, square, invert, pow2523, cswap, pack, unpack, equal, parity
std/x25519.cho, module std.x25519scalarmult(k, u, out) and public_key(k, out) (RFC 7748 §5); refusals x25519-length and x25519-zero-secret
std/ed25519.chothe point arithmetic now on std.field25519; the scalar arithmetic mod L, its bit-serial bignum, is unchanged

One copy of the field. Before this, Ed25519’s field arithmetic was private to std/ed25519.cho: a byte-oriented bit-serial bignum, with gf_add/gf_mul/gf_invert over 32-byte buffers (docs/ed25519.md §2). #200 asked for it to be extracted, not copied. It could not be extracted as it was, because it branched on its data (docs/ed25519.md §6: “every branch here … is an ordinary conditional”). So it was replaced: gf_*, bn_modpow and the two exponent tables are gone from std/ed25519.cho, and both curves use std.field25519. The duplication check (crates/cancho/tests/conformance/duplication.rs) has no exception for it.


2. Representation

An element is a [int] of 16 limbs of 16 bits, least significant first. That is TweetNaCl’s representation, chosen over the faster radix-2^25.5 one for the size of its bounds:

  • After carry, a limb is in [0, 2^16), or slightly outside it for limb 0, which takes the folded carry.
  • Inputs to mul may be sums or differences of up to four reduced elements (Ed25519’s point_add makes those), so a limb is under 2^19 in magnitude.
  • A product of two such limbs is under 2^38, a column of 16 is under 2^42, and folding the top half down as × 38 (2^256 = 38 mod p) gives under 2^48.

A signed 64-bit int holds every intermediate with 15 bits to spare. The arithmetic is written wrapping_* anyway, so that no overflow test is emitted on a secret limb (docs/chacha20.md §2’s reason).

Ed25519’s constants d and √−1 became 16-limb tables. They were converted from the byte tables they replace by a script, and d was also checked against TweetNaCl’s D. A point is one [int] of 64: X, Y, Z, T at offsets 0, 16, 32, 48.

Scratch, not arenas. mul and the rest take a scratch slice the caller owns. An arena is a malloc (docs/hkdf.md §2), and one scalar multiplication does about 2,800 field multiplications.


3. Secrets

The X25519 scalar is secret, and so is everything the ladder computes from it. The rule is the one from docs/chacha20.md §3: no branch and no memory index depends on a secret.

  • The ladder walks all 255 bits whatever they are. A bit decides only cswap, a masked XOR (mask = 0 − bit). Clamping is done bit by bit as the ladder reads the scalar (scalar_bit), so the caller’s scalar is never written. The index i >> 3 is the loop counter, which is public.
  • The field. Every loop bound is a constant. invert and pow2523 follow fixed exponents, so their if bit != 2 && bit != 4 tests the loop counter, not data. pack reduces with two trial subtractions, each kept or dropped by cswap_at.
  • The result. The all-zero check ORs every output byte, and refuses only after that. The result is what the caller gets, so it is public.

3.1 Checked with Valgrind, not only by reading

docs/chacha20.md §3 read the object code. That shows where the conditional jumps are, but not what they depend on. This slice used the ctgrind method instead:

  1. The secret bytes are marked undefined for Valgrind’s Memcheck.
  2. The function is called.
  3. Memcheck reports every conditional jump, and every memory access whose address, that depends on an undefined value.

Arithmetic on undefined values is not reported, which is exactly the constant-time distinction. The harness (tests/ct/ctgrind.c) calls the cancho functions directly from an object cancho build --emit obj made. A slice is a pointer and an i64 length, an int an i64, and capabilities and regions are no argument at all (crates/cancho-codegen-llvm/src/emit.rs, leaves_into).

Under Memcheck, the secret marked undefinedReports
x25519.scalarmult, the scalar secret0
the same, with an if on the secret bit planted in the ladder (a control: does the check see anything?)252, at x25519.scalarmult: about one per ladder step
ed25519.sign, the seed secret1,937 in 10 places, every one in bn_mulmod, bn_reduce_wide or sign itself: the scalar arithmetic mod L, not the field or the point ladder (§5)
scripts/curve25519_ctgrind.sh target/release/cancho

What this does not show:

  • Timing on real hardware, since no dudect-style statistical test was run. #208 runs one, over X25519 and the AEAD.
  • Instructions whose own timing depends on their operands. 64-bit imul is constant-time on current x86 cores, and the field uses nothing else that varies.
  • The Cranelift backend’s code. The harness uses the default (LLVM) object.

The claim is exactly that, on the LLVM backend for x86-64, no branch and no memory index in X25519 depends on the scalar.


4. Checked, not assumed

tests/programs/curve25519_driver.cho drives std.x25519 and std.ed25519 from standard input. crates/cancho/tests/conformance/x25519.rs runs it in cargo test.

4.1 RFC 7748

tests/vectors/rfc7748.txt has 8 rows, all passing on both backends:

  • §5.2’s two scalar multiplications. The second has the top bit of u set, which must be ignored.
  • §5.2’s iterated test at 1 and at 1,000 iterations.
  • §6.1’s Diffie-Hellman example: both public keys, and the shared secret computed from each side.

The RFC’s text is not reachable from the machine this was written on. The values were written down from it and kept only after OpenSSL (through pyca/cryptography) gave each of them; for the iterations, OpenSSL was run the same number of times.

The 1,000,000-iteration vector is RFC 7748’s last one. It takes about 19 minutes at this speed, so it is not in cargo test. It was run by hand (echo "I 1000000" | curve25519_driver, 1,157 seconds) and gave 7c3911e0ab2586fd864497297e575e6f3bc601c0883c30df5f4dd2d24f665424. OpenSSL, run the same million times through pyca/cryptography (87 seconds), gives the same bytes. A first draft of this section quoted the value from memory with its last bytes wrong (…887424); the two independent computations settled it.

4.2 Wycheproof

FileCasesResult
x25519_test.json518 (264 valid, 254 “acceptable”: twists, non-canonical u, low-order points)all give the file’s shared secret. The 31 whose secret is all zeros are refused with x25519-zero-secret, as RFC 8446 §7.4.2 requires of a TLS client
ed25519_test.json151 (88 valid, 63 invalid)all as the file says, after two fixes to std.ed25519

The two Ed25519 bugs, both older than this slice:

  1. A signature that is not 64 bytes trapped verify (SIGILL, status 132). It read sig[32..64] without checking the length, and Wycheproof’s truncated signatures reach that. verify now returns 0 for a key that is not 32 bytes or a signature that is not 64.
  2. An R with x = 0 and the sign bit set was accepted (case 151). RFC 8032 §5.1.3 step 4 says decoding fails when x = 0 and the sign bit is 1. The old code, and the first port, negated zero to get zero and went on. OpenSSL refuses it. point_unpack now refuses it.

docs/ed25519.md §5 is corrected in place to say so.

4.3 Against OpenSSL

scripts/curve25519_differential.py checks against OpenSSL (pyca/cryptography):

  • X25519: a random scalar with a random 32-byte u (non-canonical, p and up, one round in fifty), and the public key of a random scalar.
  • Ed25519: a random seed and message. The public key and the signature must equal OpenSSL’s, OpenSSL’s signature must verify here, and with one random bit flipped it must be refused by both.
BackendX25519 roundsEd25519 roundsChecksDifferences
LLVM10,0001,00024,0000
Cranelift1,0001002,4000

The existing Ed25519 tests pass unchanged: tests/accept/ed25519.cho (three OpenSSL keypairs), tests/accept/ed25519_long.cho, and backends::the_two_backends_agree_on_ed25519.


5. Mutants

scripts/curve25519_mutants.py builds 21 mutants of the three files as local modules beside the driver. It runs each against the RFC 7748 table, every Wycheproof X25519 and Ed25519 case, tests/accept/ed25519.cho’s signatures, and 300 differential rounds. 21 mutants, 19 killed, 2 survive, and neither is a missing test:

  • X25519, “the final swap left out”: equivalent. After the ladder, swap holds bit 0 of the scalar. Clamping always clears bit 0, so the final cswap RFC 7748 writes is always a no-op here.
  • Ed25519, “point_equal compares x only”: not reachable through verify by any input that can be constructed. Two points with the same x differ only in the sign of y. To make verify’s equation hold up to that sign, a forger would have to choose R after seeing H(R || A || M). The full comparison stays, because it costs two multiplications and the shortcut is a bug waiting for a caller other than verify.

The 19 that are killed:

  • field25519: the carry’s fold as 37; the product’s fold as 19; one carry instead of two; a 15-limb cswap; the inversion’s exponent; pack keeping the unreduced value; one trial subtraction instead of two; unpack keeping the top bit; pow2523’s exponent.
  • x25519: a24 as 121666; bit 254 not set; the low three bits not cleared; the zero secret accepted; z3 not multiplied by x1.
  • ed25519: the ladder’s swap back left out; the second square-root candidate never tried; the sign bit ignored; x = 0 with the sign bit accepted; a signature of any length passed on.
python3 scripts/curve25519_mutants.py target/release/cancho     # about a minute

6. Cost

One core of an Intel Xeon at 2.80 GHz, median of 5, the time being the difference between 1 call and many (tests/programs/ed25519_bench.cho, timed by scripts/curve25519_bench.py):

before (bit-serial field)after, LLVMafter, Craneliftopenssl speed
X25519— (did not exist)1.08 ms (927 a second)2.35 ms0.038 ms (26,397 a second)
Ed25519 sign145 ms4.6 ms (31×)9.8 ms0.045 ms
Ed25519 verify174 ms5.0 ms (35×)9.3 ms0.12 ms

#200’s gate asked that Ed25519 not slow down by more than 5%; it is 31 to 35 times faster. X25519 is about 28 times slower than OpenSSL’s assembly. A TLS handshake does two X25519 operations (the key share and the shared secret), so about 2.2 ms against the 0.6 ms a whole OpenSSL handshake takes (docs/tls-nonblocking.md §8.1). The next step, if it matters, is 10 limbs of 25.5 bits: 100 products a multiplication instead of 256, at the cost of tighter bounds.


7. Not done here

  • The 1,000,000-iteration RFC vector in CI (§4.1). It is too slow for cargo test at this speed. It was run once by hand, and it agrees with OpenSSL.
  • A statistical timing test. That is #208. §3.1 is a data-flow check, not a timing measurement.
  • Constant-time Ed25519 signing. The field and the point ladder are now branch-free, but the scalar arithmetic mod L (bn_*) still branches on the secret scalar (§3.1’s third row). docs/ed25519.md §6 is corrected to say exactly that.
  • An independent review. That is #209.

packages/x509: strict DER and X.509 v3 certificates

Status: built. Not independently reviewed (#209). Sub-issue 5 (#202) of the self-contained TLS 1.3 client (#197). docs/tls-pure.md §1 puts certificate code in a package, versioned apart from the compiler; this is that package’s first part, the parser. Chain building, signatures and name matching are #206; RSA and ECDSA are #203 and #204.


1. What was built

FileWhat
packages/x509/x509.cho, module x509tlv (one strict DER TLV), parse(der, view), pem_next(text, at, out, info), refusal_tag, oid_code, and the view’s slot names (§2)
tests/programs/x509_driver.choa PEM bundle in, one line per certificate out, with every field §5.1 compares; or hex DER lines in
tests/programs/x509_fuzz.chothe fuzz harness (§5.4)
crates/cancho/tests/conformance/x509.rsthe corpus on both backends, the negative corpus, PEM refusals, 20,000 fuzz rounds
tests/vectors/x509/corpus.pem and corpus.txt (10 certificates and pyca’s reading of them), negative.txt (36 damaged ones)
scripts/x509_check.pyany PEM bundle against pyca/cryptography, field by field (§5.1)
scripts/x509_corpus.pymade tests/vectors/x509/ (§5.2)
scripts/x509_limbo.pyevery certificate in x509-limbo (§5.3)
scripts/x509_oids.pyprints the OID table (§2.3)

The package imports nothing, not even std, so it needs no --std to publish (docs/package-system.md §4.8). A program that uses it names the file: cancho build --std app.cho packages/x509/x509.cho. It is not published to a store yet; #205 is its first user, and publishes it.

No secrets here. A certificate is public, sent in the clear, so none of the constant-time rules of docs/tls-pure.md §4.2 apply. The rules are about input: no input reaches a trap, every refusal has its own code and tag, and no allocation is sized by the input.


2. The view

parse does not copy anything out of the certificate. It fills a caller’s [int] of view_len() (40) words with offsets into the certificate’s own DER, and a few small decoded values. Ranges are [start, end). The slots have names, so a caller writes view[x509.subject_start()], never view[11].

SlotsNameHolds
0, 1tbs_start, tbs_endthe whole TBSCertificate TLV: the bytes the issuer signed
2signature_algorithmthe outer AlgorithmIdentifier’s OID code (§2.3; 0 for one not in the table)
3, 4signature_params_start, _endits parameters (empty when absent; RSASSA-PSS has some)
5, 6signature_start, _endthe signature’s bits, after the unused-bits byte (which must be 0)
7, 8serial_start, _endthe serial’s INTEGER content
9, 10; 11, 12issuer_*, subject_*the whole Name TLVs, for byte comparison in chain building
13, 14not_before, not_afterseconds since 1970, UTC
15, 16spki_start, _endthe whole SubjectPublicKeyInfo
17, 18key_algorithm, key_curveOID codes; the curve is 0 unless the key is EC
19, 20key_start, _endthe key’s bits: an RSAPublicKey, an EC point, or 32 Ed25519 bytes
21version1, 2 or 3
22, 23is_ca, path_lenbasicConstraints: -1 absent, else 0 or 1; the path length or -1
24, 25key_usage, ext_key_usage-1 absent; else keyUsage’s named bits as a mask (bit 0 digitalSignature, 5 keyCertSign, …), and eku_* flags (1 serverAuth, 2 clientAuth, 4 any, 8 another)
26, 27san_start, _endsubjectAltName’s GeneralNames content; 0, 0 when absent
28, 29name_constraints_*nameConstraints’ content, unread here (#206 reads it)
30 to 33ski_*, aki_*the subject key identifier, and the authority’s keyIdentifier
34extension_count
35leniencylenient_* flags (§3.2)
36 to 39rsa_modulus_*, rsa_exponent_*for an RSA key, the two INTEGERs’ contents

On a refusal the view’s contents mean nothing.

2.1 What is checked, and what is only located

parse checks that the whole certificate is strict DER (§3.1), that the fields come in RFC 5280 §4.1’s order with the right tags, and that the fields it decodes are valid: version, serial, both times, both names’ shape, the key’s parameters and length, and the five extensions in §2.2. It does not verify the signature, read name constraints, compare names, or decide whether the key is acceptable. Those belong to #206, with the view’s ranges as their input.

2.2 Extensions

ExtensionReadCritical allowed
basicConstraintscA, pathLenConstraint (non-negative, under 2^31)yes
keyUsagethe BIT STRING, with its unused bits zeroyes
extendedKeyUsageat least one OID, as flagsyes
subjectAltNameat least one entry, at most 1,024; a dNSName, rfc822Name or URI is ASCII; an iPAddress is 4 or 16 bytesyes
nameConstraintslocated, for #206yes
subjectKeyIdentifier, authorityKeyIdentifierlocatedno
any otherits DER checked by the whole-certificate walkno

A critical extension that is not in the “critical allowed” column is refused (x509-critical-extension), as RFC 5280 §4.2 requires of an extension a verifier does not process. That includes a critical certificatePolicies, cRLDistributionPoints or AKI. docs/tls-pure.md §5.4 already says policies and revocation are not checked; refusing them when critical is the honest consequence. The same OID twice is refused (x509-duplicate-extension), and so is an empty extensions list.

2.3 Object identifiers

oid_table is a static of 30 OIDs: the RSA, RSASSA-PSS, ECDSA and Ed25519 algorithms, the three NIST curves, the extensions above and five more, and the three EKU purposes. scripts/x509_oids.py prints it from the dotted forms, so no byte was typed by hand, and its output equals the static byte for byte. Each OID was also checked against openssl asn1parse -genstr OID:<dotted>. An OID that is not in the table has code 0, which is not a refusal: an algorithm the parser does not know is for the verifier to refuse, with the tag docs/tls-pure.md §8 gives it (x509-unsupported-algorithm).


3. What is accepted

3.1 Strict DER

X.690 §10, enforced in one place, tlv, and applied to every TLV in the certificate by a recursive walk before any field is read:

  • a tag in one byte (the high-tag-number form is refused);
  • a definite length (indefinite, 0x80, is refused), in the fewest bytes: the short form below 128, no leading zero byte, at most four bytes;
  • the content entirely inside its parent, and the parent filled exactly;
  • at most 24 levels deep;
  • INTEGERs minimal, BOOLEANs 0x00 or 0xff, BIT STRINGs with a valid unused-bits count.

A serial may be up to 21 content bytes (20 bytes of value, plus the 0x00 that keeps a high bit positive). A negative or zero serial is accepted: RFC 5280 §4.1.2.2 forbids it, but one of the system roots on this machine has one, and refusing a root for its serial helps nobody.

3.2 Two leniencies, flagged

Two things strict RFC 5280 refuses are accepted, and each one sets a bit in view[leniency()], so a caller can see them and #206 can refuse them for a leaf if it chooses:

  • A GeneralizedTime before 2050 (RFC 5280 §4.1.2.5 says UTCTime). One of the 128 roots in this machine’s bundle has one (§5.1).
  • A BOOLEAN DEFAULT FALSE written out as FALSE (X.690 §11.5 says omit it): an extension’s critical, or basicConstraints’ cA. The first version of this document, and the parser’s comments, said some system roots do this. Measured, none of the 128 does. The leniency stays, because other issuers have done it and refusing it gains nothing, but the claim is corrected here, where it was made. pyca/cryptography refuses such a certificate outright (EncodedDefault); OpenSSL accepts it.

3.3 Limits

LimitValueRefusal
a certificate’s DER16,384 bytes (docs/tls-pure.md §5.2)x509-too-large, before anything is read
a PEM block’s decoded bytesthe caller’s bufferx509-too-large, and the bundle goes on
extensions32x509-too-large
subjectAltName entries1,024x509-too-large
DER nesting24der-too-deep

4. Refusals

CodeTagWhen
-1der-truncateda length runs past its parent
-2der-trailing-bytesbytes after the certificate
-3der-indefinite-lengtha 0x80 length
-4der-non-minimal-lengtha length not in its fewest bytes
-5der-tagthe high-tag-number form, or a tag where another was required
-6der-too-deepnesting past 24
-7der-integera non-minimal INTEGER, a serial over 21 bytes, a negative path length
-8der-booleana BOOLEAN that is not 0x00 or 0xff
-9der-bit-stringa bad unused-bits count, or nonzero unused bits
-10x509-structurea field missing, extra or empty where RFC 5280 says it is not
-11x509-versiona version other than v1 to v3, or extensions (or unique IDs) in a version that has none
-12x509-timenot YYMMDDHHMMSSZ or YYYYMMDDHHMMSSZ, or a date that does not exist
-13x509-signature-algorithm-mismatchthe TBSCertificate’s algorithm is not the outer one, byte for byte (RFC 5280 §4.1.1.2)
-14x509-duplicate-extensionthe same extension twice
-15x509-critical-extensiona critical extension that is not read here (§2.2)
-16x509-too-large§3.3
-17x509-namean empty RDN, an attribute without a value, a non-ASCII DNS name, an IP address of the wrong length
-18pemno END line, a character outside base64, wrong padding
-19x509-keyan RSA key that is not an RSAPublicKey with NULL parameters, an EC key with explicit parameters, or a point of the wrong length or not uncompressed

How these become docs/tls-pure.md §8’s tags. That design gave certificate parsing one tag, x509-decode, for a caller to act on: a certificate that is not well-formed is a broken or hostile peer whatever the detail. packages/tls (#205) maps -15 to x509-critical-extension, -16 to x509-chain-too-large, and every other code to x509-decode, keeping this package’s tag as the detail. The finer tags exist so that a test can say which rule refused a certificate (§5.2).


5. How it was tested

5.1 Every system root, against pyca/cryptography

scripts/x509_check.py <driver> parses /etc/ssl/certs/ca-certificates.crt and compares every field the driver prints with pyca/cryptography 49.0.0’s reading: version, serial, issuer and subject DER, both times, key algorithm, curve and size, basicConstraints, keyUsage, extendedKeyUsage, the SAN bytes, the signature algorithm, the extension count, and the leniency flags (computed by the script’s own DER walk, since pyca does not report them).

Result: 128 certificates, all accepted, 0 differences. The bundle: 61 RSA-4096, 28 RSA-2048, 35 P-384, 3 P-256 and 1 P-521 keys; 5 SHA-1 signatures; 1 GeneralizedTime before 2050; 1 serial that is not positive. To show the check is not vacuous, the same script run on the driver’s output with keyUsage changed wherever it is 96, and one notBefore changed, reports 86 differences.

The bundle is this machine’s, not committed: it changes with the system, and two of its 128 are a sandbox’s TLS inspection CAs. The committed corpus is §5.2’s.

5.2 The committed corpus, and one refusal per rule

scripts/x509_corpus.py made tests/vectors/x509/ once, with pyca/cryptography:

  • 10 good certificates. An RSA-3072 root (SHA-384), a P-384 intermediate under it with a path length of 0, name constraints and EKU, and a P-256 leaf from that (ECDSA-SHA384) with every kind of SAN entry. Also an RSA-2048 leaf signed with RSASSA-PSS that has a 20-byte serial with its top bit set; a P-521 certificate whose keyUsage needs a two-byte BIT STRING (decipherOnly); an Ed25519 certificate valid past 2050, so its notAfter is a GeneralizedTime; an RSA-4096 key with exponent 3, valid from 1999; a v1 certificate; and one certificate for each leniency. corpus.txt is the driver line pyca’s reading gives for each. For the two lenient ones, which pyca refuses or reports differently, it is the base certificate’s line with the flag set.
  • 36 damaged certificates. Each is made by editing one field of the P-256 leaf’s DER tree and re-encoding, so every other length stays right and the refusal is the rule named, not a truncation the edit caused. All 18 DER-reachable tags are covered, most more than once: for example x509-time by month 13, 29 February 2027, a UTCTime without seconds, and a fractional GeneralizedTime.

conformance/x509.rs checks the 10 lines on both backends, the 36 tags, and seven PEM blocks in one bundle: a good one, a bad character, padding after a whole group, a missing pad, 18,000 bytes into a 17,408-byte buffer, a good one again, and no END line. That shows a bad block is refused and the bundle goes on.

5.3 x509-limbo

x509-limbo is a suite of path-validation cases (#206’s), not parser cases, but each one carries certificates. scripts/x509_limbo.py parses every distinct one: 30,379 certificates from 9,802 cases (limbo.json with SHA-256 611e337b…acd4de, fetched on 2026-10-04).

  • 29,803 accepted and 576 refused, with no trap. Every refused certificate is used only by cases that expect FAILURE, with two exceptions: rfc5280::unknown-critical-extension-unrelated-root and -unrelated-intermediate expect SUCCESS because the certificate with the unknown critical extension is in the pool but not on the path. For #206: the trust store loader must skip a root it refuses, not fail the whole store, or those cases fail.
  • pyca loads every one of the 576, but it reads extensions lazily. Read, each refusal here is real:
    • 506 bettertls certificates have a SAN entry whose tag GeneralName does not allow. There are eleven tags, 46 certificates each: a primitive [0], [3], [4] or [5] (these are constructed in GeneralName), and [9] to [15] (which do not exist). They are refused as der-tag, and pyca refuses each SAN with UnexpectedTag when it is read.
    • 44 bettertls certificates have an iPAddress entry holding a DNS name or a URI (bad.example.com, http://…). They are refused as x509-name, and pyca refuses the same SAN as “not an IPv4 or IPv6 address”.
    • nc-permits-invalid-ip-san has an 8-byte iPAddress (a network, which belongs only in name constraints), and webpki::san::unicode-emoji-san has a non-ASCII dNSName. pyca reads both. These two are refused only here.
    • rfc5280::serial::negative’s certificate also has a critical cRLDistributionPoints, which is what refuses it here. Its negative serial alone would be accepted (§3.1).
  • No certificate that pyca refuses to load is accepted here.

Wycheproof has no certificate-parsing vectors; its ECDSA and RSA signature cases belong to #203 and #204.

5.4 A million damaged certificates

tests/programs/x509_fuzz.cho takes the corpus and runs rounds. Each round takes one certificate and damages it in one of six ways: 1 to 4 random bytes replaced, bits flipped, bytes set to the values DER lengths and tags are made of (0x00, 0x7f, 0x80 to 0x85, 0xff, 0x1f, 0x30, 0xa3, 0x01), a cut at a random length, a byte inserted or removed, or the PEM text damaged and decoded again. Each damaged input is parsed from a slice exactly its own length, so a read past the end would be a bounds trap, not a silent read. A round must answer one of the 20 codes. A trap ends the process, and the test fails on any exit status but 0.

1,000,000 rounds: no trap, no other code, on both backends, about 32 s each. The two backends gave identical counts:

okder-truncatedder-trailing-bytesder-non-minimal-lengthder-tagx509-timepemx509-signature-algorithm-mismatchx509-name
306,471345,59069,56150,10448,11641,53838,00934,87028,019

The rest: no block found 11,242, x509-structure 4,968, der-bit-string 4,792, x509-key 4,600, x509-critical-extension 4,433, der-indefinite-length 3,521, der-boolean 2,218, x509-version 1,580, der-integer 309, x509-duplicate-extension 59. A mutation that only touches a signature, key or string still parses, which is why “ok” is the second-largest count. der-too-deep and x509-too-large are never reached by mutation, so §5.2 covers them. The conformance test runs 20,000 rounds of the same harness.

5.5 Cost

The driver over all 30,379 limbo certificates, minus a run over the same bytes with nothing to decode, comes to 11.2 µs a certificate on LLVM and 18.3 µs on Cranelift. That figure includes base64 decoding and printing every field in hex, so the parse alone costs less. A TLS handshake parses 2 to 4 certificates.


6. Found

  • A v2 certificate was reported as v3. The version field’s value was checked, but the view was then given 3 whatever it said, so a v2 certificate with extensions was accepted. Found by reading the code while writing §5.2’s cases. Fixed (v + 1), and the “extensions in a v2 certificate” case now fails on the old code.
  • One oversized PEM block ended the bundle. limbo’s pathological::nc-dos-* roots are about 55 KB. pem_next refused the first as pem and gave no position to continue from, so the driver stopped at certificate 119 of 30,379. Now an oversized block is x509-too-large, and info[1] is past its END line on any refusal.
  • A claim made without measuring. “Some of the system roots” write an explicit DEFAULT FALSE: none of the 128 does (§3.2). “Two of the system roots” have an early GeneralizedTime: one does.

7. Not done

  • Signature verification, chain building, name matching and name constraints. Built in #206, beside this parser: docs/x509-verify.md.
  • Publishing the package to a store. #205, its first user.
  • The independent review. #209.

std.bigmod and std.rsa: modular arithmetic and RSA signature verification

Status: built. Not independently reviewed (#209). Sub-issue 6 (#203) of the self-contained TLS 1.3 client (#197). A TLS 1.3 server whose certificate chain is RSA signs its CertificateVerify with RSASSA-PSS, and most web roots and intermediates sign certificates with RSASSA-PKCS1-v1_5 (docs/x509.md §5.1: 89 of the 128 system roots have RSA keys). So the client has to verify both.


1. What is built

FileWhat
std/bigmod.cho, module std.bigmodunsigned integers mod an odd n of up to 4,096 bits, in Montgomery form: pow_mod(n, e, a, out, work) over big-endian bytes
std/rsa.cho, module std.rsapkcs1_verify (RSASSA-PKCS1-v1_5, RFC 8017 §8.2.2) and pss_verify (RSASSA-PSS, §8.1.2), over SHA-256, SHA-384 and SHA-512

std/bignum.cho already exists. It is a small decimal number type for float printing, not modular arithmetic, so the new module gets its own name rather than growing that one.

Verification only. No signing, no decryption, no key generation. Nothing here touches a secret: the modulus, the exponent, the signature and the message are all public. So constant time is not a requirement (docs/tls-pure.md §4.2). What is required is correctness, and refusing every malformed input with a tag.

2. std.bigmod

2.1 Representation

A number is a [int] of 30-bit limbs, least significant first. That is 137 limbs for 4,096 bits.

The width is set by the multiplication step. Montgomery multiplication (CIOS, one fused inner loop) computes t[j] + a_i*b[j] + m*n[j] + carry for each limb. With 30-bit limbs that is below 2^30 + 2^60 + 2^60 + 2^31 < 2^62, so it fits an int with checked arithmetic and no wrapping_*. With 31-bit limbs it would reach 2^63 and overflow. With 32-bit limbs even one product would. Checked arithmetic is kept on purpose: an overflow here would be a bug, and a trap shows it, where wrapping would give a wrong answer silently.

Superseded (#381, wide-multiply.md §12): a number is now 64-bit limbs, each an int read as an unsigned word, 64 for 4,096 bits. The CIOS step is two mul_wides and four add_carrys, one carry chain for a_i·b[j] and one for m·n[j], so no sum can exceed two words and no limb arithmetic can trap; comparisons of limbs are borrows. R = 2^(64k), n' = -n^-1 mod 2^64 (Newton, five steps from 3 bits), and §2.2’s doubling runs to 64k bits. The register slot stays 139 words. Measured on x86-64: RSA-2048 verification 6.7 times fewer cycles, RSA-4096 7.1 times.

2.2 Montgomery multiplication

R = 2^(30k) for a k-limb modulus. mont_mul(a, b) = a·b·R^-1 mod n, for a, b < n.

  • n' = -n^-1 mod 2^30 comes from Newton’s iteration on the lowest limb: it starts right to 3 bits (n is odd) and doubles each step.
  • The result of one CIOS pass is below 2n, and one conditional subtraction brings it below n.

R² mod n without division.

  • 2^(bits(n)-1) is below n. Doubling it (subtracting n when the result reaches n) at most 30 times reaches R mod n; one more doubling gives 2R mod n.
  • That is 2 in Montgomery form. Raising it to the power 30k with mont_mul gives 2^(30k)·R = R² mod n.
  • This costs about 12 squarings, against the 30k doublings, each over all k limbs, that a plain doubling from 1 would take.

2.3 pow_mod

pow_mod(n, e, a, out, work) sets out = a^e mod n, all as big-endian bytes, with out exactly as long as n.

  • Refused: an even n, n < 3, n over 4,096 bits, a >= n, an empty e, out the wrong length, or work shorter than work_len().
  • Leading zero bytes in n, e and a are allowed, because DER INTEGERs carry one before a high bit.
  • Method: left-to-right square-and-multiply in Montgomery form, then one mont_mul by 1 to leave it.
  • Not constant time (§1): it branches on the exponent’s bits, which are public. Since #207 (docs/ecdh.md §2): the reduction under mul, add and sub is constant time, because std.ecdh multiplies secret values with it. pow_mod still branches on the exponent.

work is the caller’s [int] of work_len() words. Nothing is allocated from a size the input names.

3. std.rsa

3.1 The key

  • The modulus must be odd and from 2,048 to 4,096 bits (docs/tls-pure.md §5.2).
  • The exponent must be odd and at least 3, shorter than the modulus, and at most 64 bits. The last added after review finding C-1 (#317): pow_mod does a multiplication or two a bit of e, and the server sends both the key and the chain, so a 4,095-bit exponent made one RSA-4096 verification about 250 times 65537’s, and the path builder tries up to 64. 64 bits is OpenSSL’s bound for moduli over 3,072 bits (RSA_MAX_PUBEXP_BITS); BoringSSL’s is 33, and the Web PKI uses 65537. Measured on an Apple M4 (LLVM, tests/programs/rsa_driver.cho’s M, a 4,096-bit modulus, best of 5): 0.79 ms with 65537, 3.15 ms with 2^64 − 1, 176 ms with 2^4095 − 1.
  • The modulus and exponent come in as big-endian bytes, as packages/x509 locates them (view[rsa_modulus_*]).

3.2 RSASSA-PKCS1-v1_5: build the block and compare it

pkcs1_verify(hash_len, n, e, digest, sig, work):

  1. sig must be exactly k bytes, where k is the modulus length in bytes (RFC 8017 §8.2.2 step 1).
  2. As an integer it must be below n (§5.2.2 step 1).
  3. m = sig^e mod n, written as k bytes.
  4. The expected block is built, not parsed: 00 01 FF … FF 00 || DigestInfo || digest, using the fixed DER DigestInfo prefix for the hash (with its NULL parameters), padded to k bytes. The two blocks are compared in full.

Comparing the whole block, instead of parsing m, is what rules out the Bleichenbacher-2006 class of forgeries. Those depend on a lenient parser: one that skips the padding, accepts a short FF run, reads the DigestInfo’s lengths, or allows trailing garbage. Here there is nothing to parse, so there is one refusal, rsa-pkcs1-mismatch. Telling a caller which byte differed would help no one but an attacker.

The DigestInfo without its NULL is refused. RFC 8017 §9.2 note 2 says verifiers may accept it, and Wycheproof marks one such case per file “acceptable”. Accepting it would mean building and comparing a second block, for a form no current signer produces.

3.3 RSASSA-PSS

pss_verify(hash_len, mgf_hash_len, salt_len, n, e, digest, sig, work) follows EMSA-PSS-VERIFY (RFC 8017 §9.1.2) exactly: emBits = modBits - 1, MGF1 over mgf_hash_len, and a fixed salt length.

TLS 1.3’s rsa_pss_rsae_sha256/384/512 are pss_verify(h, h, h, ...): the MGF1 hash is the message hash, and the salt is as long as the digest (RFC 8446 §4.2.3). The salt length is a parameter, not inferred, because a verifier that recovers it from the padding accepts more than the signer meant. Each PSS step that can fail has its own tag:

TagStep of §9.1.2
rsa-pss-length3: emLen < hLen + sLen + 2
rsa-pss-trailer4: the last byte is not 0xbc
rsa-pss-top-bits6: the bits above emBits are not zero
rsa-pss-padding10: DB is not zeros, then 0x01
rsa-pss-mismatch14: H != H'

3.4 Refusals

CodeTagWhen
-1bigmod-even-modulusn even
-2bigmod-modulus-sizen < 3 or over 4,096 bits
-3bigmod-not-reduceda >= n
-4bigmod-exponente empty
-5bigmod-output-lengthout not as long as n
-6bigmod-work-lengthwork shorter than work_len()
-10rsa-modulus-sizethe modulus outside 2,048 to 4,096 bits
-11rsa-even-modulusthe modulus even
-12rsa-exponente even, below 3, not shorter than the modulus, or over 64 bits
-13rsa-hasha hash length other than 32, 48 or 64
-14rsa-digest-lengththe digest not hash_len bytes
-15rsa-signature-lengththe signature not exactly k bytes
-16rsa-signature-rangethe signature not below the modulus
-17rsa-pkcs1-mismatch§3.2
-18 to -22rsa-pss-*§3.3

std.rsa checks the key itself (-10 to -12) before it calls std.bigmod, so a caller of std.rsa sees an rsa-* tag for a bad key, never a bigmod-* one.

4. How it is tested

  • Wycheproof:

    • rsa_signature_{2048,3072,4096}_sha{256,384,512}_test.json (PKCS#1 v1.5);
    • rsa_pss_*_test.json for 2,048, 3,072 and 4,096 bits;
    • rsa_pss_misc_test.json, whose SHA-1 and SHA-224 cases are counted as not supported.

    Every valid case must verify and every invalid one must be refused. The counts are listed.

  • NIST CAVP SigVer15_186-3.rsp and SigVerPSS_186-3.rsp, for SHA-256/384/512. Moduli of 2,048 and above must give the file’s P/F; 1,024 and 1,536 must be refused as rsa-modulus-size.

  • pow_mod against Python’s pow(a, e, n) on 100,000 random inputs, with moduli of random odd sizes from 2 to 4,096 bits.

  • Against OpenSSL: 1,000 signatures made by openssl dgst -sign and 1,000 with one bit flipped, each verified by both openssl dgst -verify and std.rsa. The two must give the same answer every time.

  • Cost: verifications per second for 2,048 and 4,096 bits, on both backends, with the command used.

All of it is run by tests/programs/rsa_driver.cho, which reads one case per line. crates/cancho/tests/conformance/rsa.rs runs the vectors and the refusals; scripts/rsa_differential.py runs Python and OpenSSL; scripts/rsa_mutants.py checks that the vectors would notice a bug (§5.4).

5. Results

5.1 Wycheproof

The 17 files in tests/vectors/wycheproof/ (rsa_signature_* and rsa_pss_*, copied unchanged), 3,368 cases:

ExpectedAnswerCases
validok694
invalidrsa-pkcs1-mismatch2,201
invalidrsa-pss-mismatch112
invalidrsa-pss-padding106
invalidrsa-pss-trailer54
invalidrsa-signature-length53
invalidrsa-signature-range41
invalidrsa-pss-top-bits2
acceptable (MissingNull)rsa-pkcs1-mismatch, as §3.2 decides9
valid, over SHA-1 or SHA-224 (rsa_pss_misc)not run: no such hash in std96

Every valid case verifies and every invalid one is refused. Most of the 2,201 PKCS#1 refusals are DigestInfo encodings that a lenient parser might accept: indefinite lengths, long-form or overflowing lengths, garbage prepended or appended, a modified OID, changed tags, a truncated digest, a missing DigestInfo. There are also an unhashed message and an unreduced signature. The one comparison catches all of them. rsa_pss_misc’s 54 groups over SHA-256/384/512 use every pairing of message hash, MGF1 hash and salt length (0, 20, 28, 32, 48, 64), so the mgf_hash_len and salt_len parameters are checked against cases where they differ from the hash.

The directory could not be listed from here, so the files were fetched by name. Five names tried do not exist under testvectors_v1/ (404): rsa_pss_2048_sha512_mgf1_64, rsa_pss_3072_sha384_mgf1_48, rsa_pss_3072_sha512_mgf1_64, rsa_pss_2048_sha512_256_mgf1_28 and rsa_signature_test. A Wycheproof file with another name would have been missed.

5.2 NIST CAVP SigVer

SigVer15_186-3.rsp and SigVerPSS_186-3.rsp, with the SHA-256/384/512 cases (tests/vectors/cavp/README.md), 270 each:

  • for 2,048, 3,072 and 4,096 bits, 27 P and 135 F a file, every one given the file’s result;
  • for 1,024 and 1,536 bits, 108 a file, every one refused as rsa-modulus-size.

The files give a reason for each F: the message changed, e changed, the signature changed, or the encoded block is malformed (the hash moved left, or the 00 after the padding removed), 45 cases each in the PKCS#1 file. The exponents are 3, 17 and 65537.

5.3 Against Python and OpenSSL

python3 scripts/rsa_differential.py <driver> pow 100000:

  • pow_mod against Python’s pow: 100,000 cases, 0 differences, in 3 min 17 s on Cranelift.
  • Moduli have random odd sizes from 2 to 4,096 bits, with limb boundaries drawn more often.
  • 1% of the exponents are as long as the modulus.
  • 1,000 cases have a = 0, 1 or n - 1.
  • 1,000 have n = p^j and a = p^i, where a^e mod n is often 0 (§5.4).

python3 scripts/rsa_differential.py <driver> openssl 1000:

  • Against OpenSSL 3.0.13: 1,000 signatures made by openssl dgst -sign and the same 1,000 with one random bit flipped.
  • Keys: three of each of 2,048, 3,072 and 4,096 bits. Hashes: SHA-256, SHA-384 and SHA-512. Padding: PKCS#1 v1.5, or PSS with rsa_pss_saltlen:digest.
  • OpenSSL accepted all 1,000 as made and refused all 1,000 flipped. std.rsa gave the same answer every time: 0 differences.

5.4 Mutants

python3 scripts/rsa_mutants.py target/release/cancho: 23 mutants, 23 killed. Each mutant is one plausible bug in std/bigmod.cho or std/rsa.cho. The script builds it and runs §5.1 and §5.2’s cases, the refusal rows and 2,000 pow_mod rounds. Three mutants survived the first version of the script, and each one taught something:

  • Lazy final reduction. Montgomery multiplication was changed to subtract n only when the result overflows a limb. It survived 3,800 cases and the 100,000-round differential, because the result is still right mod n. Unreduced values feed the next multiplication harmlessly, and the last multiplication by 1 brings the result below n + 1. The one wrong answer is n where 0 is right: a^e ≡ 0 mod n, which no random case reaches. The differential now has the p^j cases, and they kill it.
  • Two limb-straddling mutants were equivalent, not survivors. A byte’s offset in a limb is 8j mod 30, which is always even, so off > 23 selects exactly what off > 22 does. They were replaced by off > 24, which drops a real case, and both are killed.

The mutant “a short signature accepted” was killed by a trap, not a wrong answer. With the length check loosened to len(sig) > k, a signature of 0 or 1 bytes (Wycheproof has such cases) makes pkcs1_verify lay out its 00 01 past the end of the block buffer: the block is placed by the signature’s length. A one-byte-short signature does not trap; it is refused as a mismatch. So the length check also keeps the block’s indexing in range, and it stays before the block is built. The driver’s buffered output was lost with the trap, so the script reports the case where the answers stop, not the case that trapped.

5.5 Cost

python3 scripts/rsa_bench.py <driver>, on one core of an Intel Xeon at 2.80 GHz, with e = 65537 and SHA-256:

CraneliftLLVMOpenSSL 3.0.13 (openssl speed rsa2048 rsa4096)
RSA-2048 PKCS#1 v1.51,109/s (0.90 ms)2,808/s (0.36 ms)49,794/s
RSA-2048 PSS1,104/s2,582/s
RSA-4096 PKCS#1 v1.5291/s (3.4 ms)665/s (1.5 ms)12,851/s
RSA-4096 PSS281/s702/s

On LLVM, verification is 18 to 19 times slower than OpenSSL’s: 30-bit limbs against 64-bit ones with assembly, and a bounds check on every limb read. A handshake verifies one signature per certificate in the chain plus CertificateVerify, usually 2 to 4. With RSA-2048 keys on LLVM that is about 1 ms. Repeated runs vary by about 10%.

By count, about 40% of each verification’s Montgomery multiplications compute R² mod n (§2.2). For RSA-2048 that is 14, against 19 for the exponent 65537 and the conversions; this is counted, not timed. Caching R² per key would not help a TLS client, which uses each key once per handshake.

6. Found

  • load refused a value whose top byte straddles two limbs, even when its high bits were zero. It refused 648 of the first 3,000 random pow_mod cases, among them a = 0x10530d0f mod 0x246dd613, which fits in one 30-bit limb. The check was on the byte, not on the bits that spill over. It was found by the first differential run, before any vector, and fixed.
  • A mutant equivalent to the code it mutates, and a bug class no random input reaches (§5.4).
  • Five Wycheproof file names that do not exist (§5.1).

7. Not done

  • Signing and decryption. Not needed by a TLS client, and those would need constant time.
  • SHA-1 and SHA-224. std has neither hash. A SHA-1-signed certificate fails in #206 as x509-unsupported-algorithm.
  • The independent review. #209.

std.ecdsa: ECDSA verification on P-256 and P-384

Status: built. Not independently reviewed (#209). Sub-issue 7 (#204) of the self-contained TLS 1.3 client (#197). Most web certificates are now ECDSA: 39 of the 128 system roots have EC keys (docs/x509.md §5.1), and a server with an EC key signs its CertificateVerify with ecdsa_secp256r1_sha256 or ecdsa_secp384r1_sha384. P-521 is not in docs/tls-pure.md §5.2’s list.


1. What is built

FileWhat
std/ecdsa.cho, module std.ecdsaverify_der and verify_raw on P-256 and P-384, over a digest of any length
std/bigmod.choa register API beside pow_mod: setup, then mul, add, sub, inverse, to_mont, from_mont on numbers held in the caller’s work (§2)
scripts/ecdsa_params.pyprints the curve constants from openssl ecparam -param_enc explicit, after checking them (§2.1)

Verification only, on public data, so variable time is allowed (docs/tls-pure.md §4.2), and the code branches on the scalars’ bits.

2. Arithmetic

The field arithmetic is std.bigmod’s, not a second copy. ECDSA needs arithmetic modulo the field prime p and the group order n. std.bigmod already has Montgomery multiplication on 30-bit limbs (docs/rsa.md §2), and the duplication check (conformance/duplication.rs) would refuse a copy. So std.bigmod gains a register API:

  • setup(n, w) checks n and stores k, n', n and R² mod n in w;
  • numbers then live in registers reg(i) of the same w;
  • inverse is Fermat’s a^(n-2), correct only for a prime modulus, which p and n are.

pow_mod now calls setup, so the modulus checks are in one place.

A P-256 number is 9 limbs and a P-384 number 13. A generic Montgomery multiplication does not use the special form of the NIST primes, and is slower than one that does (§5.4). The design chooses one well-tested multiplication over a fast one per prime.

2.1 Constants

p, b, G and n for both curves are printed by scripts/ecdsa_params.py from OpenSSL’s explicit parameters. The script also checks that:

  • a = p - 3;
  • G is on the curve;
  • p and n are prime (64 Miller-Rabin rounds);
  • n·G is the point at infinity.

They are kept in the source as hex strings and decoded at each verification: five decodings of 32 or 48 bytes, small next to the thousands of multiplications a verification does (not separately measured).

2.2 Points

Points are in Jacobian coordinates (X, Y, Z) in Montgomery form, with Z = 0 the point at infinity. Doubling uses dbl-2001-b for a = -3, which gives Z = 0 by itself for infinity and for Y = 0. Addition uses add-2007-bl, with the cases its formula does not cover handled by branches:

  • either input infinite;
  • the same x (H = 0): a doubling when the points are equal, infinity when they are opposite.

An attacker chooses Q and the signature, so Q = ±G and u1·G = ±u2·Q part-way through the ladder are inputs to expect, not corner cases.

u1·G + u2·Q is computed by Shamir’s trick: one shared doubling chain, adding G, Q or the precomputed G + Q at each bit.

3. Verification

verify_raw(curve, digest, point, sig, work) takes sig = r || s, each exactly the curve’s size. verify_der takes SEQUENCE { INTEGER r, INTEGER s } in strict DER, as packages/x509 reads DER: a definite length in its fewest bytes, minimal INTEGERs, nothing after the SEQUENCE. Both then run SEC 1 §4.1.4:

  1. The key point is 04 || x || y (uncompressed, as packages/x509 requires), with x, y < p and on the curve. The curves have cofactor 1, so on the curve means in the group.
  2. 1 <= r, s < n.
  3. e is the digest’s leftmost bits(n) bits, which is whole bytes for both curves. It is reduced mod n once (e < 2n).
  4. w = s^-1, u1 = e·w, u2 = r·w, all mod n.
  5. R = u1·G + u2·Q. Infinity is refused.
  6. Valid when x(R) mod n = r.
CodeTagWhen
-30ecdsa-curvea curve other than P-256 or P-384
-31ecdsa-point-encodingthe key is not `04
-32ecdsa-point-infinitythe key is the point at infinity (the one byte 00)
-33ecdsa-point-rangea coordinate not below p
-34ecdsa-point-not-on-curvey² != x³ - 3x + b
-35ecdsa-raw-lengtha raw signature not twice the curve’s size
-36ecdsa-der-structurenot one SEQUENCE of two INTEGERs and nothing else; an indefinite length
-37ecdsa-der-non-minimala length or an INTEGER not in its fewest bytes
-38ecdsa-r-ranger zero, negative, or not below n
-39ecdsa-s-rangethe same for s
-40ecdsa-result-infinityu1·G + u2·Q is the point at infinity
-41ecdsa-mismatchx(R) mod n != r
-42ecdsa-work-lengthwork shorter than work_len()

4. How it is tested

  • Wycheproof:

    • ecdsa_secp256r1_sha256_test.json and ecdsa_secp384r1_sha384_test.json (DER), and their _p1363 raw forms;
    • ecdsa_secp256r1_sha512, ecdsa_secp384r1_sha256 and ecdsa_secp384r1_sha512, for a digest longer and shorter than n.

    Every valid case must verify, and every invalid one must be refused. The files have no “acceptable” cases.

  • NIST CAVP FIPS 186-3 SigVer.rsp, for P-256 and P-384 with SHA-256/384/512.

  • Against OpenSSL:

    • 1,000 signatures made by openssl dgst -sign on each curve, and each with one bit flipped;
    • both verified by openssl dgst -verify and by std.ecdsa, which must agree every time.
  • The register API against Python: random mul, add, sub and inverse on both primes.

  • Mutants: a mutation run, as for RSA.

  • Cost: verifications per second per core.

tests/programs/ecdsa_driver.cho runs all of it. crates/cancho/tests/conformance/ecdsa.rs has the vectors and refusals, scripts/ecdsa_differential.py runs Python and OpenSSL, and scripts/ecdsa_mutants.py the mutants.

5. Results

5.1 Wycheproof

The seven files, 3,098 cases, copied unchanged into tests/vectors/wycheproof/:

ExpectedAnswerCases
validok1,370
invalidecdsa-der-structure875
invalidecdsa-r-range462
invalidecdsa-s-range152
invalidecdsa-mismatch119
invalidecdsa-der-non-minimal45
invalidecdsa-raw-length40
invalidecdsa-result-infinity35

Every valid case verifies and every invalid one is refused, on the first run of the code.

  • The 875 DER refusals are Wycheproof’s BER and malformed encodings: indefinite lengths, long-form lengths, wrong tags, garbage and missing parts.
  • The 35 ecdsa-result-infinity cases are signatures built so that u1·G + u2·Q is infinity. std.ecdsa reaches that point and refuses it there, rather than miscomputing it.
  • The SHA-512 files check that a digest longer than n is truncated, and ecdsa_secp384r1_sha256 that a shorter one is used whole.

Two file names tried, ecdsa_secp256r1_sha256_bitflip_test.json and its P-384 form, do not exist (404).

5.2 NIST CAVP

ECDSA_SigVer_186-3.rsp (tests/vectors/cavp/README.md), 90 cases: P-256 and P-384, each with SHA-256, SHA-384 and SHA-512.

  • 18 P, all verify.
  • 72 F, all ecdsa-mismatch: the file’s reasons are “message changed”, “R changed”, “S changed” and “Q changed”, 18 each. NIST’s changed Q is another point on the curve, so it fails as a mismatch, not as an invalid point.

5.3 Against Python and OpenSSL; mutants

  • python3 scripts/ecdsa_differential.py <driver> registers: std.bigmod’s mul, add, sub, inverse and load_reduced, modulo P-256’s and P-384’s p and n, against Python’s integers. 100,000 operations, 0 differences.

  • python3 scripts/ecdsa_differential.py <driver> openssl:

    • 1,000 signatures on each curve, from three openssl genpkey keys a curve, signed by openssl dgst -sign over random messages, and the same with one bit of the DER flipped;
    • OpenSSL 3.0.13 accepted the 2,000 unflipped signatures and refused the 2,000 flipped ones;
    • std.ecdsa gave the same answer all 4,000 times.
  • python3 scripts/ecdsa_mutants.py target/release/cancho: 21 mutants, 21 killed. One of them, load_reduced not subtracting when its input equals n exactly, first survived. That run had every vector and 2,000 register rounds, so two things were added:

    • a fixed case whose digest is exactly n, so e = 0. Its signature was made in Python with a key and nonce from random.Random(2041). pyca/cryptography accepts it over that digest and refuses it over 00…01.
    • load_reduced in the register differential, with n itself drawn often.

    The mutant then died only in the second. Left unreduced, n behaves as 0 inside every Montgomery multiplication, which is the right answer here, so ECDSA’s results never showed it. The register API’s contract, that a value is below the modulus, is still now tested directly.

5.4 Cost

Corrected (#380, docs/p256-fast.md): the prime’s form is now used for P-256, in verification as in signing and key exchange: ecdsa.verify_raw is 106 µs on an M4 against 814, and 258 µs against 1,742 on the loaded i7 (docs/p256-fast.md §9); P-384 is unchanged. The table below is main before that change.

python3 scripts/ecdsa_bench.py <driver>, on one core of an Intel Xeon at 2.80 GHz:

CraneliftLLVMOpenSSL 3.0.13 (openssl speed ecdsap256 ecdsap384)
P-256, SHA-256271/s (3.7 ms)675/s (1.5 ms)13,282/s
P-384, SHA-384101/s (9.9 ms)229/s (4.4 ms)1,209/s

On LLVM, P-256 is 20 times slower than OpenSSL and P-384 5 times. OpenSSL’s P-256 is a hand-tuned implementation that uses the prime’s special form; its P-384 is generic, closer to this. A handshake with an ECDSA chain verifies two or three signatures plus CertificateVerify: 4 to 6 ms with P-256 on LLVM. That is the figure #210 must weigh against OpenSSL’s. §2 names the speed-up left untaken: arithmetic specialised to each prime.

6. Found

  • Nothing wrong in the first run against 3,098 Wycheproof cases, 90 NIST cases, 4,000 OpenSSL cases and 100,000 register operations. The bugs the RSA slice found were in the shared limb code, which this slice reuses, so they were already fixed (docs/rsa.md §6).
  • A test gap: load_reduced’s contract was not tested directly (§5.3).

7. Not done

  • P-521. docs/tls-pure.md §5.2 lists P-256 and P-384 only. One system root has a P-521 key, and a chain through it would be refused by #206.
  • Compressed points. packages/x509 refuses them already.
  • Prime-specific reduction (§5.4). Done for P-256 (#380); P-384 still open.
  • The independent review. #209.

packages/tls: the TLS 1.3 client handshake and record layer

Status: built (#205, all three PRs); results in §9 (PR 2) and §10 (PR 3). Sub-issue 8 (#205) of the self-contained TLS 1.3 client (#197). docs/tls-pure.md already fixes the API (§2.2), the cipher policy (§3), the threat model (§4), the limits and messages (§7.1) and the refusal tags (§8). This document settles what that left open for the code:

  • how the work is split into PRs;
  • how a slot is laid out;
  • the state machine;
  • what “the certificate is verified” means before #206 builds chains;
  • how the issue’s byte-for-byte gate is met when RFC 8448’s traces use a cipher this client does not have.

1. Three PRs, not one

#205 is the largest slice of the epic, and each part has its own evidence. Each PR passes the gate on its own:

  1. This design.

  2. The protocol without a network.

    • records: framing, ChaCha20-Poly1305 protection, sequence numbers;
    • handshake messages: encoding, parsing, fragmentation and coalescing;
    • the key schedule over the transcript;
    • the state machine.

    Its evidence is offline: the key schedule against RFC 8448 (§5.1), and a recorded handshake replayed byte for byte (§5.2).

  3. The engine and the network.

    • the API of docs/tls-pure.md §2.2 over 64 slots;
    • real handshakes against openssl s_server, a Python ssl server and tlslite-ng;
    • 64 connections on one poller, fed byte by byte and in large reads;
    • the misbehaving-server cases (§6);
    • the mutants (§7).

2. Files

Every source file stays under 2,000 lines (crates/cancho/tests/files.rs), so the package is split by concern, one module per file:

FileModuleWhat
packages/tls/record.chotls_recordrecord framing, the per-direction AEAD key, IV and sequence number, the nonce (RFC 8446 §5.3), TLSInnerPlaintext padding
packages/tls/message.chotls_messageClientHello encoding, and parsing of ServerHello, EncryptedExtensions, Certificate, CertificateVerify, Finished, NewSessionTicket and KeyUpdate, each refusing with a tag
packages/tls/client.chotls_clientthe state machine (§4), the transcript, the key schedule’s use of std.hkdf, signature checks. Since #207: TLS 1.3’s handshake and the interface; what both handshakes share moved to slot.cho
packages/tls/slot.chotls_slot#207: the slot’s layout, states, transcript, record queue, alerts, the ECDH share, chain and signature checks (docs/tls-parity.md §3.4.1)
packages/tls/client12.chotls_client12#207: the TLS 1.2 handshake (docs/tls-parity.md §3.4)
packages/tls/tls.chotlsthe engine: slots, feed/take/send/recv/event (PR 3)
packages/tls/hello.chotls_hellodocs/tls-server.md step 2: the ClientHello parsed with a server’s rules, and the messages a server sends
packages/tls/server.chotls_serverthe same: the server’s state machine, its key schedule, the signature and the client’s Finished
packages/tls/identity.chotls_identitythe same: a server engine’s identities (chains, P-256 keys, names) and ALPN list
packages/tls/ticket.chotls_ticketdocs/tls-server.md §12: the server’s session tickets: the sealed ticket, the ring of ticket keys and its rotation, and the rules a ticket must pass

The package imports std, so it is published with --std (docs/package-system.md §4.8). It requires packages/x509, which is published with it (docs/x509.md §1).

3. A slot

As docs/tls-pure.md §2.1 requires, the engine owns two boxes, one of ints and one of bytes, and a slot is a fixed stride of each. Every offset is a named function, as in packages/x509’s view. Nothing is allocated per connection, and no size comes from the peer. The byte stride holds:

  • the 64 KiB handshake reassembly buffer (docs/tls-pure.md §7.4);
  • one incoming and one outgoing record;
  • the queue of bytes for take;
  • the X25519 secret;
  • the four traffic secrets, keys and IVs;
  • the transcript hash state;
  • the host name.

4. The state machine

StateWaiting forThen
start—ClientHello queued → wait_sh
wait_shServerHello (plaintext)handshake keys → wait_ee
wait_eeEncryptedExtensions→ wait_cert_cr
wait_cert_crCertificate, or CertificateRequest then Certificate→ wait_cv
wait_cvCertificateVerifysignature checked → wait_finished
wait_finishedserver Finishedchecked; client Finished queued; application keys → connected
connectedapplication data, NewSessionTicket, KeyUpdate, alerts
closed, failed—

Anything else in a state is tls-unexpected-message with an unexpected_message alert.

  • HelloRetryRequest is refused (tls-hello-retry, docs/tls-pure.md §3.3). The ServerHello random that marks one (RFC 8446 §4.1.3) is checked before anything else in the message. Changed by #207 (docs/tls-parity.md §3.3): one HelloRetryRequest to P-256 or P-384 is followed; the random still tells it apart first.
  • A downgrade sentinel in the last 8 bytes of the random is tls-protocol-version. Corrected (#208, docs/tls-assurance.md §4): only in a ServerHello for TLS 1.2 or below, as RFC 8446 §4.1.3 says. In a TLS 1.3 ServerHello the sentinels are random bytes, and OpenSSL takes them; the client refused them, which the differential test against openssl s_client found.
  • change_cipher_spec: one is accepted after ServerHello and before the server’s Finished, and only if it is exactly 01 (Appendix D.4). Corrected (§10.3): this said “before the first encrypted record”. The code accepts one until the server’s Finished, which is what Appendix D.4 asks of a receiver.
  • KeyUpdate is applied, and answered when update_requested (docs/tls-pure.md §7.1).

5. Before #206: a certificate is accepted only when pinned

Chain building, name matching and validity times are #206. A client that skipped them and still said established would be a client that trusts anyone. So until #206 the trust store is used as a set of pins:

  • the leaf’s DER must equal a certificate in the store, byte for byte;
  • the leaf’s key must verify CertificateVerify (RSA-PSS through std.rsa, or ECDSA through std.ecdsa);
  • anything else is x509-unknown-issuer.

A pinned leaf needs no chain and no name check: the operator named that exact certificate. This is enough for every test in §6, which use certificates made for them. #206 replaces the pin check with real chain validation, behind the same call.

Ended (#206, docs/x509-verify.md §9): tls.trust now loads roots, and Certificate is verified against them, the host and the time start was given. The pins are gone. The tests of §9 and §10 were re-recorded with a CA for each server; their text below describes them as they were built.

6. Evidence

6.1 The byte-for-byte gate, and why it is not RFC 8448’s trace

The issue asks for RFC 8448’s traces, replayed byte for byte. Two facts stand in the way:

  • RFC 8448’s Simple 1-RTT Handshake negotiates TLS_AES_128_GCM_SHA256. s2n-tls’s transcription of it sets that suite (below), and as far as I recall, without the RFC to check, the other traces do the same. This client offers only TLS_CHACHA20_POLY1305_SHA256 (docs/tls-pure.md §3.2), so its ClientHello cannot equal the trace’s. Its encrypted messages cannot either, nor can it decrypt the trace’s server flight.
  • The RFC’s text is not reachable from the machine this is built on (rfc-editor.org and datatracker.ietf.org are outside the network policy). docs/hkdf.md §4 already met this: some RFC 8448 values written from memory did not reproduce.

So the gate is met in two parts:

  1. RFC 8448’s key schedule, in full, from AWS s2n-tls’s tests/unit/s2n_tls13_secrets_rfc8448_test.c. That file transcribes RFC 8448 §3 and cites it section by section. It covers:

    • every secret of the Simple 1-RTT Handshake;
    • the transcript hashes they are derived from;
    • the application traffic secrets that docs/hkdf.md §4 left out because they did not reproduce from memory.

    Each value is kept only if Python’s hmac reproduces it from the file’s own inputs. Where one disagrees with docs/hkdf.md, that document is corrected in place.

  2. A recorded ChaCha20 handshake, replayed byte for byte.

    • A Python TLS 1.3 server (tlslite-ng 0.8.2, pure Python, with ChaCha20-Poly1305 and X25519) runs with its randomness fixed: getRandomBytes is replaced by a seeded generator.
    • The client is this one, seeded with fixed bytes.
    • The bytes each side sent are written to tests/vectors/tls/.
    • The test feeds the server’s bytes to the client with no network. Every byte the client produces must equal the recording: ClientHello, Finished, an encrypted request and close_notify.

    This proves determinism, and it guards against regressions. Interoperability comes from tlslite-ng, an implementation written independently of this one, having accepted the client’s Finished and decrypted its request when the recording was made. There are three recordings:

    • an RSA-PSS server certificate;
    • an ECDSA P-256 server certificate;
    • a server that sends a HelloRetryRequest, which must be refused.

6.2 Real servers (PR 3)

The client does a handshake and an HTTP request, and must see the body and close_notify, against:

  • openssl s_server -tls1_3 -ciphersuites TLS_CHACHA20_POLY1305_SHA256 (OpenSSL 3.0.13);
  • a Python ssl server (OpenSSL underneath, so a different configuration path);
  • tlslite-ng.

nginx is not installed here, and the issue makes it conditional.

Then 64 connections on one thread through the poller:

  • each fed one byte at a time, which tests fragmentation and reassembly;
  • each fed in reads of up to 64 KiB, which tests coalescing.

6.3 A misbehaving server (PR 3)

tlsfuzzer tests servers; this is a client. The equivalent is a server that lies and changes one thing in its flight. Corrected (§10.1): this said the server would be tlslite-ng with hooks. It is written instead on pyca/cryptography’s primitives (scripts/tls_liar.py), so that every byte it sends is fixed and each case can be recorded and replayed. Each case must end in its own tag:

Server doesTag
sends EncryptedExtensions before ServerHello, or Finished before CertificateVerifytls-unexpected-message
adds an extension the client did not offer (ALPN, early_data)tls-unsupported-extension
chooses TLS 1.2, or puts a downgrade sentinel in its random (since #208, in a TLS 1.2 ServerHello only, §4 above)tls-protocol-version
chooses a cipher suite that was not offeredtls-no-shared-cipher
sends HelloRetryRequesttls-hello-retry (since #207, followed when it asks for P-256 or P-384; the HRR cases are in docs/tls-parity.md §3.3.1)
sends an all-zero X25519 sharetls-key-share
sends a record over 2^14 + 256 bytestls-record-overflow
flips one bit in an encrypted recordtls-bad-record-mac
signs CertificateVerify with another keytls-bad-certificate-verify
sends a wrong Finishedtls-bad-finished
sends an unpinned certificatex509-unknown-issuer
sends a fatal alerttls-alert

The list goes in the issue’s closing comment, each case marked passed, refused for the right reason, or not applicable.

7. Mutants (PR 3)

At least 15, each killed by §6 (22 were built; §10.2):

  • the server Finished not checked;
  • the transcript missing a message;
  • the transcript hashed over the record header;
  • a sequence number not incremented;
  • the nonce built without the sequence;
  • the downgrade sentinel ignored;
  • the HRR random ignored;
  • an unexpected extension accepted;
  • the record limit off by one;
  • inner-plaintext padding not stripped;
  • the content type taken from the outer header;
  • the client keys used for reading;
  • a KeyUpdate not answered;
  • the CertificateVerify context string misspelled;
  • a fragmented message assembled in the wrong order;
  • the alert level ignored.

8. Secrets

tls.drop and the end of a connection overwrite the slot’s secrets, keys, IVs and X25519 secret with zeros. The end is a failure, once its alert is sealed, or close_notify both sent and received (§10.3). The language guarantees no erasure (docs/tls-pure.md §7.3): the optimiser may remove stores to memory that is not read again, and freed memory is not cleared. So this is best effort, and the package says so. Because the slot’s boxes live as long as the engine, the zeroing stores are not to memory about to be freed. That is the case where an optimiser removes them most readily.

9. PR 2: the protocol, offline (results)

9.1 What was built

FileLinesWhat
packages/tls/record.cho (tls_record)339framing, the nonce, seal and open, every refusal code of the package
packages/tls/message.cho (tls_message)464ClientHello, and strict parsers for every server message
packages/tls/client.cho (tls_client)1,224one connection: start, feed, take, send, recv, finish, event, drop; the state machine, transcript, key schedule, pins and signature checks
tests/programs/tls_driver.chothe package driven one line at a time, so a harness can put a real server on the other end

The slot is larger than docs/tls-pure.md §7.4 estimated: 181,927 bytes and 158 words, about 179 KiB per connection, or 11.2 MiB for 64. The estimate was about 100 KiB. The difference is buffers the estimate did not count:

  • room for three outgoing records;
  • separate buffers for one opened record, received application data, and the leaf certificate (kept from Certificate to CertificateVerify).

docs/tls-pure.md §7.4 is corrected to point here. Shrinking it is that document’s question 6, and #208’s to measure.

Corrected (#383, docs/tls-memory.md): the slot is now 187,191 bytes and 10,241 words, 262.8 KiB: std.ecdh’s work area (9,593 words) had been added to it. And the slot is not what an established connection costs: it keeps a core of 4.5 KiB (a server) or 8.3 KiB (a client), and the buffers and the work area are leased (tls-memory.md §7). The byte layout now starts with the core (the keys and the ticket state), then the buffers; the integers end with the transcripts and the work area.

9.2 Evidence

  • The record layer against pyca/cryptography: scripts/tls_record_differential.py. 2,000 records sealed and 2,000 opened (with random padding, and one in three with a bit flipped), sequence numbers up to 2^62 - 1, contents up to 2^14 bytes: 0 differences.

  • Two recorded handshakes against tlslite-ng 0.8.2 (scripts/tls_trace.py, tests/vectors/tls/): one with an RSA-2048 certificate (CertificateVerify by RSA-PSS), one with ECDSA P-256. In each recording, tlslite-ng accepted the client’s Finished, received GET / HTTP/1.0 and answered, and the client decrypted the body and saw close_notify. conformance/tls.rs replays both byte for byte on both backends.

  • Fragmentation and coalescing. The same server bytes fed one byte a line, and the whole handshake flight in one line, give exactly the bytes the client sent and received before.

  • A wrong pin is refused (x509-unknown-issuer) with an unknown_ca alert.

  • 15 crafted ServerHellos and records, each refused with its own tag and a fatal alert:

    • HelloRetryRequest;
    • the downgrade sentinel (since #208 accepted in a TLS 1.3 ServerHello, and refused in a TLS 1.2 one); a TLS 1.2 ServerHello; supported_versions 1.2;
    • AES-128-GCM chosen; ALPN never offered; a duplicate extension; another session id;
    • a P-256 share; an all-zero share; a byte after the extensions;
    • a record over 2^14 + 256; an unknown content type; application data before the handshake.

    Plus a bit flipped in the encrypted flight (tls-bad-record-mac). The case “a byte after the extensions” first passed, for the wrong reason: the test appended the byte after the record, not inside the message.

  • RFC 8448’s key schedule (§6.1): every value in s2n-tls’s transcription reproduces in Python. The three application-stage secrets that docs/hkdf.md had left out are now rows of tests/vectors/kdf.txt (62 rows), and that document is corrected in place.

Tried by hand, not yet a gate. The same driver, through a Python harness over TCP, completed a handshake and an HTTP request against:

  • openssl s_server -tls1_3 -ciphersuites TLS_CHACHA20_POLY1305_SHA256 -groups X25519 (OpenSSL 3.0.13);
  • a Python ssl server.

Each was run with P-256, P-384, RSA-2048 and RSA-4096 certificates, and OpenSSL’s NewSessionTickets were parsed and dropped. PR 3 makes this a committed test, through the engine and a poller rather than a harness.

9.3 Found

  • The application secrets of RFC 8448 (§9.2): docs/hkdf.md’s open item is closed.
  • The design’s memory estimate (§9.1).
  • Two bugs caught reading the code before it ran:
    • after the handshake, the client erased the range from the client handshake secret to the master secret, which included the application secrets that KeyUpdate needs;
    • the client’s Certificate message wrote its length into one byte, when the echoed context can make it 259.

10. PR 3: the engine and the network (results)

10.1 What was built

FileLinesWhat
packages/tls/tls.cho (tls)346the engine of docs/tls-pure.md §2.2: open, trust, seed, start, feed, take, send, recv, eof, finish, event, failure, drop, close; slots as strides of two boxes; the pins; a fast-key-erasure DRBG over chacha20.block
packages/tls/client.cho1,234peer_eof, and the secrets overwritten when a connection ends (§8)
tests/programs/tls_many.cho467up to 256 connections on one thread through the Poller: dial, handshake, a 2^14-byte request, read to close_notify; one line per connection with the response’s length and SHA-256
scripts/tls_live.pyPython ssl, openssl s_server and tlslite-ng servers on this machine, 64 connections at once
scripts/tls_liar.pythe lying server (§6.3) and the recorded streams for cargo test
scripts/tls_mutants.py§7

The engine’s API differs from docs/tls-pure.md §2.2 in three places, which that section now records:

  • trust borrows the engine and answers a count, rather than moving it;
  • eof(engine, slot) exists: the socket’s end is not close_notify, and only the engine can tell a clean end from a truncation;
  • recv answers would_block() when nothing is waiting, because 0 means close_notify.

The poller reads once a wakeup. It is level-triggered, so a slot with more waiting is reported again after the others. The first version read until the socket was empty. With one-byte reads, each slot then ate its whole flight in one turn, and “64 connections fed one byte at a time” never interleaved them. The engine’s slot-overlap mutant survived that version (§10.2).

10.2 Evidence

All of it passes the gate, and the parts that need no Python run in cargo test (conformance/tls.rs).

  • Live servers (scripts/tls_live.py, by hand). 64 connections at once, each reading 1 byte a socket read and then 65,536, each sending a 2^14-byte request and reading to close_notify:

    • a threaded Python ssl server, with P-256, P-384, RSA-2048, RSA-4096 and Ed25519 certificates, a 64 KiB body per connection: every connection ok, every body’s SHA-256 the one the server sent;
    • openssl s_server -HTTP (OpenSSL 3.0.13, ChaCha20-Poly1305 and X25519 only), P-256 and RSA-2048: every connection ok, every body the same. It serves one connection at a time, so 63 wait in its backlog;
    • tlslite-ng 0.8.2, threaded, P-256 and RSA-2048: every connection ok;
    • a Python ssl server that closes without close_notify: every connection fails tls-peer-closed.

    64 connections take 0.4 s at 65,536 a read and 3.8 to 4.2 s at one byte (three runs each), on a 64 KiB body each (the Python server is the larger share). Peak RSS of tls_many is 31.1 MiB with 1 connection and with 64. So the 64 slots (11.2 MiB, §9.1) are not what its size is made of; #208 measures where it goes. Corrected (#383, docs/tls-memory.md §4): the client’s resident memory grows by 184 KiB a connection (2.1 MiB with one, 11.2 MiB with 50, 38.9 MiB with 200, on this tree), most of it the slot’s integers and the pages of its buffers; the figure above was of another tree.

  • The lying server (scripts/tls_liar.py, tests/vectors/tls/liar.txt). 29 connections, each recorded and replayed byte for byte on both backends; a second recording is identical. Two are honest, in the hardest legal shape:

    • a change_cipher_spec;
    • EncryptedExtensions sharing a record with Certificate, and Certificate in three records;
    • CertificateVerify and Finished in one record, padded by 300 bytes;
    • a NewSessionTicket, and a record of exactly 2^14 + 256 bytes;
    • a KeyUpdate that asks for an answer, which the server decrypts under the client’s old key, then data under both new keys;
    • a KeyUpdate that does not ask, and close_notify;
    • in the second, user_canceled and then close_notify.

    The other 27 each end with their tag and the alert RFC 8446 §6.2 names. During the server’s flight the alert is plaintext, because the client’s write key changes only after the server’s Finished (Appendix A.1); after that it is under the client’s application key.

    Server doesTagAlert
    EncryptedExtensions before ServerHello; Certificate before EncryptedExtensions; Finished before CertificateVerify; application data in the flight; a NewSessionTicket in Finished’s recordtls-unexpected-message10
    ALPN, or early_data, in EncryptedExtensionstls-unsupported-extension110
    no supported_versions; supported_versions 1.2; the TLS 1.2 or TLS 1.1 downgrade sentinel (since #208, both accepted in a TLS 1.3 ServerHello: the two cases are now honest connections, docs/tls-assurance.md §4)tls-protocol-version70
    AES-128-GCM (since #207, TLS_AES_128_CCM_SHA256: AES-128-GCM is offered)tls-no-shared-cipher40
    HelloRetryRequest (since #207, followed; the twelve cases that replace this row are in docs/tls-parity.md §3.3.1)tls-hello-retry40
    an all-zero, or a low-order, X25519 sharetls-key-share47
    an encrypted record over 2^14 + 256; an inner plaintext over 2^14; a Certificate over 64 KiBtls-record-overflow22
    one bit flipped; records out of order; data under the key a KeyUpdate replacedtls-bad-record-mac20
    CertificateVerify by another keytls-bad-certificate-verify51
    a wrong Finishedtls-bad-finished51
    an unpinned certificatex509-unknown-issuer48
    a fatal alert for ServerHello; a warning-level alert in the flight; a fatal alert after the handshaketls-alertnone sent

    Every row of §6.3’s table is passed.

  • 64 connections with no Python (tests/vectors/tls/streams.txt). With a fixed seed (a test-only argument of tls_many), each ClientHello depends only on how many connections started before it. The honest server’s bytes depend only on the ClientHello. So scripts/tls_liar.py --streams records the server’s flight and reply for each of the 64, and conformance/tls.rs serves them back from a Rust thread. One in eight carries a 60 KB chain, nearly filling its slot’s 64 KiB reassembly buffer. Three runs:

    • one byte a read, then 65,536: every connection ends ok with its recorded response;
    • close_notify cut off: every connection fails tls-peer-closed.
  • 22 mutants, 22 killed (scripts/tls_mutants.py, about 2 minutes). §7’s sixteen, plus:

    • the write sequence number not incremented;
    • the pin not checked;
    • a message allowed to share a record with the next key;
    • the engine’s DRBG key not replaced;
    • the engine’s slots overlapping by half;
    • the socket’s end taken for close_notify.

    “The transcript hashed over the record header” is built as the ClientHello hashed with its own header, since no other message’s header ever reaches the transcript code. “A fragmented message assembled in the wrong order” is built as a partial message not moved to the front of the buffer. The overlapping-slots mutant survived twice. First, the requests were small, so no slot’s output buffer reached its neighbour’s keys. Second, the poller drained each socket in one turn. The 2^14-byte request and one read a wakeup kill it: 30 of 64 connections then end ok.

10.3 Found

  • §4’s change_cipher_spec rule was stricter than the code. The code is right (Appendix D.4), and §4 is corrected.
  • §8’s claim that the end of a connection erases the slot was false. Only drop did. A failure, and close_notify both ways, now overwrite the keys, secrets and last plaintext. No test can observe this; the stores are best effort, as §8 says.
  • §6.3’s tlslite-ng hooks became a server of its own. That is corrected in §6.3. A deterministic server makes each case a recording that cargo test replays with no Python.
  • The poller’s first loop did not interleave one-byte reads (§10.1).

11. The record layer in place (#448)

Added by #448 (record-in-place.md §4), which this section summarises; the design and the measurements are there. §3’s “one incoming and one outgoing record” and §8’s “last plaintext” change as follows.

  • Sealing copies the content once, to where the record is sent from in the output queue, writes the content type after it, and seals that in place (gcm.seal_in_place, chacha20.seal_in_place). No region: the nonce is made in the write IV itself (XORed with the sequence number, the AEAD run, XORed back), so tls_record.seal borrows the IV mutably.
  • Opening (tls_slot.open_record) decrypts a TLS 1.3 record straight from the input buffer into the received data, where recv reads it: application data is not copied at all. A record whose padding does not fit the room there is opened in place in the input buffer and its content moved. A handshake message or an alert is copied out of the received data into a region for the code that handles it, and cleared there.
  • What holds plaintext (§8): the received data, as before; the last record opened (b_plain) only on a TLS 1.2 connection, which still opens into it; the input buffer never, after a record is opened (an opening in place is cleared).
  • tls_record.open is now open_into (into a buffer of the caller’s) and open_in_place, and both answer size * 256 + type (opened_size, opened_type) rather than filling an info slice.

packages/x509: verifying a server’s chain

Status: built (#206, all three PRs): the verifier (§8), and packages/tls verifying with it (§9). §10, a chain verified without a host name (for verify-ca and client certificates), is designed there and built in the same PR. §11, a table for each P-256 trust anchor (B3 of #378), came with #380. Sub-issue 9 of the self-contained TLS 1.3 client (#197). docs/tls-pure.md §5 already fixes the rules: the root store, the depth limit, the checks per certificate, the key sizes, name matching, and what is not checked. Its §8 fixes the refusal tags. This document settles what those sections left open for the code:

  • where the code goes, and its API;
  • how a chain is built when there is more than one candidate issuer;
  • how name constraints are read, and what happens to a constraint the verifier cannot read;
  • what the trust anchor itself is checked for;
  • how each of the issue’s four gates is met, with what it costs.

1. Three PRs

  1. This design.
  2. The verifier, in packages/x509, with the four gates of §6: x509-limbo, a generated OpenSSL matrix, saved real chains against the system roots, and mutants.
  3. packages/tls uses it. tls.trust loads roots instead of pins, and Certificate is verified against them, the host and the clock (docs/tls-core.md §5 ends). The live tests and the recorded streams of docs/tls-core.md §10 move from a pinned certificate to a CA made for them.

2. Files and API

packages/x509/x509.cho is 1,598 lines, so verification is two new modules beside it, each under 2,000 lines (crates/cancho/tests/files.rs):

FileModuleWhat
packages/x509/names.chox509_namesa host as a DNS name or an IP address; matching a SAN; reading and applying name constraints
packages/x509/verify.chox509_verifythe root store; building a path; the checks of docs/tls-pure.md §5.2 on each certificate
// The store: the roots of a PEM bundle, each as a 3-byte length and its DER, the format of
// docs/tls-core.md §5's pins. A block that is not a certificate, or a certificate this parser
// refuses, is skipped and counted, never fatal (docs/x509.md §5.3).
x509_verify.store_load(pem, store, info) -> roots | refusal   // info[0] bytes used, info[1] skipped

// A chain as the server sent it: `certs` holds the DERs, `ranges` their [start, end) pairs,
// the leaf first. `host` is a DNS name or an IP literal. `now` is seconds since 1970.
x509_verify.verify(store, certs, ranges, host, now, max_intermediates) -> 0 | refusal

§10 adds verify_chain (the chain for a purpose, with no name), verify_name (the name, as its own step), and san_next; verify is those two steps, with the same answers.

  • ranges rather than a copy. packages/tls already has the Certificate message in a slot, and tls_message.certificate already gives each certificate’s range (docs/tls-core.md §9.1). The verifier reads them in place.
  • max_intermediates is 6 for TLS (docs/tls-pure.md §5.2: 8 certificates, leaf and root included). It is a parameter because x509-limbo sets it per case (§6.1). As built (§8.3): it counts intermediates that are not self-issued, as RFC 5280 §6.1.4 and limbo’s max-chain-depth-1-self-issued count them, and no path has more than 6 intermediates of any kind.
  • No capability. The clock and the store are numbers and bytes the caller passes, as for the rest of the engine (docs/tls-pure.md §2.2).
  • Working memory is the verifier’s own regions: up to 7 views of 40 words (docs/x509.md §2), a candidate list, and the RSA or ECDSA work area. Nothing grows with the input past the limits of §3.

3. Building a path

The server sends its leaf first and the rest “in any order” (RFC 8446 §4.4.2). A sent intermediate may be unused, and more than one certificate may carry the issuer’s name: cross-signs, a renewed intermediate, or an attacker’s decoy. x509-limbo tests each of these (pathological::*, rfc5280::*).

  • Depth-first, with backtracking. From the certificate being checked, the candidates for its issuer are:

    • first the roots whose subject equals its issuer;
    • then the sent certificates whose subject equals its issuer.

    A candidate is tried only if its signature over the certificate verifies and its checks of §4 pass. If the path from it fails, the next candidate is tried. The path’s result is the first that reaches a root. When every path fails, the refusal is the one from the deepest attempt, so a chain that is merely expired says x509-expired, not x509-unknown-issuer.

  • Names are compared byte for byte. That is webpki’s choice, and it is what RFC 5280 §7.1’s case-insensitive comparison reduces to for every name in a real chain. A pair of names that differ only in case is “no issuer”, and x509-unknown-issuer says so.

  • No certificate twice in one path. That refuses a loop instead of following it (docs/tls-pure.md §5.2), and it covers pathological::intermediate-cycle-*. Corrected (review finding D-2, #209): “twice” is by position in what the server sent, not by content (verify.cho, in_path). The same self-issued CA certificate sent at two positions can stand twice in one path. That is no loop: each hop still costs a signature of the 64-signature budget below and one of the depth slots, so the search stays bounded, and the second copy adds no authority the first did not have.

  • A budget. At most 64 signature verifications per verify. x509-limbo’s pathological-chain-* cases send 100 intermediates with the same subject. Without a budget, backtracking over them is exponential. With one, the worst case is 64 signatures: about 0.1 s at 1.5 ms each on LLVM for P-256 (docs/ecdsa.md §5.4), or 0.6 s at 9.9 ms for P-384 on Cranelift. Corrected (review finding C-1, #317): that assumed an RSA key’s exponent was 65537. The server chooses its keys, and nothing bounded the exponent below the modulus; measured on an Apple M4 (LLVM), one RSA-4096 exponentiation took 0.79 ms with 65537 and 176 ms with a 4,095-bit exponent, so 64 of them 11.3 s. std.rsa now refuses an exponent over 64 bits, whose worst case measured 3.15 ms: 0.2 s for the budget. Exhausting the budget is x509-path-too-long.

  • Signatures are checked before anything else about a candidate. A candidate whose key does not verify is not an issuer. Corrected (§8.3): this said a signature is the only way to tell two certificates with the same subject apart. As built, two cheaper facts come first, as OpenSSL’s X509_check_akid and RFC 5280 §4.1.2.6 have them:

    • a candidate whose subjectKeyIdentifier differs from the certificate’s authorityKeyIdentifier keyIdentifier is not its issuer, when both are present;
    • a CA with an empty subject issues nothing.

4. What each certificate is checked for

docs/tls-pure.md §5.2 lists the checks. These are the decisions it left open.

CertificateSignatureValiditycA and pathLenkeyUsageEKUName constraints
leafchecked by its issuer’s keycheckednot readdigitalSignature if present; keyCertSign only with cAserverAuth if present; anyExtendedKeyUsage alone is refused, as OpenSSL’s server purpose does (measured, §8.2)subject to every issuer’s; its own refused unless it is a CA
intermediatecheckedcheckedcA true; pathLen against the intermediates below it, self-issued ones not counted (RFC 5280 §6.1.4)keyCertSign if presentserverAuth if present (OpenSSL and webpki both refuse an intermediate whose EKU excludes it)its own apply below it
rootnot checked (§5.1 of tls-pure.md)checkedcA true, except an X.509 v1 root, which has no extensions; pathLen as an intermediate’skeyCertSign if presentnot readits own apply below it

Corrected (§8.3), two cells:

  • The leaf’s pathLen cell said “not read, so pathlen::validation-ignores-pathlen-in-leaf holds”. That case’s leaf is a CA whose keyUsage has no digitalSignature, so it is refused for that, as OpenSSL refuses it.

  • The root’s validity cell said “not checked”, following docs/tls-pure.md §5.1’s claim that this is OpenSSL’s default. Measured, OpenSSL refuses an expired root (rfc5280::validity::expired-root, error 10 at the root’s depth). The root’s dates are checked, and that claim is corrected where it was made.

  • Validity is notBefore <= now <= notAfter, both inclusive (RFC 5280 §4.1.2.5), in whole seconds.

  • Signature algorithms, by the outer AlgorithmIdentifier, which parse already checks equals the inner one:

    • sha256, sha384 and sha512WithRSAEncryption (PKCS#1 v1.5, std.rsa);
    • RSASSA-PSS with SHA-256, SHA-384 or SHA-512, MGF1 with the same hash, and the salt the hash’s length (the only parameters the Web PKI uses). Reading those parameters needs the hash and MGF1 OIDs, which docs/x509.md §2.3’s table lacks; PR 2 adds them through scripts/x509_oids.py;
    • ecdsa-with-SHA256 and ecdsa-with-SHA384, on P-256 and P-384 (std.ecdsa). ecdsa-with-SHA512 is accepted on either curve, truncated as FIPS 186-5 says;
    • Ed25519;
    • anything else, SHA-1 included, is x509-unsupported-algorithm. That is 14 certificates of ecdsa-with-SHA1, 12 of ML-DSA and 2 of DSA in x509-limbo (§6.1).
  • Keys. RSA from 2048 to 4096 bits, else x509-key-size. EC on P-256 or P-384, else x509-unsupported-algorithm (x509-limbo has two P-192 keys).

  • Leniencies (docs/x509.md §3.2) are accepted on every certificate, as OpenSSL accepts them.

  • An unknown critical extension is already refused by parse (docs/x509.md §2.2).

5. Names

5.1 The host

host is an IP literal when tls_message.is_ip_literal says so: only digits and dots, or any colon. An IPv4 literal must be four decimal parts of 0 to 255 with no leading zeros. An IPv6 literal is RFC 4291 §2.2’s text form, :: included and a trailing dotted IPv4 included. Anything else is x509-name-mismatch, since no certificate can match it. A DNS host is lowercased ASCII, with at most one trailing dot removed.

5.2 Matching the leaf

docs/tls-pure.md §5.3 holds without change. The SAN only; an IP host matches only an iPAddress entry, byte for byte, and a DNS host only a dNSName entry. A wildcard is the whole left-most label, needs two labels after it, and matches exactly one label. A dNSName entry that is not a valid name (empty labels, a * anywhere else) matches nothing; it does not refuse the certificate. Corrected (review finding D-5, #209): it does not when no CA above it has nameConstraints. Under a constrained CA every dNSName of the SAN is constrained (§5.3), and one that is not a valid name cannot be placed inside or outside a subtree, so it refuses the chain as x509-name-constraint (names.cho, constraints_ok): a leaf naming example.com and example.com. under a constrained intermediate is refused. That is the safe side; webpki skips such an entry instead.

5.3 Name constraints

The issue names x509-limbo’s BetterTLS cases as a gate. They are 9,572 of its 9,802 cases, and every one is about name constraints. Among the 30,379 certificates of limbo.json, the constraint subtrees are 24,681 dNSName, 11,736 iPAddress, 12 rfc822Name, 5 directoryName, 2 URI and 2 otherName (measured with pyca, §6.1).

  • dNSName subtrees. A name matches a subtree when it equals it, or ends with . followed by it, compared without case. A subtree with a leading dot (.example.com) matches only proper subdomains. An empty subtree matches every name.
  • iPAddress subtrees are an address and a mask of the same length, 8 or 32 bytes. An address of the other family never matches. A mask that is not contiguous ones then zeros is unreadable (below). Corrected (review finding D-3, #209): only when it is read, which is when a certificate below carries an iPAddress of the subtree’s family (names.cho, subtrees). The pass that reads the constraints once with no name checks a subtree’s length, not its mask, so a CA with such a mask is accepted above a chain with no address of that family. Whenever the constraint would apply, it is refused: no name escapes it.
  • What is constrained. Every dNSName and iPAddress in the SAN of every certificate below the constraining one. A wildcard SAN *.a.example is checked as a.example against permitted subtrees. Corrected (§8.3): this said “and as itself against excluded ones, as webpki does”. As built, against an excluded subtree it is refused when either holds the other: a.example inside the subtree, or the subtree (x.a.example) inside a.example, since the wildcard names it. Corrected again (review finding D-1, #318): that missed a subtree with a leading dot. .a.example holds only proper subdomains of a.example, so neither held the other, and *.a.example, which names only such subdomains, passed an exclusion of .a.example. A wildcard is now also refused by a dotted subtree whose base is its own, as OpenSSL refuses it; .x.a.example still excludes none of the names *.a.example can match. Both are cases of the OpenSSL matrix (§6.2). Against a permitted .a.example, *.a.example is still refused, which is the safe side of the same reading. The subject’s common name is never read (no CN fallback, docs/tls-pure.md §5.3), so it is never constrained.
  • Permitted, then excluded. A name of a type that has permitted subtrees must match one of them, and it must match none of the excluded subtrees.
  • A subtree of any other type, rfc822Name, directoryName, URI or otherName, is unreadable: the chain is refused with x509-name-constraint. This is RFC 5280 §4.2.1.10’s rule for a critical extension the verifier cannot apply, and docs/tls-pure.md §8 already maps OpenSSL’s 51 to 53 to that tag. It costs every x509-limbo case whose constraint has one of those types, and §6.1 counts them.
  • Malformed constraints are unreadable too: a nameConstraints with neither subtree list, an empty list (RFC 5280 says SIZE (1..MAX)), a minimum or maximum, or the lists out of order. (Added in PR 2, §8.3.)
  • Limits. At most 1,024 subtrees in one certificate, and at most 2^20 name-against-subtree comparisons in one chain. pathological::nc-dos-* exist to find a verifier without that second limit. Over either is x509-name-constraint.

6. The gates

6.1 x509-limbo

scripts/x509_limbo.py (docs/x509.md §5.3) gains a verify mode. For every case in limbo.json it passes the trusted certificates as the store, the intermediates and the leaf as the chain, expected_peer_name as the host, validation_time (or the time of the run when it is null) as now, and max_chain_depth when set. Each case’s result is one of:

  • pass: SUCCESS accepted, or FAILURE refused;
  • refused for the right reason: a FAILURE whose tag is the reason the case’s description gives. It is listed by tag;
  • not applicable: a case outside this client’s scope, each kind named with its count:
    • validation_kind CLIENT (10 cases: this is a server-certificate verifier);
    • revocation (has-crl, 17 cases: not checked, docs/tls-pure.md §5.4);
    • ML-DSA and DSA, which docs/tls-pure.md §5.2 leaves out;
    • a name-constraint type of §5.3’s unreadable list, when the case expects SUCCESS;
  • a disagreement: anything else, each one read and either fixed or explained in the results.

limbo is 41.8 MB and fetched, not committed (as in docs/x509.md §5.3). cargo test runs a committed subset: every case that is not BetterTLS (230), and one BetterTLS case in twenty, picked by a hash of its id. The subset is written by the script with the expected outcomes.

6.2 An OpenSSL matrix

scripts/x509_matrix.py makes a CA, an intermediate and leaves with the OpenSSL CLI, one case per row of the issue: valid, expired, not yet valid, wrong host, self-signed, untrusted root, an intermediate that is not a CA, pathlen exceeded, and the three wildcard abuses (*.com, a.*.b.com, and *.a.b.com against a.b.com). It also adds the cases §4 decided: keyUsage without keyCertSign, a leaf EKU of clientAuth only, an RSA-1024 leaf, a SHA-1 signature, P-192, and a constraint excluding the host. Each case runs both:

  • openssl verify -x509_strict -purpose sslserver -verify_hostname <host> -attime <t> -CAfile root -untrusted intermediates leaf (-purpose matters: without it openssl verify checks no EKU, while a TLS client’s OpenSSL does);
  • the verifier.

The case’s tag must be the one expected, and no case OpenSSL refuses may be accepted. The certificates are committed (tests/vectors/x509/matrix/), with -attime fixed, so the test needs no OpenSSL.

6.3 Saved real chains, the system roots

x509-limbo’s 14 online::* cases are real chains, saved from akamai.com, google.com, cloudflare.com and eleven more, each with the time it was saved. Among them:

  • RSA with SHA-256 and SHA-384;
  • ECDSA P-256 and P-384 leaves;
  • chains of two and three certificates.

Each root is in this machine’s 128-root bundle, byte for byte (measured). They are verified against the whole bundle, through store_load, at their saved time and at a time outside the leaf’s validity. A live connection is not part of the gate, as the issue says. It also could not be: this container’s outbound HTTPS goes through an intercepting proxy, which would show its own chain.

6.4 Mutants

At least 12, each killed by §6.1 to §6.3:

  • a skipped signature check;
  • a wildcard crossing a dot;
  • notAfter compared off by one;
  • a missed cA;
  • pathLen counting self-issued intermediates;
  • pathLen not checked;
  • a wildcard on a one-label suffix (*.com);
  • a partial wildcard (f*.example.com);
  • the excluded subtrees ignored;
  • a dNSName subtree matched as a plain suffix (example.com permitting badexample.com);
  • an IP mask ignored;
  • EKU not checked;
  • the leaf’s keyUsage not checked;
  • the budget off.

7. Questions this does not settle

  • Whether byte-for-byte name comparison loses a real chain. x509-limbo has cases for it, and §6.1 will list them. Answered (§8.2): no limbo case and none of the 14 real chains is lost to it.
  • Whether *.co.uk-style wildcards need a public-suffix list. That is docs/tls-pure.md §10’s question 5; limbo’s pedantic-public-suffix-wildcard cases (3) will be listed as disagreements if they fail, not hidden. Measured (§8.2): two of the three are accepted here and by OpenSSL, and listed. The question stays a person’s.

8. PR 2: the verifier (results)

8.1 What was built

FileLinesWhat
packages/x509/names.cho (x509_names)565the host as a DNS name or an IPv4 or IPv6 address; SAN matching; name constraints
packages/x509/verify.cho (x509_verify)778store_load, verify, the refusal tags; path building, the checks of §4, the signatures
packages/x509/x509.cho1,665four OIDs for RSASSA-PSS’s parameters (SHA-256, -384, -512, MGF1), from scripts/x509_oids.py and checked against openssl asn1parse; is_ip_literal, moved here from packages/tls/message.cho so the two share one copy (conformance/duplication.rs)
tests/programs/x509_verify_driver.cho203a store line and chain lines, from standard input
scripts/x509_limbo.py verify, x509_matrix.py, x509_online.py, x509_verify_mutants.py§6’s four gates

conformance/x509_verify.rs replays all three committed files (tests/vectors/x509/verify/), the matrix and the real chains on both backends, in 4 seconds. The verifier needs --std (std.rsa, std.ecdsa, std.ed25519, std.crypto, std.bytes).

8.2 Evidence

x509-limbo (limbo.json SHA-256 611e337b…, the file of docs/x509.md §5.3). All 9,802 cases in 26 s:

  • 9,743 pass. Every BetterTLS case is among them: 8,707 expected failures refused and 865 expected successes accepted. FAILURE cases are refused with these tags:

    TagCases
    x509-name-constraint5,364
    x509-name-mismatch2,814
    der-tag and x509-name (the SANs of docs/x509.md §5.3)552
    x509-unknown-issuer19
    x509-path-too-long13
    x509-unsupported-algorithm12
    x509-expired, x509-not-yet-valid14
    x509-not-ca10
    x509-key-usage8
    nine other tags14

    limbo gives no machine-readable reason for a FAILURE, so “refused for the right reason” is read from the tag against the case’s name. The BetterTLS name-constraint cases are refused as x509-name-constraint or, where the leaf’s own name is the one outside the constraint, as x509-name-mismatch.

  • 20 not applicable:

    • revocation (10);
    • a client certificate (5);
    • ML-DSA or DSA expected to succeed (3);
    • a case with no peer name (1);
    • a directoryName constraint expected to succeed (1).
  • 39 disagreements, each read and checked against openssl verify -purpose sslserver (OpenSSL 3.0.13), with and without -x509_strict. scripts/x509_limbo.py’s KNOWN lists each one with its reason, and the script fails on any other:

    DisagreementCasesOpenSSL
    a CA as the leaf, with no digitalSignature in its keyUsage, expected to succeed2refuses too (26)
    a root whose AKI does not name itself, so OpenSSL does not take it as self-issued and finds no anchor5refuses, only for that
    CA/Browser Forum or webpki rules beyond RFC 5280: CN contents, EKU presence, RSA sizes not a multiple of 8, a CA flag on a leaf16accepts
    public-suffix wildcards (docs/tls-pure.md §10 question 5)2accepts
    missing AKI or SKI, a non-critical basicConstraints on a root, an empty subject with a non-critical SAN6accepts; refuses with -x509_strict
    others RFC 5280 or CABF state and OpenSSL does not check: non-critical name constraints or policy constraints, a leading period in a dNSName constraint, an underscore, serial zero, a malformed AIA, a critical SAN with a subject, a root’s AKI without a keyIdentifier8accepts

    So every limbo case this verifier accepts and OpenSSL refuses is one of the 5 whose root is unusual, and in each of them the leaf is signed by a root in the store.

The OpenSSL matrix (scripts/x509_matrix.py, tests/vectors/x509/verify/matrix.txt). 33 cases, every one with its own tag, and no case OpenSSL refuses accepted:

  • the issue’s rows: valid, expired, not yet valid, wrong host, self-signed, untrusted root, an intermediate that is not a CA, pathlen exceeded, *.com, a.*.b.com, *.a.b.com against a.b.com;
  • §4’s additions: an intermediate without keyCertSign, a leaf EKU of clientAuth only or anyExtendedKeyUsage only (OpenSSL: 26), an RSA-1024 leaf, P-192, SHA-1, an unknown critical extension, an expired intermediate, constraints excluding and permitting the host;
  • the signatures: P-256, RSA-4096 with SHA-512, Ed25519, RSA PKCS#1 v1.5 and RSA-PSS issuers, one bit of a signature changed;
  • names: a wildcard, IPv4 and IPv6 SANs, a host only in the CN.

Four of them are refused here and accepted by OpenSSL’s defaults: the CN-only host (no CN fallback, docs/tls-pure.md §5.3), RSA-1024, P-192 and SHA-1 (§4).

Saved real chains against the system roots (scripts/x509_online.py). All 128 roots of the bundle load, none skipped. Each of the 14 chains is then checked six ways, 84 checks, all as wanted:

  • at its saved time;
  • at exactly the leaf’s notAfter, and at exactly its notBefore (both ok: both are inclusive);
  • a second after (x509-expired) and a second before (x509-not-yet-valid);
  • under <name>.invalid (x509-name-mismatch).

The bundle is committed as tests/vectors/x509/verify/roots.pem (Debian’s ca-certificates 20260601~24.04.1), so the replay needs no system file.

22 mutants, 22 killed (scripts/x509_verify_mutants.py, 3 minutes):

  • a skipped signature check, on an intermediate and on a root;
  • a wildcard crossing a dot; a wildcard on a one-label suffix;
  • notAfter and notBefore off by one;
  • a missed cA;
  • pathLen counting self-issued intermediates; pathLen not checked;
  • excluded and permitted subtrees ignored; a dNSName subtree as a plain suffix; an IP mask ignored;
  • the leaf’s EKU, and its keyUsage, not checked; keyCertSign not required of an issuer;
  • the budget off (limbo’s pathological chains then do not finish in 120 s);
  • a root’s and an intermediate’s dates not checked;
  • the host not matched;
  • RSA-1024 allowed;
  • the AKI not matched against the SKI.

§6.4’s partial-wildcard mutant (f*.example.com) cannot be written: a pattern with a * outside the left-most label never equals a host, which has no *, so removing the check changes nothing.

Cost. The 14 real chains (two signatures each, RSA and ECDSA) against the 128-root store take 3.4 to 3.7 ms a chain on either backend, measured through the driver, so this includes its byte-by-byte reading of hex. Loading the store takes 8 ms.

8.3 Found

limbo found these before any of them reached main. Each is corrected where its claim was made:

  • docs/tls-pure.md §5.1’s claim that OpenSSL accepts an expired root was false. It refuses one. The root’s dates are now checked. The claim is corrected there, and in its §10 question 4, and in §4 here.
  • Issuer selection needed the AKI and SKI, and a non-empty CA subject (§3), to agree with OpenSSL where a sent certificate carries the issuer’s name but not its key.
  • max_intermediates must not count self-issued intermediates (§2).
  • Five RFC 5280 MUSTs were added to the leaf and the constraints: keyCertSign only with cA, name constraints only in a CA, neither list empty, no constraint with neither list (§4, §5.3).
  • The wildcard rule against excluded subtrees was written backwards in §5.3, and is corrected there.
  • Two test expectations were wrong, not the verifier:
    • docs.python.org holds *.python.org, so not-docs.python.org does match it;
    • a leaf signed by an impostor is x509-unknown-issuer, not x509-bad-signature, once the AKI is matched.

9. PR 3: packages/tls verifies with it (results)

9.1 What changed

  • tls_client.

    • start takes now (seconds), kept in the slot.
    • Certificate is verified by x509_verify.verify, over the message’s own ranges, against the store, the host start was given and now. The leaf is still kept for CertificateVerify.
    • The pin check is gone.
  • tls.

    • trust is x509_verify.store_load: a root it cannot read is skipped and counted (tls.skipped), never fatal.
    • start passes now_unix_ms / 1000.
  • Tags and alerts. tls_record gains the verifier’s tags as codes -25 to -33. Each refusal sends the alert RFC 8446 §6.2 names:

    RefusalAlert
    x509-unknown-issuerunknown_ca (48)
    x509-expired, x509-not-yet-validcertificate_expired (45)
    x509-key-usage, x509-critical-extension, x509-unsupported-algorithm, x509-key-sizeunsupported_certificate (43)
    x509-bad-signature, x509-name-mismatch, x509-not-ca, x509-path-too-long, x509-name-constraint, x509-chain-too-large, x509-decodebad_certificate (42)
  • tests/programs/tls_driver.cho. C takes a PEM root bundle and a time instead of pins. tls_many checks against clock_unix_ms. Its first version passed clock_ms, a monotonic clock, and every certificate was “not yet valid”.

  • Every recording was made again with a CA:

    • the tlslite-ng traces: an RSA-2048 CA for the RSA leaf, a P-256 CA for the ECDSA leaf;
    • the lying server: an Ed25519 CA from a fixed seed, so its recording is still identical run to run;
    • the 64 streams.

    The replays are byte for byte as before. The test of a wrong root is now a root from another CA.

9.2 Evidence

  • conformance/tls.rs, all six tests:
    • both traces replayed byte for byte on both backends;
    • any split of the server’s bytes;
    • a certificate from another CA refused as x509-unknown-issuer, with unknown_ca;
    • the 15 ServerHellos;
    • the 29 lying-server connections;
    • 64 connections on one poller, one byte and 65,536 a read, and cut short.
  • Live, 64 connections at once (scripts/tls_live.py). Every server has a certificate from a CA made for the run, and the CA is all tls_many trusts:
    • Python ssl with P-256, P-384, RSA-2048, RSA-4096 and Ed25519 leaves, one byte and 65,536 a read: every connection ok;
    • openssl s_server and tlslite-ng: every connection ok;
    • no close_notify: every connection tls-peer-closed;
    • a host the certificate does not name: every connection x509-name-mismatch;
    • another CA’s root: every connection x509-unknown-issuer.
  • Mutants (scripts/tls_mutants.py): 24 of 24 killed. “The pin not checked” is replaced by three:
    • the chain not verified;
    • the time not given to the verifier;
    • an unknown issuer reported as x509-decode.

9.3 Found

  • Two CAs with one name are one issuer, as far as a name goes. The first re-recording gave both trace CAs the same subject and no key identifiers. The “another CA” test then failed, correctly, as x509-bad-signature instead of x509-unknown-issuer. Each test CA now has its own name. The verifier is unchanged: a same-named issuer whose key does not verify is a bad signature, as OpenSSL says (error 7).
  • clock_ms is not a time of day (§9.1).

10. A chain without a name

Two users need a chain checked with no host name:

  • sslmode=verify-ca in cancho-pg (its docs/tls.md, PR #14): the chain to a trusted CA, and no name. Its §3 found that verify parses the host and matches it inside one function, and that the path builder, extend, is private, so only verify-full could be offered.
  • Client certificates, step 4 of docs/tls-server.md §8: a server verifies a client’s chain against a configured trust store. There is no name to expect; the program is given the verified subject and SANs and decides what they may do.

10.1 The API

// Which extendedKeyUsage the leaf and every intermediate must allow, when they carry one.
x509_verify.purpose_server_auth() -> 1   // a server's certificate: the TLS client, verify-full, verify-ca
x509_verify.purpose_client_auth() -> 2   // a client's certificate: a TLS server's step 4

// The chain only: the path, every signature, every validity, cA and pathLen, keyUsage and EKU for `purpose`, name
// constraints over every SAN. No name is read. `leaf` (x509.view_len() words) gets x509.parse's view of the leaf when the
// answer is 0, and all zeros on a refusal.
x509_verify.verify_chain(store, certs, ranges, now, max_intermediates, purpose, leaf) -> 0 | refusal

// The name, as its own step: the leaf's subjectAltName against `host`. 0, or x509-name-mismatch.
x509_verify.verify_name(leaf_der, leaf, host) -> 0 | refusal

// Unchanged, and now those two steps: the host must be readable (else x509-name-mismatch, first, as before), then
// verify_chain(purpose_server_auth()), then verify_name.
x509_verify.verify(store, certs, ranges, host, now, max_intermediates) -> 0 | refusal

// The verified leaf's subjectAltName, one GeneralName at a time: `at` 0 starts; answers where the next one starts, or 0
// when there is none. entry[0] is the tag (0x81 rfc822Name, 0x82 dNSName, 0x86 URI, 0x87 iPAddress, 0xa0 otherName,
// 0xa4 directoryName, ...), entry[1], entry[2] its content's [start, end) in `leaf_der`.
x509_verify.san_next(leaf_der, leaf, at, entry) -> next | 0

The subject is leaf_der[leaf[x509.subject_start()]..leaf[x509.subject_end()]], the Name’s DER, as every other field of the view is read (docs/x509.md §2). A function that picks a common name out of it is left until a program asks (CONTRIBUTING.md: two askers); the broker of step 4 is the first candidate.

  • One path builder. verify_chain is the code verify ran up to the name: the same parse, leaf_ok, extend, budget and refusal order. The purpose is passed down to the two places that read an EKU. verify keeps its signature, its refusal tags and their order; the matrix, the real chains and the limbo subset replay unchanged (§10.5).
  • A purpose that is neither is refused, x509-purpose (-41), before anything is read. There is no default and no “any”.
  • leaf shorter than x509.view_len() is x509-structure, as x509.parse answers for a short view.

10.2 The purposes

The two purposes differ only where an EKU is read; every other check of §4 is the same.

purpose_server_auth()purpose_client_auth()
the leaf’s EKU, when presentmust hold serverAuthmust hold clientAuth
anyExtendedKeyUsage alone in the leafrefused (§4)refused, as OpenSSL’s sslclient purpose does (measured, §10.5)
an intermediate’s EKU, when presentmust hold serverAuthmust hold clientAuth
the root’s EKUnot read (§4)must hold clientAuth, when present. Corrected while building (§10.5): this cell said “not read”. Measured, OpenSSL’s sslclient refuses a root whose EKU excludes clientAuth (error 26 at the root’s depth), and so does its sslserver for serverAuth. A client-certificate store is a file an operator writes for the purpose, so the stricter reading costs nothing there; for a server’s purpose §4’s rule stays, since verify’s answers do not change in this PR, and the difference is listed (§10.5)
the leaf’s keyUsage, when presentdigitalSignaturedigitalSignature: a TLS 1.3 client signs CertificateVerify with it. OpenSSL’s sslclient also accepts keyAgreement alone, which no TLS 1.3 client can use, so that is refused here and accepted there
OpenSSL’s equivalentopenssl verify -purpose sslserveropenssl verify -purpose sslclient
  • The TLS client and verify-full use verify, so serverAuth, as today.
  • verify-ca is verify_chain with purpose_server_auth(). That is what libpq does: with verify-ca or verify-full it sets SSL_VERIFY_PEER with the root file and no purpose or host of its own (fe-secure-openssl.c, PostgreSQL 16), so OpenSSL’s TLS client applies its default for a server’s chain, the ssl_server purpose (ssl_verify_cert_chain in ssl/ssl_cert.c, OpenSSL 3.0.13: X509_STORE_CTX_set_default(ctx, s->server ? "ssl_client" : "ssl_server")). The name is libpq’s own check, made only under verify-full (pq_verify_peer_name_matches_certificate returns at once for any other mode, fe-secure-common.c). So verify-ca there requires serverAuth when an EKU is present, and every chain check; it skips the name and nothing else. It is the same here.
  • Step 4 uses purpose_client_auth(). The same ssl_verify_cert_chain line is why: an OpenSSL server (PostgreSQL’s, mosquitto’s) checks a client’s chain with the ssl_client purpose.

10.3 The hazard, and how the API says it

A chain verified with no name authenticates “a key some CA in this store vouched for, for this purpose, today”. It does not say which server or which client. Two consequences:

  • The store decides everything. Under verify-ca against the system bundle, anyone who can get a certificate for any domain from any public CA passes, so verify-ca is only safe with a store holding a CA that issues only to the servers the client means to reach: a private CA per database, which is libpq’s own advice for verify-ca. For client certificates the same holds more sharply: public CAs have issued certificates with both serverAuth and clientAuth, so a server that trusted the system bundle for client certificates would admit the holder of any such web certificate. A client-certificate store is its own file, never the system roots. That is for step 4 and for cancho-pg to enforce where they load the store; packages/x509 cannot tell a public CA from a private one.
  • Authentication is not authorization. After verify_chain answers 0 the program has an identity, the subject and the SANs, that the CA put there. Deciding what that identity may do (which MQTT topics, which database role) is the program’s, and it must read the identity from the leaf view verify_chain filled, which is the certificate that was verified.

How the API makes the choice explicit rather than easy to fall into:

  • verify cannot be told to skip the name. An empty host, -, * or anything else that is not a DNS name or an IP literal is still x509-name-mismatch (§5.1), before the chain is read. There is no flag. Tested (§10.4).
  • A chain without a name is a different function, with a name that says what it checks, and it takes a purpose with no default. A caller that wants the name must call verify (or verify_name after verify_chain): that is the only way the TLS client verifies, and packages/tls is unchanged by this section. Offering verify-ca through packages/tls would be its own named entry there, asked for by cancho-pg, not a flag on start.
  • A refusal leaves no identity behind. On a refusal leaf is all zeros: no subject range and no SAN, so a program that forgets to look at the answer reads nothing, rather than a name from a certificate that failed.
  • The CA’s limits still apply. Name constraints are checked over every SAN of the leaf in verify_chain, whether or not a name is asked, so a CA constrained to example.com cannot vouch for a client whose SAN says admin.other.test. A subject (a directoryName) is constrained only by a directoryName subtree, which this verifier refuses to read (§5.3), so a CA with one is refused; a CA with only dNSName subtrees says nothing about the subject. A program that authorizes by subject must trust each CA of its store for every subject, which is RFC 5280’s reading.

10.4 Tests

  • Unchanged and passing: conformance/x509_verify.rs (limbo subset, the 14 real chains, the 33-case matrix), the TLS client’s suites (conformance/tls.rs: the traces, the liar, the 64 streams; tickets; the differentials), the fuzz corpora, and publish_packages.py --check with the stores republished.
  • A second OpenSSL matrix, with no name (scripts/x509_matrix.py chain, tests/vectors/x509/verify/chain_matrix.txt). Each case runs openssl verify -x509_strict -purpose sslclient or sslserver, with no -verify_hostname, and the driver’s new C line (verify_chain). The tag must be the one expected, and no case OpenSSL refuses may be accepted:
    • for both purposes: a valid chain; an expired leaf; an expired intermediate; an unknown CA; pathlen 0 exceeded; an intermediate that is not a CA; a leaf with no SAN (accepted: there is no name to match); a bad signature;
    • a client-auth-only leaf: accepted for clientAuth, x509-key-usage for serverAuth;
    • a server-auth-only leaf: the reverse;
    • a leaf with both, and a leaf with no EKU: accepted for both;
    • anyExtendedKeyUsage alone: refused for both;
    • an intermediate whose EKU is serverAuth only, under a clientAuth leaf: refused for clientAuth; and the reverse;
    • a root whose EKU is serverAuth only, and one whose EKU is clientAuth only, for both purposes;
    • a leaf whose keyUsage is keyAgreement only, for clientAuth (refused here, accepted by OpenSSL: §10.2);
    • a constrained CA and a client leaf whose SAN is outside it: x509-name-constraint with no name asked;
    • the leaf’s view: each accepted case’s answer carries the subject and the SAN entries read back through san_next, compared with what the script put in the certificate.
  • verify cannot skip the name: the same valid chain through V with the host empty, -’s byte and *: each x509-name-mismatch.
  • verify_name on its own: the right host, a wrong one, an IP, a leaf with no SAN.
  • A purpose of 0 or 3: x509-purpose.
  • Mutants over the new code (scripts/x509_verify_mutants.py, with chain_matrix.txt added to what it replays): the purpose ignored at the leaf; ignored at an intermediate; the client purpose made the server’s; an invalid purpose accepted; a root’s EKU not read for client certificates; the leaf view not cleared on a refusal (the driver prints left-behind when a refused chain’s view still holds a subject or a SAN); san_next skipping the first entry; verify_name matching nothing; verify built on purpose_client_auth(); verify no longer refusing an unreadable host before the chain.
  • No trap: tests/programs/fuzz_chain.cho also runs verify_chain for both purposes and walks san_next over every corpus input, on both backends.

10.5 Results

What was built.

FileWhat
packages/x509/verify.cho (914 lines)purpose_server_auth, purpose_client_auth, verify_chain, verify_name, san_next, the tag x509-purpose (-41); verify is now the host check, chain (the shared path) for serverAuth, and verify_name. leaf_ok, issuer_ok and extend take the purpose’s EKU flag where they read an EKU
tests/programs/x509_verify_driver.choC lines (verify_chain, then the subject and every SAN through san_next) and N lines (verify_name alone)
scripts/x509_matrix.py chainthe no-name matrix of §10.4, against openssl verify -x509_strict -purpose sslclient / sslserver
tests/vectors/x509/verify/chain_matrix.txtits 56 cases, replayed by conformance/x509_verify.rs on both backends
tests/programs/fuzz_chain.choalso verify_chain for both purposes, san_next and verify_name over every input

Evidence.

  • verify unchanged. The 33-case matrix, the 14 real chains (84 checks) and the limbo subset (705 answers) replay byte for byte through the new code, on both backends; so do conformance/tls.rs and the TLS fuzz corpora. publish_packages.py --check passes with the stores republished.

  • The no-name matrix: 56 cases, each with its own tag; OpenSSL 3.6.4 (Homebrew) beside each of the 39 chain cases:

    • accepted for both purposes: a leaf with both EKUs, with none, with no SAN (device-17, the subject only), a client leaf under pathlen:0, a constrained CA with the SAN inside it, an RSA-2048 leaf; each answer carries the subject’s DER and the SAN entries, equal to what the script put in the certificate (DNS, IP and email entries read back in order);
    • x509-key-usage: a client-auth-only leaf for serverAuth and a server-auth-only leaf for clientAuth (OpenSSL: 26), anyExtendedKeyUsage alone (26), an intermediate whose EKU excludes the purpose (26), a root whose EKU excludes clientAuth (26), an intermediate without keyCertSign (79), and a leaf with keyUsage keyAgreement only for clientAuth (OpenSSL accepts it: refused here, §10.2);
    • x509-expired (10) for a leaf and an intermediate, x509-not-yet-valid (9), x509-unknown-issuer for another CA’s chain (19) and a self-signed leaf (18), x509-path-too-long (25), x509-not-ca (79), x509-name-constraint for a SAN outside a constrained CA with no name asked (47), x509-bad-signature (7);
    • verify with an empty host, -, * or a space: x509-name-mismatch, and before an expired chain is read;
    • verify_name alone: the leaf’s name in any case with a trailing dot, an IP SAN, a wrong name, an IP the leaf does not have, an empty host, a leaf with no SAN;
    • purposes 0, 3, -1 and 4: x509-purpose.

    No chain OpenSSL refuses is accepted, but one, listed in the file as a known disagreement: a root whose EKU is clientAuth only, under serverAuth, is accepted by verify and verify_chain and refused by OpenSSL (26). That is §4’s existing rule for a server’s chain (“not read”, as webpki has it), which this PR does not change because verify must answer as before. Whether a server’s purpose should read the root’s EKU too is a question for a person. Measured: 4 of the 128 system roots (roots.pem) carry an EKU, and each is serverAuth alone, so reading it would refuse no chain to a system root; it would make verify-ca and verify-full agree with libpq’s OpenSSL on a private root restricted to another purpose. (The same 4 roots would refuse every client certificate under clientAuth: one more reason a client-certificate store is never the system bundle, §10.3.)

  • Mutants: 33 of 33 killed (the 22 of §8.2, with three texts moved to the new code, and 11 new).

  • No trap: 20,000 chains from the no-name matrix with random byte changes, through C, N and V lines, ended with no trap (a scratch run, not committed); and the committed chain corpus, now through verify_chain, on both backends.

Not done here. packages/tls does not offer verify-ca, and the server’s step 4 is not built: each is its own change, in packages/tls, asked for by its program. x509-limbo’s 10 CLIENT cases (§8.2’s “a client certificate”, 5 of them not revocation cases) could now run through verify_chain; that needs limbo.json, which is fetched rather than committed, and was not run for this PR.

11. Trust anchors’ tables (B3 of #378, #380)

Designed in docs/p256-fast.md §11, which measured the table’s shape before it was chosen; this section is how the store carries it. The last signature of a chain is verified under the root’s key, the same key for every chain that ends there, so a P-256 root’s key can have a table built once (p256_vf.build_table, 10,305 bytes) and that verification costs about half: 688.1k to 326.4k cycles on gram, and a full client handshake under a P-256 CA 18% fewer instructions (1.62 to 1.41 times OpenSSL’s tconn in cycles; docs/p256-fast.md §11.4, §11.5).

11.1 In the store

The tables are entries of the store, after its roots, so that the store stays one byte slice and every function that takes one (verify, verify_chain, and the packages/tls functions that hand it on) keeps its signature:

byteswhat
3the entry’s length, 1 + 10,305
2 + 2the subject’s offset and length, both zero
1the kind: 1, a P-256 key’s table
10,305the table (docs/p256-fast.md §11.2), whose first 65 bytes are the key it is of

A root’s entry has a subject of at least two bytes (a Name is a SEQUENCE: its tag and length), so no issuer equals an empty subject and the walk over the roots (extend) passes over the tables without reading them as certificates. store_tables(store, used, most, work) appends one for each of the first most roots of store[0..used] whose key is P-256 and answers the store’s new length; anchor_tables(store) counts them. When a root is the issuer being tried, anchor_table finds the table whose key is the root’s, byte for byte, and signature_ok gives it to ecdsa.verify_der in the key’s place (a table begins with its key, docs/p256-fast.md §11.2); a root with no table, any other kind of key, and every intermediate, verify as before.

11.2 In packages/tls

  • Built by tls.trust, after store_load, for at most 16 P-256 roots: most_anchor_tables(). The system bundle (roots.pem, 128 roots) has 3 (61 RSA-4096, 28 RSA-2048, 35 P-384, 1 P-521): 30,939 bytes after its 138,350, built in about half a millisecond on the M4. Sixteen take 165,008 bytes, inside the store’s 1 MiB room (roots_cap), which is zero-filled address space resident only where written.
  • Engine-wide, not per slot: the store is the engine’s roots box, read by every connection.
  • Replaced with the store: trust now zeroes the old store and its tables before it loads the new one, and a refused bundle leaves zeros, so no table of a root no longer trusted remains. tls.residue counts every byte of roots past the store and its tables (it counted nothing of the client’s store before). tls.anchor_tables(engine) says how many roots have one.
  • Not lazily, for the anchors used: the store reaches a connection read-only (tls_client.feed_with), and building a table on first use would make it mutable there and in every caller; at trust it is about 0.5 ms and 31 KB for the system bundle.
  • Not for the server’s store of client CAs (trust_clients, docs/tls-server.md §13.3): not in #380’s list; store_tables would serve it as it is (but tls_identity.client_authorities, which reads every entry’s subject, would have to skip the tables).

11.3 Tests

Every x509 file is replayed a second time with the anchors’ tables (T for S in tests/programs/x509_verify_driver.cho) and must give every recorded answer: the limbo subset (223 stores, 195 of which give a root a table), the 14 saved real chains against the system bundle (its 3 tables, 30,939 bytes, counted), the OpenSSL matrix and the no-name matrix, on both backends where the original runs on both. conformance/key_tables.rs checks trust’s tables and their cleanup through tls.anchor_tables and tls.residue.

What this does not buy the public Web PKI: of the 31 signatures in §8.2’s 14 real chains, 22 are RSA, 7 P-384 and 2 P-256, and none ends at a P-256 root. B3 pays for a private P-256 CA (the measured client’s, a service mesh’s). For public sites the larger cost is P-384 verification, docs/p256-fast.md §12.

TLS parity with the OpenSSL backend: AES-GCM, P-256/P-384 key exchange, TLS 1.2

§6 (client certificates, ALPN, and the revocation and ML-KEM decisions, #386): design, then built in the same PR; see §6.

Status: built (#207, all five PRs): AES-GCM (§3.1.1), P-256 and P-384 key exchange (docs/ecdh.md), TLS 1.3’s AES-GCM suites and HelloRetryRequest (§3.3.1), TLS 1.2 (§3.4.1). Sub-issue 10 of the self-contained TLS 1.3 client (#197). The issue asked for a measured number of receivers needing TLS 1.2 or AES-GCM before anything is built. That number cannot be measured here, and the requirement replaces it: the maintainer’s requirement is that the pure client be equivalent to the OpenSSL backend it is to replace in cancho-hooks (#210). The maintainer chose the scope below, “AEAD parity”, over full parity with OpenSSL’s defaults and over the issue as written. This document settles what that means and how it is built and tested. It supersedes docs/tls-pure.md §3.2 to §3.4, which are marked so.


1. The decision, and the number the issue asked for

The number. The issue’s gate is “receivers needing 1.2 or AES-GCM, out of how many”. For cancho-hooks that means its operators’ receivers. Neither they nor the first backend’s negotiation logs are reachable from this repository. Nor can public servers be surveyed from here: this container’s outbound HTTPS goes through an intercepting proxy (docs/x509-verify.md §6.3), which would show its own TLS, not the server’s. So no number is given, and none is claimed.

The requirement that replaces it. The pure client must reach what the OpenSSL backend reaches, short of its weakest options. The maintainer chose among three scopes:

ScopeAddsChosen
the issue as writtenTLS 1.2 with ECDHE and ChaCha20-Poly1305 only
AEAD parityTLS 1.2 ECDHE with AES-GCM and ChaCha20-Poly1305; TLS 1.3’s AES-GCM suites; P-256 and P-384 key exchangeyes
full default parityalso CBC, static-RSA key exchange and finite-field DHE

2. What the OpenSSL backend offers (measured)

docs/tls-nonblocking.md §3.4 configures OpenSSL with a minimum of TLS 1.2 and OpenSSL’s default cipher list. The ClientHello of openssl s_client (OpenSSL 3.0.13), captured here, offers:

  • 31 cipher suites:
    • the 3 TLS 1.3 suites (1302, 1303, 1301);
    • 27 TLS 1.2 suites: ECDHE and DHE with AES-GCM and ChaCha20, CBC with SHA-1 and SHA-2, and static RSA;
    • the renegotiation SCSV (00ff).
  • groups: X25519, P-256, X448, P-521, P-384 and FFDHE 2048 to 8192;
  • one key share: X25519;
  • 20 signature schemes, including SHA-1 and SHA-224 ones for TLS 1.2;
  • extended master secret offered (RFC 7627), and a session ticket extension.

What AEAD parity offers, and what it does not:

offerednot offered, and refused with a tag if a server needs it
TLS 1.3 suitesall three: TLS_AES_256_GCM_SHA384, TLS_CHACHA20_POLY1305_SHA256, TLS_AES_128_GCM_SHA256—
TLS 1.2 suitesECDHE-{ECDSA,RSA}-AES256-GCM-SHA384, ECDHE-{ECDSA,RSA}-CHACHA20-POLY1305, ECDHE-{ECDSA,RSA}-AES128-GCM-SHA256the other 21: DHE, CBC, static RSA (tls-no-shared-cipher)
groupsX25519, P-256, P-384, one X25519 share, and HelloRetryRequest to P-256 or P-384X448, P-521, FFDHE (tls-key-share)
TLS 1.2 signaturesrsa_pkcs1_sha256/384/512, rsa_pss_rsae_sha256/384/512, ecdsa_secp256r1_sha256, ecdsa_secp384r1_sha384, ed25519SHA-1 and SHA-224 schemes (tls-bad-certificate-verify if a server uses one)
renegotiationthe SCSV, so a server knows; a server’s HelloRequest is refusedrenegotiation
extended master secretrequired (the issue’s rule)a TLS 1.2 server without it (tls-protocol-version; corrected in PR 5: tls-extended-master-secret, a tag of its own, as the next paragraph says)
resumptionnone, as nowsession tickets are not sent or used

One deliberate difference from OpenSSL beyond the suites. OpenSSL offers the extended master secret but does not require it. The issue requires it, because without it a TLS 1.2 session can be synchronised across two connections (the triple handshake attack). It stays required. The interop gate (§5) shows which servers that refuses, and a server refused for it gets a tag of its own.

3. What has to be built

3.1 AES-GCM, constant-time (PR 2)

docs/tls-pure.md §3.2 kept AES out because table-based AES leaks the key through cache timing. AEAD parity needs it, so it is built without tables:

  • AES-128 and AES-256 (FIPS 197), bitsliced: the S-box as a fixed boolean circuit over machine words (Boyar–Peralta’s 113 gates), with no table lookup and no branch or index that depends on the key or data. The key schedule is bitsliced too.
  • GHASH with a constant-time carry-less multiply, with no table indexed by the key-derived H. Corrected in PR 2: this said 64-bit operands, as BearSSL’s ghash_ctmul64 does. That needs the high half of a 64×64-bit product, and cancho’s int is a checked 64-bit signed integer, so a product over 2^63 traps. It is built as BearSSL’s ghash_ctmul32 instead: 32-bit operands split into masked quarters, every product of two quarters under 2^63 (§3.1.1).
  • GCM (NIST SP 800-38D) with a 96-bit nonce only, which is all TLS uses.
  • In std, beside std.chacha20: std.aes and std.gcm, each with a refusal tag for every rejected input.

Gates:

  • NIST CAVP gcmEncryptExtIV and gcmDecrypt for 128- and 256-bit keys, filtered to 96-bit IVs;
  • Wycheproof aes_gcm_test.json;
  • 10,000 random cases against pyca/cryptography;
  • a dudect-style timing test, a Welch t-test over fixed versus random keys and inputs, on both backends, reported with its t;
  • at least 12 mutants killed;
  • the cost per byte, against ChaCha20-Poly1305’s 135 MB/s (docs/chacha20.md §6).

A bitsliced AES may be several times slower than ChaCha20 here. The cost is measured and written, not assumed.

3.1.1 Results (PR 2)

Built. std/aes.cho is a port of BearSSL’s aes_ct and std/gcm.cho of its ghash_ctmul32 (Thomas Pornin, MIT licence, quoted in the files):

  • Two blocks per pass. They are encrypted as eight 32-bit words with the blocks’ bits regrouped (ortho), and the S-box is the 113-gate circuit run on all eight words.
  • The key schedule runs that S-box on one word at a time. It is built in place in the caller’s skey.
  • Counter mode encrypts two counter blocks per pass.
  • GHASH uses Karatsuba. Each 128×128-bit product is nine 32×32-bit carry-less products, done on the words and on their bit reversals.

What each module answers, and refuses:

  • std.gcm.seal and std.gcm.open take and answer what std.chacha20’s do: the tag follows the ciphertext, and open writes nothing on a mismatch.
  • A 192-bit key, or a nonce that is not 96 bits, is refused (gcm-key-length, gcm-nonce-length). TLS uses neither.
  • std.aes exposes the block and CTR functions with _with variants that take the caller’s eight-word scratch.

Every call allocates once. The first version gave each helper its own region. Nested three deep, they made glibc’s free trim the heap and the next malloc regrow it: 8 brk system calls per seal (strace -c), and 23 µs for a 64-byte message. After the change, one region holds all of a call’s scratch:

  • no brk per call;
  • 5.0 µs for the same 64-byte message.

Correctness, every case through tests/programs/gcm_driver.cho (crates/cancho/tests/conformance/gcm.rs):

EvidenceCasesResult
FIPS 197 C.1 and C.3, one block, both backends2equal
NIST CAVP gcmEncryptExtIV and gcmDecrypt, 128- and 256-bit keys, 96-bit IVs and 128-bit tags (tests/vectors/cavp/README.md), both backends1,500, 387 of them FAILall equal; every FAIL refused without writing
Wycheproof aes_gcm_test.json31679 valid cases sealed and opened; 54 bad tags refused; 103 with a 192-bit key and 80 with another nonce size refused with their tags
each bit of a 67-byte sealed message and its 20 bytes of associated data flipped696all refused, none writes
every reachable refusal11each with its own tag
scripts/gcm_differential.py, random cases against pyca/cryptography (OpenSSL 4.0.1)10,000 cases, 30,000 checks0 differences

Mutants: scripts/gcm_mutants.py runs 24 mutants across both files, and 24 are killed. Each is one bug planted in the S-box, ShiftRows, MixColumns, the key schedule, ortho, the counter, the carry-less multiply, Karatsuba, the reduction, the lengths block, the order of the hashed data, J0, the zero padding, the tag compare, or the nonce check.

Timing. There are two checks.

Branches. scripts/chacha20_branches.py lists every conditional jump in the 26 functions that touch the key, the keystream or GHASH’s state, on both backends’ objects. Each jump either goes to a trap (a bounds or overflow check) or compares:

  • a constant;
  • a loop counter;
  • the counter block;
  • a length, as in GHASH’s zero padding, where the register compared is len(data).

No index depends on the key or the data either.

Statistics. scripts/gcm_timing.py runs a dudect-style Welch t-test (Reparaz et al., 2017) over tests/programs/gcm_timing.cho:

  • each call is timed with rdtscp, through a three-line C library linked with -l tick;
  • the inputs are 64-byte messages with 13 bytes of associated data;
  • |t| below 4.5 is no evidence of a leak.
TestLLVM, 10^6 samplesCranelift, 10^6 samples
seal: fixed key against random key2.241.15
seal: fixed message and associated data against random2.063.13
open: fixed sealed message against random (both refused)2.982.31
open: tag wrong in its first byte against its last2.312.38

Corrected (#208, docs/tls-assurance.md §6.1): on the Xeon. On an Apple M4 Max at 20,000 samples, Cranelift’s two data tests fail (|t| up to 34.39), with equal instruction counts for both classes, and its open-data test still fails with Arm’s data-independent-timing bit set (5.62). LLVM passes there too.

The harness’s first version found a false leak. It decoded each case’s hex just before timing it. On the Cranelift backend, that gave t = 31 and 43 for the data tests, and t = 174 for a loop of plain XORs timed the same way. The cause was the decoding, not the code under test: its branch on each hex digit is always predicted for an all-zero input and mispredicts for a random one, and the state that leaves behind reaches the next timed call. The harness now decodes every case before it times any.

Cost. tests/programs/gcm_bench.cho seals one message repeatedly, as aead_bench.cho does for ChaCha20 (median of 5, one core of the same Xeon at 2.80 GHz). ChaCha20-Poly1305 was re-measured in the same session at 129 MB/s for 16 KiB, against docs/chacha20.md §6’s 135 MB/s.

MessageAES-128-GCM, LLVMAES-256-GCM, LLVMAES-128-GCM, CraneliftChaCha20-Poly1305, LLVMopenssl speed -evp aes-128-gcm
16,384 bytes32.6 MB/s (502 µs)27.3 MB/s (600 µs)10.4 MB/s (1,569 µs)129 MB/s (127 µs)5,546 MB/s
1,024 bytes30.9 MB/s (33 µs)23.5 MB/s (44 µs)9.2 MB/s (111 µs)124 MB/s (8.3 µs)3,984 MB/s
64 bytes12.8 MB/s (5.0 µs)9.4 MB/s (6.8 µs)4.1 MB/s (16 µs)61 MB/s (1.0 µs)1,356 MB/s

What the costs mean:

  • AES-GCM costs about 4 times ChaCha20-Poly1305 here. A full 16 KiB TLS record takes half a millisecond to seal. A server that picks an AES-GCM suite costs that much per record.
  • OpenSSL is about 170 times faster. It uses the AES-NI and PCLMULQDQ instructions, which cancho cannot emit. Corrected (docs/crypto-builtins.md, step 4): cancho now emits them, on LLVM, where the CPU has them. AES-128-GCM seal, measured with cancho’s hardware path against OpenSSL on the same machine: on aarch64 Linux (the Apple M4’s VM, OpenSSL 3.0.13) 557 against 4,007 MB/s at 64 bytes and 1,110 against 8,035 MB/s at 16 KiB, 7.2 times at both; on the M4 under macOS (OpenSSL 3.6.4) 99 against 611 MB/s and 1,050 against 10,497 MB/s, 6.2 and 10 times. Not measured on this section’s Xeon. The software path, still what Cranelift and a CPU without the instructions run, is the table above.
  • Most of the cost is instructions. valgrind --tool=callgrind puts GHASH at 28% of a 16 KiB seal and the AES rounds at most of the rest. GHASH keeps its 74 working words in a bounds-checked array. Holding them in locals is the first place to look if the cost matters.
  • The key is expanded, and H computed, on every call. In a 64-byte seal they are 25% and 14% of the instructions (callgrind, inclusive). Both depend only on the key, so PR 4 can keep them per connection if the record layer needs that.

3.2 P-256 and P-384 key exchange, constant-time (PR 3)

Built: docs/ecdh.md has the results. Building it found the LLVM backend turning constant-time masks into branches, and added value_barrier to the language (docs/value-barrier.md).

std.ecdsa verifies with public data, and is variable-time. Key exchange multiplies by a secret scalar. std.ecdh will:

  • use std.bigmod’s registers (docs/ecdsa.md §2), with field operations that do not branch on their values;
  • multiply with a fixed-window ladder over complete addition formulas (Renes–Costello–Batina), so the same operations run whatever the scalar;
  • refuse a point not on the curve, or the point at infinity, before using it.

std.bigmod’s reduction is checked for data-dependent branches, and any it has is replaced. docs/rsa.md and docs/ecdsa.md document bigmod as variable-time, because it was only ever given public data.

Gates:

  • Wycheproof ecdh_secp256r1_test.json and ecdh_secp384r1_test.json, including the invalid-point cases;
  • NIST CAVP KAS_ECC_CDH_PrimitiveTest;
  • the same timing test;
  • at least 12 mutants killed.

3.3 TLS 1.3: AES-GCM and HelloRetryRequest (PR 4)

  • The record layer takes its AEAD from the suite. A suite’s hash, SHA-256 or SHA-384, runs the key schedule: std.hkdf already takes 48 (docs/hkdf.md).
  • The transcript keeps both a SHA-256 and a SHA-384 state until ServerHello names the suite, as RFC 8446 §4.4.1 permits.
  • HelloRetryRequest is accepted once, for P-256 or P-384:
    • the transcript is replaced by message_hash (RFC 8446 §4.4.1);
    • the cookie is echoed, and the second ClientHello carries the new share;
    • a second HRR, an HRR for a group not offered, or for the group already sent, is refused.
  • ServerHello’s key share may now be P-256 or P-384, as the HRR asked.

Gates:

  • the existing tlslite-ng traces and lying server replay unchanged; (corrected in PR 4: they cannot. The ClientHello now offers three suites and three groups, so every recorded byte the client sends changed. They were re-recorded; §3.3.1)
  • new traces recorded for each suite, and for an HRR to each group;
  • the lying server gains HRR cases: twice, an unoffered group, the group already sent, a changed suite;
  • live against openssl s_server -ciphersuites each suite and -groups P-256 (forcing the HRR), Python ssl and tlslite-ng;
  • RFC 8448’s key schedule rows extended to the AES-128-GCM record keys of its trace.

3.3.1 Results (PR 4)

Built:

  • packages/tls/record.cho knows the three suites, their hash and key lengths, and seals and opens under the suite’s AEAD: std.chacha20 or std.gcm.
  • message.cho offers the suites in OpenSSL’s order (1302, 1303, 1301) and the groups X25519, P-256 and P-384, with one X25519 share. Its ServerHello parser reads a HelloRetryRequest too: the group it names, and its cookie.
  • client.cho keeps both transcripts until a suite is named. It sizes every secret, the Finished MAC and the CertificateVerify content by the suite’s hash.
  • On a HelloRetryRequest, client.cho:
    • restarts the transcript from message_hash;
    • sends a second ClientHello with a P-256 or P-384 share, echoing the cookie;
    • checks the second ServerHello against the retry: the same suite, a share of the group asked for, no second retry.
  • A server’s change_cipher_spec is accepted after a HelloRetryRequest, as OpenSSL sends one there. There is still only one per connection.

Decisions made here:

  • The P-256 or P-384 scalar is drawn from the X25519 secret, as HKDF-Expand-Label(secret, “ecdh scalar”, [attempt]). It is redrawn while it is not below n, which fails once in 2^32 for P-256, up to sixteen times. Once a retry has come, that X25519 secret is never used for anything else. So start still takes 96 bytes of entropy, and every caller and recording is unchanged.
  • A cookie is limited to 2,048 bytes (RFC 8446 allows 65,535). A longer one is tls-hello-retry, so the ClientHello’s buffer is 2,688 bytes and no slot grows by 64 KB for a cookie no server sends.
  • The work for std.ecdh (77 KB) is part of the slot’s ints, because a region holds at most 64 KiB (docs/ecdh.md §1).
  • tls-hello-retry now sends illegal_parameter (47), not handshake_failure (40). It now means a retry the client cannot follow, which RFC 8446 §4.1.4 answers with that alert.

Evidence:

  • Five tlslite-ng traces, replayed byte for byte on both backends (conformance/tls.rs). Every one was re-recorded, because the ClientHello changed:

    • RSA and P-256 certificates under ChaCha20 with X25519, as before;
    • AES-256-GCM-SHA384 with X25519;
    • AES-128-GCM with a HelloRetryRequest to P-256;
    • AES-256-GCM with a HelloRetryRequest to P-384.

    The one-byte and coalesced splits run over all five.

  • The lying server (scripts/tls_liar.py) gained P-256 and P-384, AES-GCM and SHA-384, and a retry step. It has 41 cases, up from 29: 14 new ones, replacing the two that refused AES-128-GCM and any HelloRetryRequest. Every case ends with its tag and its alert:

    Server doesTagAlert
    AES-256-GCM-SHA384; a retry to P-256 with a cookie and a change_cipher_spec, under AES-128-GCM; a retry to P-384 under AES-256-GCM, each through the honest case’s whole connection (a 2^14 + 256 record, two KeyUpdates)oknone
    TLS_AES_128_CCM_SHA256; a retry with ittls-no-shared-cipher40
    a P-256 share never sent; a retry for X25519, whose share was sent; a retry for X448; after a retry to P-256, an X25519 share, or a point not on the curvetls-key-share47
    a retry that changes nothing; a cookie over 2,048 bytes; after a retry, a ServerHello with another suitetls-hello-retry47
    a second HelloRetryRequesttls-unexpected-message10

    The 64 streams tls_many replays were re-recorded too.

  • The ServerHello rules test (conformance/tls.rs) builds a HelloRetryRequest for P-256, one with a share in it, one for X25519, and one that changes nothing. AES-128-GCM and AES-256-GCM are now accepted, and TLS_AES_128_CCM_SHA256 is refused.

  • RFC 8448’s AES-128-GCM record. §3’s protected server flight (EncryptedExtensions to Finished, 674 bytes) opens with tls_record.open under 1301, with RFC 8448’s handshake key and IV, to the RFC’s plaintext. With one bit flipped, or under ChaCha20, it is refused (tests/vectors/tls/rfc8448_record.txt, from s2n-tls’s transcription, as kdf.txt’s rows are). That is the RFC 8448 gate this section set.

  • scripts/tls_record_differential.py now picks one of the three suites per record. Of 2,000 records sealed and 2,000 opened against pyca, 0 differ.

  • Live (scripts/tls_live.py), every connection ok:

    • openssl s_server (OpenSSL 3.0.13) with each of the three suites against each of X25519, P-256 and P-384, 16 connections each. P-256 and P-384 make it answer the X25519 share with a HelloRetryRequest.
    • Python ssl with the five certificate types, 64 connections at both read sizes. The client now lists AES-256-GCM first, as OpenSSL does, so these connections run AES-256-GCM-SHA384, not ChaCha20 as before.
    • openssl s_server and tlslite-ng with their earlier settings, 64 connections at both read sizes.
    • The refusals (a missing close_notify, another host, another CA’s root) end with the same tags as before.
  • Mutants: scripts/tls_mutants.py has 38, all killed.

    • 15 are new: the SHA-384 transcript, each suite’s hash, key length and AEAD, the Finished MAC’s hash, message_hash, the transcript restart, the cookie, a second retry, the retry’s suite and group checks, a retry that changes nothing, the change_cipher_spec after a retry, and the retry share’s curve.
    • One was dropped as equivalent: the ServerHello’s group compared with the share sent. A share of another group always has the wrong length for the key the client holds, so the key exchange refuses it with the same tag. The comparison is defence in depth.

RFC 8448’s trace is still not replayable byte for byte, AES-GCM or not. Its ClientHello has an empty session id and offers groups and suites this client does not, so the server’s flight answers a transcript this client never produces. #205’s decision on that gate stands.

3.4 TLS 1.2 (PR 5)

A second handshake in tls_client, sharing the record layer, the certificate path (docs/x509-verify.md), the slot and the engine:

  • ClientHello offers 1.3 and 1.2 in supported_versions and legacy_version 1.2. It carries the six suites of §2, extended_master_secret, the renegotiation SCSV, ec_point_formats (uncompressed), and the groups and signatures of §2.
  • The downgrade sentinels:
    • a TLS 1.2 ServerHello whose random ends DOWNGRD\x01 is refused, since this client offered 1.3 (RFC 8446 §4.1.3);
    • one ending DOWNGRD\x00 is refused too.
    • #208: the code refused them in a TLS 1.3 ServerHello as well, which this never said; corrected to match it (docs/tls-assurance.md §4).
  • The flight:
    • ServerHello, whose extensions may be renegotiation_info (empty), extended_master_secret and ec_point_formats. Corrected (#208, docs/tls-assurance.md §5): and server_name, empty, which RFC 6066 §3 has a server send when it used the name. This list left it out, and the client refused every TLS 1.2 handshake with nginx (tls-unsupported-extension); the interop matrix found it;
    • Certificate (verified as in 1.3);
    • ServerKeyExchange, whose ECDHE parameters are signed with the leaf’s key over both randoms;
    • ServerHelloDone;
    • then the client’s ClientKeyExchange, ChangeCipherSpec and Finished, and the server’s ChangeCipherSpec and Finished.
  • The keys:
    • the PRF of RFC 5246 §5 over the suite’s hash;
    • the extended master secret over the session hash (RFC 7627 §4);
    • the key block;
    • verify_data of 12 bytes.
  • The nonces:
    • ChaCha20-Poly1305 per RFC 7905 §2: the sequence number XORed into the 12-byte IV, as in 1.3;
    • AES-GCM per RFC 5288 §3: a 4-byte implicit salt and the 8-byte sequence number as the explicit nonce.
  • A CertificateRequest is answered with an empty Certificate, as in 1.3: no client certificates.
  • Refused:
    • a HelloRequest (no renegotiation);
    • a server without extended master secret;
    • a NewSessionTicket the client did not ask for;
    • compression;
    • a record that is not an AEAD record after ChangeCipherSpec.

Gates. The issue names “RFC 7905 vectors”. RFC 7905 has no test vectors: it defines the suites and the nonce, and nothing to replay. The gates are:

  • the PRF against RFC 5246’s reference computation in pyca/cryptography and OpenSSL’s kdf -kdfopt TLS1-PRF, 1,000 cases each;
  • the extended master secret against the same;
  • traces recorded against openssl s_server -tls1_2 -cipher each of the six suites, replayed byte for byte on both backends;
  • live, 64 connections at once:
    • openssl s_server -tls1_2 for each suite;
    • Python ssl with maximum_version TLS 1.2;
    • tlslite-ng with TLS 1.2;
  • the lying server’s 1.2 cases:
    • each sentinel;
    • ServerKeyExchange signed by another key, and over the wrong randoms;
    • no extended master secret;
    • a wrong Finished;
    • a HelloRequest;
    • CBC and static-RSA suites chosen;
    • an unoffered group;
    • the explicit nonce reused; (dropped in PR 5: a receiver reads the explicit nonce from each record and cannot tell a reused one from a fresh one without keeping every nonce it has seen. RFC 5288 §3 puts uniqueness on the sender, and this client’s nonces are its sequence numbers. Nothing for a lying server to test.)
  • the mutant and fuzz bars of #205: at least 15 mutants killed, and the driver fuzzed with no trap.

3.4.1 Results (PR 5)

Built:

  • packages/tls/slot.cho (tls_slot) is new. It holds what both handshakes share: the states, the slot’s layout, the transcript, the record queue, alerts, the ECDH share, and the chain and signature checks. It was moved out of client.cho, which would otherwise have passed 2,000 lines.
  • packages/tls/client12.cho (tls_client12) is new: the TLS 1.2 handshake.
  • record.cho gained:
    • the six suites;
    • TLS 1.2 records: the additional data carries the sequence number. AES-GCM’s 8-byte explicit nonce is sent in each record and is the sequence number, and ChaCha20’s nonce is the IV XORed with the sequence number, as in 1.3;
    • the PRF.
  • message.cho:
    • the ClientHello offers TLS 1.2 beside 1.3, with the six suites, the renegotiation SCSV, ec_point_formats, extended_master_secret, and rsa_pkcs1_* among the signature algorithms;
    • a ServerHello with no supported_versions is read as TLS 1.2;
    • new parsers for TLS 1.2’s Certificate, ServerKeyExchange and CertificateRequest.
  • A TLS 1.2 ServerKeyExchange may use RSASSA-PKCS1-v1_5, and its ECDSA schemes name the hash, not the curve (RFC 5246 §7.4.1.4.1). The leaf’s key must be the suite’s kind: ECDSA or Ed25519 for ECDHE_ECDSA, RSA for ECDHE_RSA.
  • Two refusal tags are new:
    • tls-extended-master-secret (alert 40);
    • tls-renegotiation, for a HelloRequest (alert 100).
  • The TLS 1.2 ECDHE scalar is drawn as a HelloRetryRequest’s is (§3.3.1).

Evidence:

  • Six traces against openssl s_server -tls1_2 -www (OpenSSL 3.0.13), one for each suite, recorded by scripts/tls_trace.py --openssl12. They replay byte for byte on both backends, and in one-byte and coalesced splits.

  • The lying server has a TLS 1.2 server (scripts/tls_liar.py, Server12) with 22 cases, so 63 in all:

    Server doesTagAlert
    ECDHE-ECDSA with ChaCha20-Poly1305; with AES-256-GCM-SHA384; with a CertificateRequest (answered with an empty Certificate) under AES-128-GCM; with P-256oknone
    either downgrade sentinel; TLS 1.2 after a HelloRetryRequesttls-protocol-version70
    no extended master secrettls-extended-master-secret40
    a CBC suite; a static-RSA suitetls-no-shared-cipher40
    a ServerHello echoing the client’s session id; a renegotiated connection in renegotiation_infotls-decode-error50
    a key_share in a TLS 1.2 ServerHellotls-unsupported-extension110
    the key exchange signed by another key, or over the randoms in the wrong order; an RSA suite with an Ed25519 keytls-bad-certificate-verify51
    a key exchange on X448tls-key-share47
    ServerHelloDone before ServerKeyExchange; a NewSessionTicket, never asked fortls-unexpected-message10
    a wrong Finishedtls-bad-finished51, encrypted
    a plaintext Finished after change_cipher_spectls-bad-record-mac20, encrypted
    a HelloRequesttls-renegotiation100, encrypted
  • The PRF and the extended master secret. scripts/tls12_prf_differential.py runs 1,000 cases, each checked against both OpenSSL’s TLS1-PRF (openssl kdf) and RFC 5246’s P_hash on Python’s hmac. They include the extended master secret (48 bytes over a session hash), the key block, and both Finished labels. There are 0 differences, and 40 of the cases are tests/vectors/tls/prf12.txt, run on both backends.

  • Records. seal12 and open12 match records built on pyca’s AES-GCM and ChaCha20-Poly1305.

  • Live (scripts/tls_live.py), every connection ok:

    • openssl s_server -tls1_2 with each of the six suites, and with -sigalgs RSA+SHA256 (a PKCS#1 v1.5 key exchange signature). Each suite was also run by hand against X25519, P-256 and P-384: all 18 pass.
    • Python ssl capped at TLS 1.2, with P-256, P-384, RSA-2048 and Ed25519 certificates.
    • tlslite-ng with TLS 1.2.
  • A fuzzer. scripts/tls_fuzz.py mutates the server’s bytes in the eleven recorded handshakes: bits flipped, bytes set, the data cut, slices duplicated or dropped, bytes inserted, lengths changed. Over 20,000 connections the driver never trapped. This is the “driver fuzzed with no trap” bar. The fuzzing over a million inputs is still #208’s (docs/tls-pure.md §9).

  • Mutants: scripts/tls_mutants.py has 59, all killed. 22 are TLS 1.2’s:

    • the PRF’s chaining and label;
    • the extended master secret’s label;
    • the key block’s order, and which key is whose;
    • the explicit nonce sent, and the explicit nonce read;
    • the additional data’s sequence number and length;
    • ChaCha20’s 1.2 nonce;
    • the extended master secret required;
    • the session id echo;
    • a key_share in a 1.2 ServerHello;
    • the signed randoms;
    • the suite’s kind of key;
    • both of Finished’s checks;
    • HelloRequest;
    • the ClientKeyExchange’s length;
    • TLS 1.2 after a retry;
    • the server’s point kept.

Corrected in place:

  • §2’s table gave the missing extended master secret tls-protocol-version. The text after it promised a tag of its own, which is what was built.
  • §3.4’s “the explicit nonce reused” case is something a client cannot detect; the note there says why.

4. Files

FilePRWhat
std/aes.cho, std/gcm.cho2bitsliced AES-128/256; GHASH and GCM
std/ecdh.cho, and std/bigmod.cho where it branches on data3P-256 and P-384 key exchange
packages/tls/record.cho4, 5the AEAD chosen by the suite; TLS 1.2’s AES-GCM nonce
packages/tls/message.cho4, 5the new ClientHello; HRR; TLS 1.2’s messages
packages/tls/client.cho, a new packages/tls/client12.cho4, 5the 1.3 changes; the 1.2 handshake in its own file, under 2,000 lines

5. What stays different from OpenSSL, after all five PRs

  • Suites: the 21 TLS 1.2 suites of §2 that are not ECDHE with an AEAD.
  • Groups: X448, P-521 and FFDHE.
  • The extended master secret is required (§2).
  • SHA-1 and SHA-224 signatures are refused.
  • No resumption: the OpenSSL backend can resume sessions, which halves its handshake CPU (docs/tls-nonblocking.md §8.5, §10.4); this client does a full handshake every time. That costs time, not reach.

Each is refused with its own tag, so an operator can see which one a receiver needs. #210’s comparison against OpenSSL reports how many receivers each one costs.

6. What the client did not do (#386): client certificates, ALPN, and two decisions

Status: design, before the code (#386). The issue lists four gaps. A gateway needs two of them now, client certificates (mutual TLS to an upstream) and ALPN (naming the protocol the connection will carry), and both are designed here and built in the same PR. The other two, revocation and the post-quantum hybrid key share, are decisions: §6.8 and §6.9 give the evidence and a proposed answer, for a person to accept. Nothing in §6.1 to §6.7 is measured yet; the build fills in “as built” results, and corrects here, in place, whatever it finds false.

6.1 The four gaps, and what is decided

GapDecisionWhere
Client certificatesbuild: TLS 1.3 and TLS 1.2, P-256 keys, up to 4 identities chosen by the host name§6.2 to §6.5
ALPNbuild: an offer list, the protocol chosen reported, a protocol not offered refused, TLS 1.3 and TLS 1.2§6.6
Revocation (CRL, OCSP, stapled OCSP)decide: proposed, do not build now§6.8
X25519MLKEM768decide: proposed, do not build now§6.9

Refused on purpose, not deferred, and unchanged: 0-RTT, PSK-only resumption, TLS 1.2 resumption, post-handshake authentication (docs/tls-resumption.md §3). A server that sends a CertificateRequest after the handshake is tls-unexpected-message, as now, because post_handshake_auth is never offered.

6.2 Client certificates: the API

docs/tls-pure.md §7.2 left them out and answered every CertificateRequest with an empty Certificate (§7.1). The signer they need is built (std.ecdsa_sign.sign_checked, docs/ecdsa-sign.md), and so is the key parser (x509_key.parse_pem) and the shape of a held identity (docs/tls-server.md §4, tls_identity).

tls.open_mutual(heap, slots) -> Engine                  // a client engine that holds identities: open_mutual_with_tickets(heap, slots, tickets) too
tls.add_client_identity(engine, chain_pem, key_pem, hosts, now_unix_ms) -> id | refusal
tls.replace_client_identity(engine, id, chain_pem, key_pem, now_unix_ms) -> 0 | refusal      // keeps its hosts
tls.remove_client_identity(engine, id) -> 0 | refusal                                         // overwrites the key
tls.client_auth(engine, slot) -> 0 | 1 | 2                                                    // see §6.3
  • The identity is the engine’s, chosen by the host. A gateway talks to several upstreams, so one engine-wide identity is not enough, and a per-pool one (a pool is a ticket pool, docs/tls-resumption.md §12) names the wrong thing: a pool is a place to find a ticket, an identity is a thing you prove. hosts is a space-separated list, *.example.com standing for one label as a certificate name does (x509_names.dns_matches), or * alone for every host; an IP address host is chosen only by * or by the address itself, never by a wildcard (*.0.0.1 is not 10.0.0.1; after review). start picks the first identity whose hosts match the name it is given, in the order they were added, and none otherwise. An identity is never sent to a host it does not name: an upstream that asks for a certificate it should not get (a public site with optional client authentication) learns nothing. hosts may not be empty (tls-client-names), so a forgotten argument is a refusal, not an identity that is silently never used.
  • What the engine holds, per identity: the chain as Certificate sends it (each certificate’s DER, up to 12 KiB in all: a message of the client’s has to fit one record, and tls_identity’s 16 KiB for a server would not), the key (32 bytes), its public point (65), the leaf’s notAfter, and the hosts (up to 512 bytes). Four identities; the engine never gives a key back, and there is no function that answers one. Memory: an open_mutual engine adds 51,668 bytes for the four (12,915 each, and an 8-byte header: corrected after review, it was 51,636 before the generations) and the key parser’s work (x509_key.work_len() words, 77 KB); an engine opened with open adds nothing. A slot holds none of it: it holds which identity it chose, so the chain’s 12 KiB is not paid 64 times. The chain is read from the engine’s store when the client’s flight is built, which is one call of feed, so a replace_client_identity between two calls cannot give a connection a chain and a signature from two identities.
  • Overwritten: remove_client_identity and replace_client_identity overwrite the old key and chain first, and close overwrites all four (best effort, docs/tls-pure.md §7.3). tls.drop of a slot is unchanged.
  • The key comes from x509_key.parse_pem: an unencrypted PKCS#8 PRIVATE KEY or SEC 1 EC PRIVATE KEY, P-256 only, as the server’s. It must be the leaf’s key (tls-client-key-mismatch), the leaf must parse and not have expired at now_unix_ms (tls-client-cert-expired), and a refused replacement leaves the identity as it was. What other keys need, and why they are not built: P-384 needs std.ecdsa_sign for that curve with SHA-384 (the ladder, the order and the RFC 6979 HMAC are parameters of std.ecdh and std.bigmod already, so this is a port, not new arithmetic), and a P-384 key parser path; Ed25519 needs a signer in std.ed25519 (verification is all it has) and PKCS#8’s 1.3.101.112; RSA needs a modular exponentiation by a secret exponent, which std.bigmod is not (docs/tls-parity.md §3.2: variable time, built for public values), so it needs a constant-time, blinded private operation first. A mutual-TLS backend whose policy accepts only RSA client certificates is out of reach until then, and the refusal says so (tls-client-key-type).

6.3 Which CertificateRequests are answered with a certificate

The identity is chosen at start (§6.2). When the server’s CertificateRequest comes, the client sends the chain only if all of these hold; otherwise it sends the empty Certificate it sends today (RFC 8446 §4.4.2: a client with no suitable certificate sends an empty one), and the server decides:

  1. an identity was chosen for this host;
  2. signature_algorithms lists ecdsa_secp256r1_sha256 (0x0403), the only scheme this key signs with. (TLS 1.2: the certificate_types list also holds ecdsa_sign, 64.) A request with no signature_algorithms is not satisfiable: RFC 8446 §4.3.2 requires it, and a client cannot know what a server will verify; corrected after review (#396): in TLS 1.3 it is a refusal, tls-missing-extension with missing_extension (109), as RFC 8446 §4.3.2’s MUST and OpenSSL both say, not an empty Certificate;
  3. certificate_authorities is empty or absent, or one of its names is the issuer of a certificate in our chain (byte for byte, as the DER Name; x509.issuer_start and issuer_end are whole TLVs). RFC 8446 §4.2.4 says the server SHOULD be guided by it, so a name that matches nothing is “not satisfiable”, not a protocol error.

Not checked, and said: signature_algorithms_cert and oid_filters. The chain’s own signature algorithms are not compared to signature_algorithms_cert; if the server cannot verify our CA’s signature it ends the handshake with an alert, and the client reports tls-alert. client_auth says what happened (and tls-alert’s detail the alert):

tls.client_authmeaning
0the server asked for nothing (or the connection resumed, which asks for nothing, §6.5)
1asked, and the chain was sent with a CertificateVerify
2asked, and the empty Certificate was sent: no identity for the host, or the request was not satisfiable

A server that requires a certificate when none is configured ends the handshake with certificate_required (116; TLS 1.2 servers send handshake_failure, 40). The client fails with tls-alert, detail 116, and client_auth is 2. That is the clean refusal: no new tag, because the alert is the server’s word and client_auth is ours. Optional mode (OpenSSL -verify, nginx ssl_verify_client optional, Go VerifyClientCertIfGiven) completes whether a certificate was sent or not.

6.4 The signature, and TLS 1.2

  • TLS 1.3. The client’s flight is change_cipher_spec, Certificate (the request’s context echoed, then the chain), CertificateVerify, Finished. The signature is over 64 bytes of 0x20, the context string TLS 1.3, client CertificateVerify, a zero byte, and the transcript hash through the client’s Certificate (RFC 8446 §4.4.3), under ecdsa_secp256r1_sha256: SHA-256 of that content, then std.ecdsa_sign.sign_checked under the identity’s key and point, then to_der. The transcript hash is the suite’s (SHA-256 or SHA-384); the digest signed is always SHA-256. sign_checked verifies the signature before it is used, as the server’s does (docs/tls-server.md §3.3), so a fault sends nothing (tls-client-sign, never reached by an input).
  • The nonce is hedged with 32 bytes of the engine’s generator, drawn at start when an identity is chosen and kept in the slot’s keys (k_sign_hedge): RFC 6979 §3.6, what std.ecdsa_sign documents extra as. Corrected after review (#396): the first build derived them from a handshake secret, which is not what the signer’s interface asks for. An engine with no identity for the host draws nothing more, so every connection of an engine without identities draws exactly what it drew before.
  • The work area is the slot’s std.ecdh work, which the key exchange has finished with by then. No allocation grows.
  • TLS 1.2: built, not refused. The client speaks TLS 1.2 to the upstreams a TLS 1.3 client cannot reach, and an mTLS backend that old is the likeliest to ask for a certificate. The slot already runs the SHA-256 and the SHA-384 transcript side by side (tls_slot.transcript_add), so the one thing 1.2 needs that 1.3 does not, a SHA-256 hash of all the handshake messages whatever the suite’s hash, is already there. The client sends Certificate (a list of DER certificates, with no per-certificate extensions), ClientKeyExchange, CertificateVerify (RFC 5246 §7.4.8: the scheme and a signature over all handshake messages so far, hashed with SHA-256, not the content string), change_cipher_spec, Finished. The extended master secret is over the session hash through ClientKeyExchange (RFC 7627 §4), so it is computed before CertificateVerify and is not affected by it.
  • In TLS 1.2 the client’s certificate travels in the clear (it precedes change_cipher_spec), so anyone on the path learns who the client is; TLS 1.3 encrypts it. tls.set_client_auth_tls12(engine, false) makes a TLS 1.2 request get the empty Certificate while TLS 1.3 still answers. The default is to answer, because the backends that still speak only TLS 1.2 are the likeliest to require a certificate; a deployment that cares about the identity’s privacy more than those backends turns it off. (Added after review, #396.)

6.5 Resumption: what a ticket obtained with client authentication binds

docs/tls-resumption.md §3 gives eight rules and none says what a client certificate does to a ticket. A resumed connection sends no certificate (a PSK handshake has no CertificateRequest); the server carries the client’s identity in the ticket. So the ticket is a stored verdict about our identity as well as the server’s, and every way it could go stale is a rule:

  • The identity is checked again when it is used (after review, #396). It is chosen at start and kept as a number and its generation (every add, replace and remove raises a counter the identities share, and an identity is stamped with its value). When the CertificateRequest comes, and again when the client’s flight is built (which may be a later feed), the identity must still be in use under that number, of that generation, and still the one the host’s name chooses; otherwise the empty Certificate. So an identity removed and another added in its number mid-handshake is never sent to a host it does not name, and a removed key is never used (the first build would have signed with a zeroed key and failed tls-client-sign).
  • 9. Bound to the identity configuration. add_client_identity, replace_client_identity and remove_client_identity each count as a trust call (they raise the same generation, tmeta[t_trust]), so no ticket saved before is offered after. Corrected after review (#396): a ticket is stamped with the generation when its connection started (i_trust_gen), not when it is saved: a connection that began before an identity or the trust store changed and received its ticket after is not resumed either. A resumption must not be presented as an identity that changed or was removed, and a server that issued the ticket to an anonymous session must not be asked to resume it as the one we now hold. The cost: after a certificate renewal the next connection to each upstream is a full handshake. That is one handshake per upstream per renewal; certificates are renewed in weeks.
  • 10. Bound to the client certificate’s life. The ticket’s notAfter (rule 3) becomes the earlier of the server leaf’s and the client leaf’s, when a client certificate was sent. A full handshake would send an expired certificate and be refused; a resumption must not outlive it either.
  • 11. Not across a host is rule 1 unchanged, and it is what keeps an identity chosen by host name from reaching a ticket for another.
  • TLS 1.2 has no tickets here (rule 7); nothing to bind.

start_with with a pool whose ticket the rules refuse overwrites the ticket and does a full handshake with the identity now configured (docs/tls-resumption.md §12). tls.resumed is true and client_auth 0 on a resumed connection.

6.6 ALPN

RFC 7301. The API:

tls.set_alpn_offer(engine, protocols) -> 0 | refusal           // the default offer, space-separated: "h2 http/1.1"; empty clears it
tls.start_alpn(engine, slot, host, now_unix_ms, pool, protocols) -> 0 | refusal   // this connection's own offer (pool 0: a new pool); empty offers none
tls.alpn(engine, slot, out) -> n                                // the protocol the server chose, into out; 0 for none

start and start_with offer the engine’s default; start_alpn overrides it for one connection (a gateway talks h2 to one upstream and HTTP/1.1 to another). alpn is the server’s call too (tls_identity’s chosen protocol, unchanged); on a client engine it answers the server’s choice once EncryptedExtensions (TLS 1.3) or the ServerHello (TLS 1.2) has been read.

  • The offer is at most 256 bytes in ALPN’s wire form (each name after its length byte), each name 1 to 255 bytes. A name that is empty, over 255 bytes, or a list over the cap is tls-alpn-list and the list is as it was. The offer is copied into the slot (256 bytes, §6.7) so a later set_alpn_offer does not change a handshake in flight, and so the server’s choice can be checked against it.
  • The extension goes in the ClientHello between signature_algorithms_cert and supported_versions, only when the offer is not empty. With none offered, the ClientHello is byte for byte today’s, so every recording and ticket test stands.
  • The server’s answer. TLS 1.3: in EncryptedExtensions. TLS 1.2: in the ServerHello’s extensions. Exactly one name (RFC 7301 §3.1).
    • one that is not in the offer is tls-alpn-selected (alert illegal_parameter, 47). The server’s answer is the only thing that could make the client run a protocol it never offered, so it is checked byte for byte against the offer. A name longer than the offer’s cap can never be in it, so it is the same refusal; a length that does not fit the extension is tls-decode-error, and the extension twice tls-extension-repeat;
    • an ALPN answer when nothing was offered is tls-unsupported-extension (RFC 8446 §4.2), as now;
    • ALPN in a TLS 1.3 ServerHello (not EncryptedExtensions) is tls-unsupported-extension;
    • no answer is a connection with no protocol: alpn answers 0, and the caller decides. A program that needs h2 closes. (The server is the one that ends a handshake with no_application_protocol, 120, and that arrives as tls-alert.)
  • ALPN and resumption. A ticket is not bound to the offer. RFC 8446 §4.6.1 ties a ticket’s use to the protocol only for early data, which is not sent; a resumed handshake carries ALPN in EncryptedExtensions again and the client checks that choice against this connection’s offer. So start_with after a changed offer resumes and reports the new choice, and the check is the same.
  • hooks and http-client consumers. packages/http-client speaks HTTP/1.1 only. Its I/O driver (examples/http_fetch_nb/fetch_io.cho) calls tls.start; with set_alpn_offer("http/1.1") a server that would pick h2 for a client that said nothing picks http/1.1, and the answer is readable. cancho-hooks maps open(...) onto tls.start; it can offer http/1.1 the same way. Neither changes unless it asks.

6.7 Hostile input, memory, and the refusals

  • CertificateRequest (TLS 1.3 and 1.2) is at most the handshake reassembly buffer, 64 KiB, as every message. Nothing is allocated from a length it names: the context is at most 255 bytes (the slot’s k_context, 256), the extensions and the authorities list are walked in place, and each is checked to end where it says. signature_algorithms and certificate_authorities twice in one request are tls-extension-repeat (RFC 8446 §4.2, alert 47 as OpenSSL sends); an authorities list whose names do not tile it, a name of length 0, or a signature_algorithms list of odd length, are tls-decode-error. Matching the authorities against our chain is at most (the chain’s certificates) times (the list’s bytes), about 8 × 64 KiB comparisons; it reads and compares, writes nothing. A second CertificateRequest is tls-unexpected-message (the flag that is set at the first, as now); one after Finished is out of order for the same reason; one in a resumed handshake is out of order too.
  • Slot layout (the shared files, as little as possible):
    • tls_slot.ints_len(): +4 words (10,241 to 10,245; two more after review: i_identity_gen and i_trust_gen, §6.5), appended after i_session_len: i_alpn_offer_len (0 to 256) and i_alpn_at (where the server’s choice starts in the offer, valid when i_alpn_len, which the server already has for its own choice, is more than 0). The slot’s chosen identity is i_identity (the server’s field for the same idea; a client slot is free to hold it, as id + 1, 0 for none).
    • tls_slot.bytes_len(): +288 bytes (187,191 to 187,479): keys_len 1,728 to 1,760 for k_sign_hedge (§6.4, after review), and b_alpn_offer, 256, at the end (after the ticket host name). A slot is 269,439 bytes, 320 more; 64 slots: 20 KiB.
    • tls_slot: one flag, f_cert_send, bit 24 (16,777,216; the server’s client-authentication work uses bits 15 to 18). A ClientHello offering ALPN adds at most 4 + 2 + 256 bytes to max_client_hello.
    • tls_record: refusal codes -120 to -132 (the server’s are -40 to -71), tls-client-*, tls-alpn-* and tls-extension-repeat, below.
    • tls.cho: no field is added to Engine; a client engine opened with open_mutual keeps its identities in the field ids (empty for a client) and the parser’s work in srv after the role word (which is 2 for it). t_fields is unchanged: rule 9 reuses t_trust, rule 10 reuses the entry’s notAfter.
  • New tags (alert in the last column; every refusal has a rule tag, CLAUDE.md):
CodeTagWhenAlert
-120tls-client-key-typeadd_client_identity: a key, or a leaf, that is not P-256n/a
-121tls-client-key-formata key that is not an unencrypted PKCS#8 or SEC 1 PEM blockn/a
-122tls-client-key-mismatchthe key is not the leaf’sn/a
-123tls-client-cert-expiredthe leaf has expired at now_unix_msn/a
-124tls-client-chainno certificate, a block that does not decode, a leaf that does not parse, or a chain over 12,288 bytes (tls_cident.chain_cap())n/a
-125tls-client-namesno hosts, or over 512 bytesn/a
-126tls-client-identities-fullfour identities heldn/a
-127tls-client-no-identityreplace or remove of an id never addedn/a
-128tls-client-signthe signer refused or its check failed: a fault, not an input80
-129tls-alpn-listset_alpn_offer or start_alpn: an empty name, a name over 255 bytes, a list over 256n/a
-130tls-alpn-selectedthe server chose a protocol that is not in the offer47
-132tls-missing-extensiona TLS 1.3 CertificateRequest without signature_algorithms (RFC 8446 §4.3.2)109
-131tls-extension-repeatsignature_algorithms or certificate_authorities twice in a CertificateRequest, or ALPN twice in a server’s answer (RFC 8446 §4.2)47

A client call on an engine not opened with open_mutual, or a server engine, is tls-role (-57), as the server’s calls on a client engine are.

6.8 Revocation: the decision, proposed

Proposed: build nothing now. Say plainly that revocation is not checked (docs/tls-pure.md §5.4 already does), and keep the controls that do work for a gateway. Evidence, from public sources read for this section (summaries of vendor and CA pages; where a default could not be read in a primary document it is marked unverified):

  • Few clients check by default. Chrome does no online OCSP or CRL check and uses CRLSets; Firefox uses CRLite from version 137 and turned OCSP off for DV certificates in 142 (Mozilla, Aug 2025); curl checks a stapled response only with --cert-status, and then a missing one is a hard failure; Go’s crypto/tls and x509.Verify check nothing; rustls checks only CRLs the program passes. Unverified: OpenSSL and Python ssl check nothing unless X509_V_FLAG_CRL_CHECK is set (as the issue says; the manual was not re-read here).
  • The public web is leaving OCSP. Let’s Encrypt removed OCSP URLs from certificates on 7 May 2025 and shut its responders on 6 Aug 2025, and Must-Staple requests failed from January 2025 (“Must Staple has failed to get wide browser support”; letsencrypt.org, 5 Dec 2024). CA/Browser Forum ballot SC-063 made OCSP optional and CRLs mandatory (effective 15 Mar 2024). Stapling was seen on about 8% of Firefox connections in early 2023 (Firefox telemetry, CA/B Forum list, Feb 2023) and has fallen with Let’s Encrypt’s change: no 2024 to 2026 measurement was found.
  • Certificates are getting short. Let’s Encrypt offers 6-day certificates (generally available 15 Jan 2026); CA/B Forum SC-081v3 cuts the maximum to 200 days (15 Mar 2026), 100 (2027) and 47 (2029). A short life is revocation by expiry, and it needs no code here.
  • Soft-fail defends against little. A client that accepts a connection when no response comes is defeated by an attacker who removes the staple; only Must-Staple made it hard, and it is being abandoned.
  • The attack surface is real. Honouring a stapled response means a BasicOCSPResponse parser (an ASN.1 structure with optional, tagged and nested parts), the responder’s certificate (a delegated responder needs id-kp-OCSPSigning and the CA’s signature), a CertID match (SHA-1 is still common, so SHA-1 would be needed for this one use), thisUpdate/nextUpdate with a skew rule, and the choice of what a missing or stale response means. OpenSSL itself had CVE-2022-1343 (OCSP_basic_verify reported success when the signer failed to verify). An estimate, not a measurement: 400 to 800 lines of security-critical code and a second signature entry point, for a check that is soft by nature and that a growing part of the web no longer feeds.

What a gateway should do instead (in the README of the package and in docs/tls-pure.md §5.4): use short-lived certificates or a private CA for upstreams it controls; keep trust to the roots it needs, not the system bundle; keep set_ticket_max_age short (resumption is the other way a revoked certificate stays trusted, docs/tls-resumption.md §3 rule 4); and, for a revoked upstream key, remove its root or replace its certificate.

If a person wants more, the smallest useful step is an opt-in require stapled OCSP mode, off by default: send status_request (RFC 6066 §8) in the ClientHello, and when the server’s CertificateStatus (TLS 1.2) or the leaf’s status_request extension (TLS 1.3) carries a response, verify it against the issuer in the chain already built, and refuse the connection if it says revoked, has expired, or fails to verify; a missing response is accepted unless the mode is require. That is the code above, and its gate would be a differential against openssl ocsp and openssl s_server -status with Wycheproof-style malformed responses. A CRL path (operator-supplied files, checked against the leaf’s serial) is the other candidate, and is larger. Not proposed: live OCSP (it leaks the host to the CA and needs an HTTP client inside the TLS client) and CRLSet or CRLite (a data pipeline, not code).

Decision for a person: accept “not checked, documented, mitigations as above” (proposed), or ask for the opt-in stapled mode as its own change.

6.9 X25519MLKEM768: the decision, proposed

Proposed: do not build it now; do not claim the client reaches every server; add a check to the interop matrix that reports tls-alert 40 or 70 from a server with a post-quantum-only policy, and revisit on the triggers below.

  • No server that requires it was found. No public evidence turned up of a server, CDN or cloud policy that refuses a client without the hybrid share. AWS’s load balancer policies with the hybrid are opt-in and the console default still accepts classical clients; Cloudflare supports it and does not require it. The standards track has it (RFC 10024, August 2026: X25519MLKEM768, code point 0x11EC; key share = ML-KEM-768 encapsulation key 1,184 bytes then the X25519 share, 1,216 bytes; the server’s = ciphertext 1,088 then X25519, 1,120; the shared secret = the two secrets, ML-KEM first, 64 bytes). NSA’s CNSA 2.0 makes post-quantum key exchange mandatory for new national-security systems from 1 Jan 2027 and exclusive by 2033, with ML-KEM-1024, a different group and a different audience. So a requirement is plausible first on a government or private upstream, and not observed anywhere on the public web.
  • What share of servers supports it (not the same as requires): Cloudflare reports 39% of the top 100,000 domains supporting post-quantum key agreement in September 2025, up from 28% six months before, and 12.8% of origins it scanned in September 2026, up from 3.7% a year earlier and 0.5% in 2023. On the client side more than half of human traffic to Cloudflare used the hybrid by October 2025. (Secondary readings of vendor blog posts, dated as given; Cloudflare Radar itself could not be read from here.) Because servers that support it also accept X25519, the share that needs it is, on this evidence, near zero. That is a measurement of absence from public sources, not of every server: it says nothing about a private upstream.
  • What ML-KEM-768 would cost. Go’s implementation is about 500 lines of code with 200 of comments and 650 of tests, with Barrett reduction (no division) from the start; on ARM64 it takes about 56 µs to encapsulate and 109 µs to decapsulate (Valsorda, words.filippo.io/mlkem768). A client needs key generation and decapsulation, the secret-handling half. In this repository that is: the NTT and polynomial arithmetic mod 3329 on int (no overflow trap risk: every product is under 2^24), SHA-3 (Keccak, which std does not have: a second hash with its own constant-time argument and vectors), the centred binomial sampler for the secret noise (branch-free and index-free), the Fujisaki-Okamoto re-encryption with a constant-time ciphertext compare and a constant-time select of the key or the implicit-rejection key, byte encoding of 12-bit coefficients, and a ClientHello that grows by 1,216 bytes and no longer fits one packet (Chrome 124 broke middleboxes that hard-code ClientHello sizes). It also needs a bigger slot (the decapsulation key is 2,400 bytes, the encapsulation key 1,184) and a ClientHello buffer that is no longer 2,688 bytes.
  • The constant-time risk is the reason to be careful, more than the lines. KyberSlash (Bernstein et al., 2024) recovered keys in minutes or hours from secret-dependent divisions in widely used implementations, including the reference code; the same failure is available to any port that writes x / 3329 on a secret. This language has the tools (value_barrier, docs/value-barrier.md; scripts/chacha20_branches.py; the dudect harness) but each is a new audit. mlkem-native (CBMC and HOL Light proofs) and libcrux-ml-kem (hax) are the verified references; a port should follow one of them, with the NIST ACVP vectors and Valsorda’s CCTV cases (negative and “unlucky” XOF cases) as gates, plus mutants and a branch audit as docs/ecdh.md §3 did.
  • The reason not to wait forever: harvest-now-decrypt-later is a threat to the confidentiality of what a gateway sends today, not to a requirement the upstream imposes. That is an argument for offering the hybrid, which is a separate question from needing it; offering it adds the 1,216-byte share to every ClientHello for a benefit only if the upstream supports it. For upstreams an operator controls, who can say which they run, this is a decision per deployment.

Triggers to revisit (proposed): a named upstream announces a post-quantum-only policy; origin support passes about half of the top domains; an OpenSSL-compatible peer the gateway must reach drops the classical groups from its default; or the hooks receivers’ stack asks. Estimate if built: one module for ML-KEM (about 700 lines with Keccak), one for the hybrid share in message.cho and slot.cho, a growth of max_client_hello by 1,216 bytes, a dozen mutants, ACVP and CCTV vectors, a branch audit, one PR of the size of docs/ecdh.md’s.

Decision for a person: accept “not now, with triggers” (proposed), or ask for the build as its own epic with the constant-time audit as its first gate.

6.10 Gates

Tests for the build, each against a server that asks for a client certificate or negotiates ALPN, and a case for every rule above: openssl s_server -Verify and -verify, nginx ssl_verify_client on|optional, Go ClientAuth: RequireAndVerifyClientCert, wolfSSL; success, wrong CA, an expired client certificate, a server that requires one when none is configured, optional mode; the lying server for the new rules (scripts/tls_liar_auth.py); the differential against openssl s_client for the same flights; fuzz corpora extended; scripts/tls_mutants.py extended and all killed or argued; the existing suites byte for byte as they were; the cost of the extra signature, measured; examples/http_fetch_nb/fetch_io.cho with --client-identity and --alpn, live against openssl s_server -Verify and nginx.

6.11 As built (#386)

Every number is from the command beside it. Where building found §6.1 to §6.9 wrong, the section says so in place (marked “corrected”).

What was built

FileWhat
packages/tls/cident.cho (tls_cident), newthe identities: four, each a chain, a P-256 key, its point, the leaf’s notAfter and the hosts it names, in one byte slice (51,636 bytes); load (the checks of §6.2), remove, select by host, names_issuer (the authorities against the chain’s issuers), list12, alpn_wire
packages/tls/message.chothe ClientHello’s ALPN extension; certificate_request and certificate_request12_info (the shape, the scheme, the authorities); encrypted_extensions_alpn; the ServerHello’s ALPN (TLS 1.2); alpn_name
packages/tls/client.chostart_alpn, feed_with, the CertificateRequest handler, send_identity (Certificate and CertificateVerify), client_auth, alpn
packages/tls/client12.choanswer_request (shared by both versions), certificate_verify_message (the one caller of the signer), take_alpn, TLS 1.2’s Certificate and CertificateVerify
packages/tls/tls.cho, slot.cho, record.choopen_mutual, add_client_identity, replace_client_identity, remove_client_identity, client_auth, set_alpn_offer, start_alpn, alpn on a client engine; the slot fields and flag of §6.7; 12 refusal codes
examples/http_fetch_nb/fetch.cho, fetch_io.cho--client-identity (the chain and key in /etc/cancho/http_fetch_nb, a directory fixed at build time by one narrow, so the report names fs_read("/dev/urandom") and fs_read("/etc/cancho/http_fetch_nb") and nothing wider: after review, the first build took two paths on the command line and held fs_read("")) and --alpn; a response line ends alpn=<protocol or -> auth=<0, 1 or 2> when either is given

Corrected: §6.2 said an open_mutual engine keeps its identities in ids; it does, and in roots after the trust store it also keeps the default ALPN offer (258 bytes), which a client engine has no other field for. No field was added to Engine.

The signer’s caller. certificate_verify_message is the one place std.ecdsa_sign is called by the client. It signs a SHA-256 digest with sign_checked (the signature verified before it is used), the nonce hedged with the slot’s k_sign_hedge (the engine’s randomness, §6.4), into the slot’s std.ecdh work area. TLS 1.3’s content is the 64 spaces, the context string and the transcript hash; TLS 1.2’s digest is the slot’s SHA-256 transcript hash whatever the suite’s (the slot keeps both, tls_slot.transcript_add), taken after ClientKeyExchange and before the extended master secret’s key block, so neither is disturbed by the other.

The lying server (scripts/tls_liar_auth.py, the same server code as tls_liar.py imported unchanged; tests/vectors/tls/liar_auth.txt, replayed byte for byte on both backends by conformance/tls_auth.rs): 88 connections (77 at first, 11 more after review, #396). 49 end ok, and every one that sends a chain has its Certificate, its CertificateVerify (under TLS 1.3’s context string, or TLS 1.2’s SHA-256 over every message) and its Finished checked by pyca/cryptography. The rest, by rule:

The server doesTagAlert
asks, and the client answers: ChaCha20, AES-256-GCM-SHA384, a retry to P-256, a context to echo, the identity named by a wildcard host or by *, an unknown extension, a chain of two certificates, the authorities naming the issuer or only the second certificate’s issuer, 2,000 authorities (about 60 KB) with ours last; an IP address host with an identity for that address or for *; with TLS 1.2 answering turned off, TLS 1.3 still answered; TLS 1.2 under three suites and P-256, a chain of two, the authoritiesoknone
asks, and the client cannot or will not: Ed25519 or RSA-PSS only, authorities naming another CA, 2,000 authorities none ours, a name of the issuer’s length and other bytes, no identity configured, an identity for another host; the identity removed and another, or the same again, added in its number before the request; removed, or replaced by one for another host, between the request and Finished; another identity naming the host added ahead of the chosen one (the host’s choice changed); an IP address host and an identity for *.0.0.1; TLS 1.2 without ecdsa_sign, the identity removed between the request and ServerHelloDone, answering TLS 1.2 turned off, Ed25519 only, another CA, 2,000 authorities over four recordsok, the empty Certificatenone
requires one and none is configured (certificate_required)tls-alert
a TLS 1.3 CertificateRequest with no signature_algorithms (after review; it was an empty Certificate)tls-missing-extension109
a second CertificateRequest; one after Finished; one in a resumed handshake; TLS 1.2: a second, one after ServerHelloDonetls-unexpected-message10
signature_algorithms or certificate_authorities twice; ALPN twice (1.3 and 1.2)tls-extension-repeat47
authorities that do not tile their list, a name of length 0, an odd signature_algorithms, a context longer than the message, extensions longer than it, ALPN with a list or name length that does not fit, two names, or an empty one (1.3 and 1.2)tls-decode-error50
ALPN offered h2 and http/1.1 and answered spdy/3; a prefix of an offered name; h3 for h2; 255 bytes never offered (1.3), spdy/3 (1.2)tls-alpn-selected47
ALPN in a TLS 1.2 ServerHello when nothing was offered; in a TLS 1.3 ServerHellotls-unsupported-extension110
the identity’s own refusals: an expired leaf, another key, a key that is not PEM, a P-384 key, an Ed25519 leaf, no certificate, no hosts; an offer of 256 bytes, a name of 256tls-client-*, tls-alpn-list(a call)

The server also checks the ClientHello: the ALPN extension is there once, with exactly the offer, between signature_algorithms_cert and supported_versions, and absent when nothing was offered. The engine’s rules (scripts/tls_tickets_auth.py, tests/vectors/tls/tickets_auth.txt, 14 cases): an identity added, replaced or removed keeps a saved ticket back (rule 9); a slot emptied and refilled mid-handshake for another host answers with the empty Certificate (the engine-level case of the review’s finding 1, first written on this branch by the reviewer); and so do an identity added or removed, and a trust call, between a connection’s start and its ticket (after review), and a ticket saved after is offered and resumed with client_auth 0; the client certificate’s notAfter bounds a ticket whose server certificate lasts ten years (rule 10), offered at notAfter and not after; the identity is chosen by the host; four identities, a fifth, a replace and a remove of ones never added, a mismatched key, an expired leaf and no hosts are refused through the engine with their tags; the default offer is what start sends and a connection’s own replaces it, even by none; a ticket resumes under another offer and the new choice is reported.

Recorded against real servers (scripts/tls_trace.py --mutual, replayed by conformance/tls.rs with the other eleven, now fourteen, and in one-byte and coalesced splits): tlslite-ng 0.8.2 (TLS 1.3, reqCert), openssl s_server -tls1_2 -Verify 1 and -tls1_3 -Verify 1 (the OpenSSL of the machine that recorded them, macOS: 3.6.4 at first, 4.0.3 when they were re-recorded after review, because the hedge of the signature changed; the other traces are 3.0.13). Each server verified the client’s CertificateVerify and Finished, and said it saw the certificate.

Interop (scripts/tls_auth_interop.py, in the lexsys-interop image’s Ubuntu 24.04 on linux-aarch64 for OpenSSL 3.0.13, nginx 1.24.0, Go 1.22.2 and wolfSSL 5.6.6; fetch built by this PR’s compiler): 92 rows, 92 ok, each a server and a fetch. Per server and per version (1.3, 1.2): a valid certificate accepted; none configured against require refused (tls-alert; nginx answers 400 No required SSL certificate was sent after the handshake); a certificate from another CA refused (by the CA list, and for OpenSSL also with no authorities named, so the server’s own verification refuses what is sent); optional completes with auth=2 without a certificate and auth=1 with one; a certificate configured and never asked for is auth=0; the client’s own refusal of an expired leaf and of an RSA key before any connection; OpenSSL with its clock three days on (-attime) refuses a certificate that expires after one; Go, wolfSSL and nginx put what they saw in the body (client=client-good alpn=...) and its SHA-256 is checked. ALPN: the offer the server speaks chosen and reported; two offered, the server’s order (OpenSSL, Go) or the client’s (wolfSSL: it chose h2 for h2,http/1.1 against a list of http/1.1,h2) decides; no offer to a server with a list, none chosen; nothing in common: OpenSSL’s s_server, nginx and Go end the handshake (fetch says client.tls; the server’s no_application_protocol alert), wolfSSL completes with no protocol.

The differential against openssl s_client 3.0.13 (scripts/tls_differential_auth.py, the liar’s cases beside OpenSSL’s client with -cert, -key and -alpn, OpenSSL’s TLS 1.3 and TLS 1.2 signatures verified by the same server code): 68 connections (the 88, less 9 that configure the engine, 10 about the engine’s own state (an identity removed mid-handshake, TLS 1.2 answering turned off, an IP address host) and one that needs OpenSSL’s saved session), 60 agree, 3 differ in the alert only, 5 differ as documented, 0 otherwise (after review; 58, 3, 6 before, the sixth being the request without signature_algorithms, now refused as OpenSSL refuses it).

Casepackages/tlsOpenSSLWhy
ALPN: the server chooses a name that is not in the offer (four 1.3 cases, one 1.2)tls-alpn-selectedacceptsRFC 7301 §3.1: the choice is one of the client’s; OpenSSL 3.0 takes any name
duplicate extensions, an empty ALPN name, an odd signature_algorithms (1.2), ALPN in a TLS 1.3 ServerHello47 / 50 / 50 / 11047 / 80 / 80 / 47alerts only

What the differential also showed, not as a difference of outcome. OpenSSL’s s_client -cert sends its certificate to a request that does not name its issuer (authorities naming another CA, 2,000 names none ours, a name of the issuer’s length), to one whose signature_algorithms lacks its scheme in TLS 1.2 (no ecdsa_sign), and to every host. This client sends the empty Certificate in each, which is what §6.3 says: the server asked for certificates from CAs we are not under (RFC 8446 §4.2.4), and sending it a certificate it cannot accept discloses the identity to a server that cannot use it. Both complete against the liar, which does not require one. A deployment whose server lists the wrong authorities (some do) would find this client stricter than OpenSSL; the answer is to fix the list, or, if that is the policy a person wants, to send anyway (§6.12, question 1).

Fuzzing. fuzz_messages takes four more kinds (a TLS 1.3 CertificateRequest; the TLS 1.2 one with its info; EncryptedExtensions with an ALPN offer sent; the authorities of a request matched against the fixture identity’s chain); fuzz_flight and fuzz_client configure the fixture identity, and fuzz_flight offers ALPN when the input’s first byte is 128 or more; the fixture holds the mutual traces’ roots and identity and a fourth recorded flight, the mutual one, whose Finished verifies under the harness’s ClientHello, so the client’s Certificate and CertificateVerify are produced from real flights; 3 recorded mutual handshakes and 23 new inputs join the corpora (tests/vectors/fuzz), all replayed on both backends by conformance/tls_fuzz.rs. scripts/tls_fuzz.py mutates the three mutual traces and the 48 honest connections of the lying server (identities and ALPN offers included): 20,000 mutated connections over 62 recorded handshakes, 0 traps (53 before review). AFL++ was not run for this PR (see §6.12).

Mutants (python3 scripts/tls_mutants.py target/release/cancho): 157, of which 155 killed and 2 argued equivalent, none survived (103 before, 54 new; 148, 146 and 2 before review). Each new one is a bug in one place: the context string, the transcript missing the Certificate or the CertificateVerify, the request’s context not echoed, the digest, the hedge, the notAfter bound on a ticket, the authorities ignored or compared by length alone or only for the first certificate, the scheme not required, TLS 1.2’s digest under the suite’s hash, its chain with TLS 1.3’s extensions or cut short, ecdsa_sign not required, * never matching, any identity for any host, an expired leaf or another key accepted, a request that does not parse accepted (authorities that do not tile, a name of length 0, a scheme list twice), the engine not choosing by host or leaving tickets offerable after an add, a replace or a remove, an unoffered ALPN choice accepted or compared by length alone or when nothing was offered, the start of the choice lost, the extension left out of the ClientHello, each of the answer’s three length checks, its empty form, twice in either version, in a TLS 1.3 ServerHello, the alert, the default offer never used, the cap on the offer and the offer truncated. Two are equivalent, each argued in EQUIVALENT: an ALPN answer in EncryptedExtensions accepted by the parser when nothing was offered (take_alpn refuses it with the same tag), and a name over 255 bytes accepted by alpn_wire (the offer’s 256-byte cap refuses it with the same tag). Four of the new ones survived a first run and each got the case that kills it: a name of the issuer’s length and other bytes (the comparison by length alone), a chain whose second certificate has another issuer (only the first considered), a selection of an offered name’s length (h3 for h2), and the engine’s default offer through start rather than start_alpn. Three older mutants’ texts (server_name in a TLS 1.3 ServerHello, an unexpected extension, resumption never advertised) and one of the server’s (start on a server engine, now in start_plain) were brought up to the new code. scripts/tls_server_mutants.py: 71 of 71 killed. After review (#396), nine more: the hedge never stored, missing_extension sent as decode_error, an identity’s generation not compared, the identity not chosen again by the host when used (it survived a first run: a generation check covers every swap, so the case that kills it is another identity naming the host added ahead of the chosen one), TLS 1.2 answered when the engine says not to, the identity not checked again when either version’s flight is sent, a request without signature_algorithms accepted, a wildcard matching an IP address, and a ticket stamped at its save rather than its connection’s start; and “the engine does not choose by host” rewritten for the new code. The server’s “missing_extension sent as decode_error” now names the server’s code, since the client has a 109 of its own.

Cost of the extra signature (scripts/tls_client_auth_cost.py; the machine is the M-series Mac this was built on, arm64 macOS, load average 12 to 17 while it ran, the LLVM backend, fetch against openssl s_server -www 3.6.4 on loopback; the Docker VM had no disk left to run it in the lexsys-hooks-env image, and gram was not used): the client’s own CPU (user and system, wait4) per full handshake, the best of 3 runs of 200, a connection for each request and no tickets:

the server asks for nothingasks, the client declines (empty Certificate)asks, the client answersthe signature
TLS 1.33.462 ms3.511 ms5.308 ms1.797 ms (51% of a handshake)
TLS 1.23.965 ms3.911 ms5.713 ms1.802 ms (46%)

That is what docs/ecdsa-sign.md §7 measured for a sign_checked on an M4 Max, 1.62 ms (a signature 0.82 and its verification 0.80), plus about 0.2 ms for the chain’s bytes, the digest and the message. Asking is free (the first two columns agree within the noise of a loaded machine); answering costs two P-256 multiplications. It is the price of the safety check (sign_checked verifies before it sends); sign alone would be half of it. A mutual handshake costs about 1.5 times a one-way one here, and a resumed connection pays none of it (§6.5).

Existing suites. The 84 connections of liar.txt, the 20 ticket cases of tickets.txt, the eleven earlier traces, the 64 streams and the RFC 8448 record all replay as recorded: nothing was re-recorded, and the ClientHello is byte for byte today’s when no ALPN is offered. scripts/tls_differential.py and scripts/tls_interop.py were not changed.

6.12 Not done, not verified, and for a person

  1. Should a client send its certificate to a request whose authorities do not name its issuer? Proposed: no, as built (§6.3, and the differential above): it is what RFC 8446 §4.2.4 says a server wants, and it discloses nothing to a server that cannot accept it. OpenSSL sends. A deployment whose server mis-lists its authorities is the case for a switch.
  2. Not built: P-384, Ed25519 and RSA client keys (§6.2 says what each needs); signature_algorithms_cert and oid_filters are not checked (a server that cannot verify our CA’s signature ends the handshake and the client reports tls-alert); post-handshake authentication; client authentication on a resumed connection (a ticket carries it).
  3. Not run: the merged packages/tls server’s client certificates (#384 had not landed; its branch is tls-server-client-certs); wolfSSL, Go and nginx in TLS 1.2 with an RSA client certificate (the client refuses one before connecting); AFL++ over the new harnesses for hours (the corpora are seeds plus 20,000 mutations); the cost on x86-64.
  4. Timing. The signature is std.ecdsa_sign’s, whose constant-time argument and branch audit are docs/ecdsa-sign.md §2 and §6; the client adds no new secret-dependent branch (the code that reads the key is tls_cident.key and point, two slices handed to the signer). No dudect test of the client handshake as a whole.
  5. Revocation and X25519MLKEM768 are §6.8 and §6.9, for a person.
  6. Not independently reviewed (#209), as the rest of the client.

std.ecdh: P-256 and P-384 key exchange, constant time

Status: built. Not independently reviewed (#209). #207’s PR 3 of 5 (docs/tls-parity.md §3.2), for the self-contained TLS client (#197): the key exchange OpenSSL’s ClientHello offers beside X25519, which a server can ask for with a HelloRetryRequest. Unlike std.ecdsa, whose inputs are all public, a key exchange multiplies by a secret scalar. So everything the scalar touches runs the same instructions and the same memory accesses whatever its value. Building that found the LLVM backend undoing it (§3), and added a builtin to the language to stop it (docs/value-barrier.md).


Updated (#380, docs/p256-fast.md): P-256 no longer runs on std.bigmod. public_key is a sum of 65 entries of a table of the generator, and shared is this document’s ladder on std.p256’s straight-line field; the checks, the codes and P-384 are as described here. §2’s reductions still serve P-384 and RSA. §5 is corrected below.

1. What is built

FileWhat
std/ecdh.cho, module std.ecdhpublic_key(curve, scalar, out, work) and shared(curve, scalar, peer, out, work) on P-256 and P-384
std/bigmod.chomul, add and sub now reduce without a branch (§2)
value_barrier, edition 6a value the optimiser cannot reason about (docs/value-barrier.md)
std/ecdsa.chocurve_param, so the curve constants are written once

The API.

  • public_key answers scalar × G as an uncompressed point, 04 || x || y. That is the key share a ClientHello sends.
  • shared answers the x coordinate of scalar × peer, which is TLS’s shared secret (RFC 8446 §7.4.2).
  • The scalar is the curve’s size in bytes, big-endian, in [1, n). A random string outside that range is refused (ecdh-scalar-range), so the caller draws again. The chance of that is under 2^-32 for P-256 and under 2^-190 for P-384.
  • work is work_len() words (77 KB), which is more than a region holds, so the caller allocates it with box_slice.

The peer’s point is checked before use, as SEC 1 §3.2.2.1 asks:

  • its encoding: exactly 04 || x || y, since TLS 1.3 sends points uncompressed (RFC 8446 §4.2.8.2);
  • both coordinates below p;
  • the curve equation.

Each failure has its own tag. P-256 and P-384 have cofactor 1, so a point that passes, multiplied by a scalar in [1, n), cannot give the point at infinity. ecdh-result-infinity is checked anyway, and no input reaches it.

2. Constant time

The scalar multiplication walks the scalar four bits at a time, highest first:

  • a table of 0 to 15 times the point, built first;
  • for each window, four doublings, then the addition of the table entry the window names.
  • The additions and doublings are complete: Renes, Costello and Batina’s formulas for a = -3 (eprint 2015/1060, algorithms 4 and 6), in projective coordinates. They are right for every input, the point at infinity and P + P included. So no branch handles a special case, and a window of zero adds the point at infinity like any other entry.
  • The entry is chosen by reading all sixteen, each ORed in under a mask that is all ones for the one wanted. No address depends on the window.

That is P-256’s 64 windows, or P-384’s 96, of identical work.

std.bigmod’s reductions were branches. Now they are masks:

  • mont_mul and add end with a subtraction of n when the value is at least n. That was if compare_n(...) >= 0, which branched on the value and stopped at the first limb that differed. Now ct_reduce computes the borrow of x − n over every limb, then subtracts n & mask.
  • sub added n back behind if under == 1. Now it adds n & mask, where the mask is made from the borrow.
  • subtract_n took its borrow from an if d < 0. Now it is the sign bit.

RSA and ECDSA verification run on the same code, and their tests pass unchanged (§6 has the cost).

What stays variable time, all of it on public values:

  • the checks on the peer’s point;
  • the test that the result is not at infinity;
  • the inversion’s exponent, which is p − 2;
  • the scalar’s range check, which compares every byte and branches only on the answer.

3. What the timing test found

scripts/ecdh_timing.py is scripts/gcm_timing.py’s dudect test over ecdh.shared (tests/programs/ecdh_timing.cho): a fixed scalar against random ones, under one peer point.

The first run failed. A scalar of 1 against random scalars gave |t| = 13.2 on P-256 and 9.5 on P-384. Scalar 1 ran 1.3% faster. A fixed random scalar against random scalars gave no signal. That is what a leak through the reductions looks like: with scalar 1 the accumulator stays at infinity, (0 : 1 : 0), whose zero coordinates rarely need a reduction.

Neither the source nor the branch audit’s first reading showed it. scripts/chacha20_branches.py listed a js in ct_reduce and a jns in select. Read in the disassembly, both test a mask the source only ever ANDs with:

  • ct_reduce: test %r9,%r9; js around the load of n’s limb. clang -O2 saw that under - 1 is 0 or -1, rewrote w[n + i] & m as a select, and unswitched the loop on it.
  • select: test %r14,%r14; jns, the same rewrite of w[from + j] & m. The window’s table index was a branch.

No spelling of the mask survives that, because LLVM can prove any of them is 0 or -1 (docs/value-barrier.md §2). So the masks now go through value_barrier, which the LLVM backend emits as an empty asm the optimiser cannot see through.

After it:

  • The audit: scripts/chacha20_branches.py over bigmod.{mont_mul, ct_reduce, add, sub} and ecdh.{select, add_points, double_point, multiply} finds every remaining conditional jump compares a loop counter or k. mont_mul’s one js tests the sign of k, the public limb count.
  • The timing test, 20,000 samples a test (max |t|):
TestLLVMCranelift
P-256, scalar 1 against random1.922.32
P-256, a fixed scalar against random2.491.37
P-384, scalar 1 against random3.0411.04
P-384, a fixed scalar against random2.701.75

One test still fails: Cranelift, P-384, scalar 1. A second run of 3,000 samples gave |t| = 6.2. Scalar 1 runs 0.3% faster there (100,000 cycles of 30.5 million), and a scalar under 2^32 shows the same direction more weakly (|t| = 2.8).

It is not a branch or a memory access:

  • the audit of the Cranelift object finds only loop counters;
  • valgrind --tool=callgrind counts 172,602,288 instructions for scalar 1 and for two random scalars, identical to the instruction;
  • every index is a loop counter or a table position read for all sixteen entries.

What is left depends on the operands’ values. With scalar 1 the accumulator’s X and Z stay zero for nearly the whole ladder, so most limbs multiplied and stored are zero. That is consistent with data-dependent power and so frequency (Hertzbleed, Wang et al., USENIX Security 2022): rdtsc counts at a constant rate, so a core that clocks higher on cheaper data finishes in fewer ticks. Cranelift’s code spills more and runs 2.8 times as long, which would show it more. This is consistent with the evidence, not proven: the instruction count rules out the software explanations, and nothing here measures power.

What it means for TLS. The client draws a fresh scalar for every connection (RFC 8446 §4.2.8) and uses it once. A statistical attack needs many timings of one scalar, and a peer gets one. It also needs inputs that keep the accumulator near zero, which a random scalar does not give. The default backend, LLVM, stays below 4.5 throughout. The result is reported, not excused: by this test’s own threshold, Cranelift’s P-384 ladder is not clean.

Corrected (#208, docs/tls-assurance.md §6.1): on the Xeon. On an Apple M4 Max, scalar 1 fails on both backends, LLVM’s P-256 at |t| = 16.19, and with Arm’s data-independent-timing bit set LLVM’s falls to 1.84 (P-256) and 2.15 (P-384). That is the CPU’s data-dependent timing, which neither backend asks the CPU to turn off.

4. Correctness

Every case runs through tests/programs/ecdh_driver.cho (crates/cancho/tests/conformance/ecdh.rs):

EvidenceCasesResult
Wycheproof ecdh_secp256r1_ecpoint_test.json355330 valid cases equal; 24 invalid points refused (9 off the curve, 7 out of range, 8 badly encoded); the one acceptable case, a compressed point, refused
Wycheproof ecdh_secp384r1_ecpoint_test.json790771 valid equal; 18 invalid refused; the compressed point refused
NIST CAVP KAS validity (static unified, Z only), P-256 and P-384, both backends6036 pass cases give the IUT’s public key and Z; each of 24 fail cases caught by the check its reason names
the scalar’s edges101 gives G, n − 1 gives −G, 0 and n refused, on both curves
every refusal the driver reaches9each with its own tag
std.bigmod.pow_mod at moduli of 30 to 330 bits, each a multiple of 30 (tests/vectors/bigmod_powers.txt), both backends220equal to Python’s pow: the one place the reduction’s carry limb is reached, which neither curve does. #381: 180 more at 64 to 576 bits, each a multiple of 64, for the 64-bit limbs (400 in all), where P-384 itself now is
scripts/ecdh_differential.py against pyca/cryptography (OpenSSL 4.0.1)1,000 key pairs a curve, 4,200 checks0 differences

Mutants: scripts/ecdh_mutants.py runs 16 mutants, and 16 are killed. #381: 17, all killed, on the 64-bit limbs; the select mutant re-anchored (it no longer applied after #397), P-384 points with a wrong first byte added to the evidence (P-256’s are now refused in std.p256_pt and never reached std.ecdh‘s check), and modulus_bits’ unsigned top limb added.

  • In ecdh.cho: the addition and doubling formulas, the windows, the table, the select, the point at infinity, the curve equation and the on-curve check, the scalar’s range, the public key’s y, and the point’s first byte.
  • In bigmod.cho: the reduction’s mask, its carry limb, and the add-back of a negative difference.

Two mutants first survived. The carry-limb mutant survived the ECDH evidence and RSA’s and ECDSA’s tests too, because no modulus in any of them is a multiple of 30 bits long. That is what bigmod_powers.txt is for. The first-byte mutant survived because the script’s evidence had no correctly sized point with a wrong first byte; the refusals were added to it.

5. Cost

One core of the Xeon in docs/chacha20.md §6, LLVM backend, the time of one shared:

P-256P-384
std.ecdh.shared2.6 ms6.4 ms
std.x25519 (docs/x25519.md §6), for comparison1.08 ms

Corrected (#380): P-256 is now 138 µs a shared and 35 µs a public_key on an M4 (755 and 768 µs before), 5.5 and 22 times faster; docs/p256-fast.md §9 has the machines and the loads. P-384 is as above. The timing test for the new code is docs/p256-fast.md §8.3.

TLS spends one public_key and one shared per handshake on the group the server picks. X25519 stays the share a ClientHello sends first, as OpenSSL’s does (docs/tls-parity.md §2), so P-256 and P-384 cost only when a server asks for them with a HelloRetryRequest. A generic Montgomery multiplication did not use the NIST primes’ special form, as docs/ecdsa.md §5.4 says; it was where the cost was, and for P-256 it is now used (docs/p256-fast.md §4.2).

6. What the change to std.bigmod cost

RSA and ECDSA verification use the same mul, add and sub, so the masked reductions cost them too. Measured with scripts/rsa_bench.py and scripts/ecdsa_bench.py on the LLVM backend, main before this PR against after:

Verificationbeforeafter
RSA-2048 PKCS#1 v1.5352 µs374 µs+6%
RSA-2048 PSS363 µs384 µs+6%
RSA-4096 PKCS#1 v1.51,443 µs1,415 µs−2%
RSA-4096 PSS1,532 µs1,414 µs−8%
ECDSA P-2561,541 µs1,705 µs+11%
ECDSA P-3844,303 µs4,633 µs+8%

RSA’s changes are within the run-to-run variation of about 10% that docs/rsa.md §5.5 reports. RSA’s time is in mont_mul’s inner loop, which did not change, so the extra pass of ct_reduce is small next to it. ECDSA does relatively more additions and subtractions, each now with a full pass, and pays about 10%. A TLS handshake verifies one or two signatures, so this is around 0.2 ms.

7. Not done here

  • std.x25519 and std.field25519 onto value_barrier. Their audit is clean today only because LLVM happens not to see through their masks (docs/value-barrier.md §4). Moving them is a change with its own timing run. Done (#316, review finding A-1), with that run: docs/value-barrier.md §4.
  • The scalar’s range check rejects instead of reducing. A TLS client can simply draw again.

value_barrier: a value the optimiser cannot see through

Status: built (#207, PR 3). Found while measuring std.ecdh’s timing (docs/ecdh.md §3): the LLVM backend turned two masked selections into branches on the secret they were written to hide. This is the builtin that stops it, and the one rule for constant-time code that comes with it.


1. What was found

Constant-time code selects without branching. Two places in std.ecdh do it the textbook way:

  • bigmod.ct_reduce subtracts n under a mask. It computes m = under - 1 from the borrow, which is 0 or -1, then subtracts w[n + i] & m from every limb.
  • ecdh.select reads all sixteen table entries. It ORs each one in under m = (i ^ idx) - 1 >> 63.

The source has no branch on m. The object clang -O2 makes from it has one:

27bb:  test   %r9,%r9          ; the borrow, before `- 1`
27be:  js     27d0             ; skip the load of n's limb
27c0:  mov    0x10(%rdi,%r11,8),%r12

LLVM knows a value built as x >> 63, or as a 0/1 minus one, can only be 0 or -1. InstCombine rewrites a & m as select(m != 0, a, 0), and loop unswitching then hoists that select out of the loop as a branch. ecdh.select became a test %r14,%r14; jns on whether i == idx: which table entry the secret window picked, as a branch.

The timing test saw it (docs/ecdh.md §3):

  • a scalar of 1 against random scalars gave |t| = 13 on P-256;
  • a scalar of 1 runs 1.3% faster, because its accumulator stays at infinity, so most reductions are not taken.

std.x25519’s cswap (docs/x25519.md §3) is written the same way and passed its audit. It passed only because LLVM happened not to see that its mask comes from one bit: nothing in the source guaranteed it.

2. Why the source cannot fix it

LLVM sees every cancho function: the backend emits one module and runs clang -O2 over it (docs/llvm-backend.md). Any arithmetic spelling of a mask (0 - bit, x >> 63, a multiply by a 0/1 value) is something InstCombine can prove is 0 or -1. So none survives. A value LLVM cannot reason about has to come from outside its reasoning: inline assembly, or a volatile access. The language has neither, so the fix is a builtin.

3. The builtin

edition 6;

value_barrier(x: int) -> [] int

What it means:

  • It answers x. It has no effect, no trap, and the row [].
  • What it promises is that the optimiser treats the answer as unknown: no range, no known bits, no relation to x.

How each backend emits it:

  • LLVM: call i64 asm "", "=r,0"(i64 %x), an empty assembly statement whose output register is tied to its input. BoringSSL’s value_barrier_w and Rust’s subtle crate do the same.
  • Cranelift: the identity. Cranelift does not turn a select or an and into a branch, so it has nothing to stop.

Edition 6, the latest. A new builtin is additive (docs/editions.md §5). value_barrier is a name a program could already declare, so it exists from the current edition on, as flush_out did in edition 5. std/bigmod.cho and std/ecdh.cho declare edition 6;.

Compile-time evaluation (docs/compile-time.md) declines it, as it declines every builtin it does not know. A call with a constant argument therefore stays a call.

4. The rule

A mask that hides a secret passes through value_barrier once, where it is made. With that:

  • InstCombine sees an and with an unknown value, which it leaves as an and;
  • nothing is left to unswitch.

The rule is enforced in two ways:

  • Each module’s audit (scripts/chacha20_branches.py) checks that no conditional jump other than a loop counter’s or a length’s remains in the module’s object.
  • Each module’s timing test checks that no class of inputs runs at a different speed.

std.x25519 and std.field25519 are left as they are. Their audit is clean today, and moving them onto the barrier is a change of its own, with its own timing run (docs/ecdh.md §6). Done after review finding A-1 (#316): both are edition 6, and cswap and cswap_at make their masks through value_barrier. After it, on LLVM: ctgrind 0 reports for X25519 (scripts/curve25519_ctgrind.sh), no conditional jump but traps in cswap, cswap_at, pack or scalarmult (scripts/chacha20_branches.py), and the timing test of docs/tls-assurance.md §6 at max |t| 2.13 (a fixed scalar) and 2.68 (a sparse one), 20,000 samples each on the Apple M4 of that section, with the machine loaded (load average 6.7).

5. Cost

The statement itself emits no instruction. It forbids a transformation, so the cost is what that transformation saved. In §1’s two functions, the branch skipped loading n’s limb, or a table entry’s, when the mask was 0. Now every limb is loaded every time, which is what constant time costs. docs/ecdh.md §6 measures it on RSA and ECDSA verification, which share ct_reduce.

The pure TLS backend in cancho-hooks: the design (#210)

Status: design (#210, PR 1), since built in cancho-hooks (§9). #210 makes packages/tls selectable in cancho-hooks, runs the whole https delivery suite on both backends, and measures. Reading both sides to write this found that two sentences of docs/tls-pure.md are false, so the work is bigger than “switch by dependency”. This document corrects them, says what has to be built, and lists the decisions taken (§7). Its claims are from reading the two code bases and from the trials named; where a later PR finds one false, that PR corrects it here, in place.


1. What #210 asks

After #208, which closed the bar for the stack as one:

  1. Selectable by dependency. “The packages/tls API is the same as the OpenSSL backend’s.”
  2. Every https delivery test of cancho-hooks passes with each backend (a valid chain, a wrong host, expired, self-signed, a slow handshake, a server that closes mid-handshake, 64 concurrent deliveries), and the outcomes are equal (delivered, or failed with which tag).
  3. Measured, side by side, on a quiet machine, with the command: handshake CPU per connection, handshakes per second per core, throughput over an established session, and memory per connection. If the pure backend is slower: by how much, and whether it matters at the delivery rates in hooks’ docs/design.md.
  4. The authority report of hooks with each backend. “The pure one must need no Ffi capability at all”, checked.
  5. The default stays OpenSSL until #209 (the independent review) is closed.

2. What reading both sides found

2.1 The two interfaces are not the same, and an adapter is needed

docs/tls-pure.md §2.1 says “one interface fits both backends with no adapter”, and a consumer “switches backend by dependency, not by code”. The two are:

hooks’ src/tls.cho (OpenSSL)packages/tls
Who owns a connection’s statethe caller: fields() = 9 integers in the attempt array at, from index bthe engine: tls.open(heap, slots) is one value for the process, and a connection is a slot number in it
Who does the socket I/Othe module: handshake, write, read and shutdown take the conns.Table and the Poller, and read and write the socketthe caller: feed takes bytes the socket gave, take gives bytes for it
What a call returnsdone/pending/failed for a step; -1 would block and -2 reset for readevent: want_read, want_write, established, closed, failed; would_block()
Why it faileddetail_of: an X509_V_ERR number, an OpenSSL error of 16 million or more, -1 (peer closed) or -1000 - errnofailure and refusal_tag: a tag (tls-peer-closed, x509-name-mismatch, …)
Capabilitytakes ffi: Ffi("libcrypto,libssl")takes none
Sessionssave_session, free_session, open(..., session)none (§2.3)
Buffersthe caller’s req slice: out_max() = 20,480 bytes for ciphertext waiting for the kernel, net_max() = 4,096 for readsthe slot’s own

So the pure backend needs a module in hooks with tls.cho’s public functions, implemented over packages/tls: it does what tls.cho’s private flush and feed do now (move bytes between the Conn and the engine), and maps event and failure onto done/pending/failed and detail_of. attempt.cho’s calls (open, handshake, write, read, shutdown, want, watching, resumed, detail_of, stage_of, drop) stay the same shape. It is a translation, not a rewrite, and docs/tls-pure.md §2.1’s sentence is corrected below.

2.2 One source cannot carry both backends’ capability rows

This is the finding that decides the build.

  • attempt.cho and hooks.cho thread ffi: &f Ffi("libcrypto,libssl") down to every call into tls. In the committed hooks, 5 functions of attempt.cho and 6 of hooks.cho carry an Ffi row: attempt.advance and its helpers, and in hooks.cho settle, conclude, sweep, delivery_turn, run and start_tls. run is 1,508 lines.
  • A function’s row is exact in both directions (docs/linearity-and-effects.md): it may not declare an effect it does not perform, and it must declare every one it does. So a function that takes an Ffi("libssl") and does not use it is not accepted as pure, and one that uses the engine instead has a different signature.
  • cancho cannot abstract over this. docs/effect-polymorphism.md is a documented no: a row is fixed at the declaration, and the language has no function values to be polymorphic over. docs/package-system.md has no feature flags and no linking options in [[bin]].

So the two builds cannot share attempt.cho and hooks.cho as they stand, and the pure one needs the engine passed where ffi goes. A pure Engine is one value for the process (tls.open(heap, 64) in main), threaded as a &! borrow beside atab and poller.

2.3 The pure client has no session resumption

docs/tls-pure.md §7.2 leaves it out, and docs/tls-parity.md’s table says “none, as now”. Hooks keeps one TLS session per endpoint, resumes it (about a third less CPU per delivery, docs/https.md), counts hooks_tls_handshakes_total{result="resumed"}, and has tests/sessions_test.py for it. #210’s gates do not list sessions.

2.4 “No Ffi capability at all” cannot be met by hooks as a whole

Hooks holds ffi for libc too: its pinned docs/authority.json lists libc:statx (the modes of the data directory, src/perm.cho) and libc:prctl, besides 32 symbols in libssl and libcrypto. So the property that can be checked, and the one that matters, is: in the pure build, no libssl and no libcrypto scope, none of those 32 symbols, and no ffi reachable from the TLS path. The libc entries stay, and equal the OpenSSL build’s. docs/tls-pure.md §9’s gate 2 (“shows no ffi(...) and no foreign symbols”) is corrected below.

2.5 Smaller differences

OpenSSL backendpure backendWhat it means for the tests
Trust storethe system’s default locations, SSL_CERT_FILE and SSL_CERT_DIR honoured; or exactly tls-ca-filethe caller reads a PEM bundle (tls.trust): the same tls-ca-file, or a bundle file read from a list of known paths and SSL_CERT_FILESSL_CERT_DIR (a directory of hashed certificates) cannot be honoured, and the loss is documented. A bundle of 128 roots takes 138,350 bytes of the engine’s 1,048,576 (packages/tls/tls.cho)
TLS 1.2the extended master secret optionalrequired (#207)a receiver without it fails on the pure backend only: one of the six differences on purpose of docs/tls-assurance.md §4.1
Message sizea Certificate over 100 KiB refusedover 64 KiB refusedthe same §4.1 list
Memory26 to 48 KiB a connection (docs/tls-nonblocking.md §8.3)about 179 KiB a slot, 11.2 MiB for 64 as built (docs/tls-pure.md §7.4), plus the rootsmeasured in §6, not estimated
Entropy, the clockthe library’s ownthe engine is seeded by the caller (32 bytes read from /dev/urandom through hooks’ Fs(""), as its SCRAM nonce is), and start takes now_unix_ms from hooks’ Clockthe adapter does both

The six differences on purpose (§4.1 of tls-assurance.md) are the only places the two backends may disagree on accept or refuse. A test that hits one of them is run on both, and its expected outcome is stated per backend.

3. The build

Packaging first. Hooks takes a package through the project file as a .cancho-vcs store pinned by git revision ([dependencies.server] for packages/http-server). packages/tls and packages/x509 have no such store. Tried here, on this machine’s compiler: cancho vcs publish --std --dir packages/x509 publishes its modules. packages/tls is refused until x509 is required by lock (--requires <lock>:<store>, docs/vcs-publish.md), which is the flow the compiler names. The exact sequence, and whether the store lays out as cancho-hooks’s [dependencies.*] expects, is the first step of the build PR, and a store committed to this repository is that PR’s first commit.

The two builds are two [[bin]] entries in hooks’ cancho.toml: hooks (OpenSSL, the default) and hooks-pure. Corrected (§9): two projects, not two bins. A project’s libraries are built into every program of it, so tls as a dependency collides with hooks’ own OpenSSL module, which is also called tls, and the pure build needs a newer compiler than the default’s pin. It is pure/cancho.toml, beside the unchanged cancho.toml.

How the pure build’s sources are made is the decision of §7. The recommendation is that src/tls.cho’s OpenSSL module and a new src/tls_pure.cho both declare module tls, each in its own source list, and the files of §2.2 are transformed mechanically for the pure build by one script, which is run by the build and is the only place the difference lives. The transform is:

  • ffi: &f Ffi("libcrypto,libssl") or Ffi("libssl") in a parameter list becomes engine: &!e tls.Engine, and ffi in a call becomes engine;
  • ffi("libcrypto") and ffi("libssl") leave a row;
  • the functions that make and free the context and the sessions (start_tls, close_tls, drop_session, keep_session) are replaced by the pure module’s own.

The pure build’s sources are not committed, so there is no copy to drift. The risk is the other one: a change to the shape of these functions in hooks.cho can defeat the transform. §5’s checks catch that at build time, not in production: the transformed source must compile, and the pure build’s authority report must have no libssl or libcrypto.

4. How a failure maps

attempt.cho‘s handshake_code turns detail_of into one of hooks’ reasons (cert_untrusted, cert_expired, cert_hostname, cert_invalid, tls_handshake, tls_timeout, tls_error). The pure module sets detail_of to the number the OpenSSL column of docs/tls-pure.md §8 gives for the refusal tag, so handshake_code is unchanged and a failed attempt’s history means the same under either backend. A tag with no OpenSSL number (x509-chain-too-large) is set to a value that handshake_code treats as tls_error, and the table says so. -1 (peer closed) is tls-peer-closed, and -1000 - errno a socket failure, as now.

5. The tests

Hooks’ harnesses start build/hooks and drive it from outside (docs/testing.md there). Each takes the binary as an argument, so each runs against build/hooks and build/hooks-pure unchanged.

  • On both backends, outcomes compared: tests/https_test.py, whose nine groups are #210’s list: a good chain, every bad certificate (expired, not yet valid, another name, another authority, self-signed, with and without tls-ca-file), a receiver that closes, speaks garbage, offers TLS 1.1 only or never answers, a 500 and a 410, a retried certificate, the trust store, kill -9 in the middle of a handshake, 64 held handshakes. It needs no database. The comparison is the table of reasons from /metrics (hooks_attempt_failures_total) and the receiver’s own record, per case, between the two binaries.
  • On both, beyond the nine groups: names_test.py (no database) and https_api_test.py (with one). Whether attempt_test.py and reason_test.py touch TLS is read in the build PR before they are listed.
  • On OpenSSL only, with the reason said: sessions_test.py (no resumption, §2.3).
  • Hooks’ unit tests (cancho test) and its mutants (tests/mutants/https.py: 49, 47 killed) are for the OpenSSL module. The new module gets its own list, with every survivor argued, as hooks does.
  • The authority check. scripts/check-authority.sh runs for each bin and pins a file each. The pure build’s file has no libssl: or libcrypto: line and no ffi label for those two, and docs/authority.json’s libc:statx and libc:prctl are unchanged. A test asserts it, so it is not a convention.
  • The environment: hooks’ tests are Linux only (LD_PRELOAD shims, gcc). Here they run in an Ubuntu image on this machine’s Linux VM, with libssl-dev and standardwebhooks added.

6. The measurements

Both builds on the same machine and day, one at a time, the machine otherwise idle (and saying what else was running if it was not, as docs/tls-assurance.md §6.1 does). scripts/bench/https_cost.py in hooks already measures the CPU of a delivery for an address, a name, https and https resumed; it is extended for the pure build.

WhatHow
handshake CPU per connectionhttps_cost.py: the service’s CPU per delivery, full handshakes only, both builds; OpenSSL resumed as an extra row
handshakes a second per corethe same, pinned to one core, saturated
throughput over an established sessionone connection, a large response, both builds. ChaCha20-Poly1305 and AES-GCM both, because the two backends may choose differently
memory per connectionthe service’s resident set with 0, 1 and 64 handshakes held, both builds (https_test.py already holds 64). The pure engine’s 64 slots exist from the start, so the difference at 0 is the finding

What is known now, and does not answer it. Hooks’ docs/https.md has an https delivery at about 1,000 µs of CPU for a full handshake (and about 700 µs resumed), on a shared 4-core x86-64 VM. The pure client’s X25519 scalar multiplication alone takes 0.55 ms on LLVM and 1.32 ms on Cranelift, on an Apple M4 Max (docs/tls-assurance.md §6.1), and a handshake also verifies a certificate chain and signs and verifies more. Those are different machines, so they are not compared, and a whole handshake has not been measured. The expectation is that the pure backend is several times slower per handshake. That is a hypothesis for the PR to confirm or correct, and what it means at hooks’ rates (about 970 https deliveries a second a core with full handshakes) is §1’s last bullet.

7. Decisions

Settled by a person (#210, PR 1 review), with the reasons. Each can be reopened by the evidence named.

  1. How the pure build’s sources are made (§2.2, §3): a mechanical transform, run by the build, the result not committed. The alternatives were to duplicate the 11 functions (a copy of run, 1,508 lines, would drift) or to refactor the loop first (a large change to a 5,357-line file other work is changing, before anything is measured). Three safeguards are part of the decision:

    • the transform fails unless it finds exactly the 11 functions it expects, so a changed hooks.cho cannot be half transformed without anyone noticing;
    • it preserves line numbers, so a compiler error in the generated source points at the real line;
    • the build checks the result: it compiles, and the pure build’s authority report has no libssl or libcrypto.

    Reopened if the transform proves too brittle to keep: the refactor is then worth its cost, with measurements behind it.

  2. Resumption: the pure backend does full handshakes only, and it is not part of #210 (§2.3). sessions_test.py runs on OpenSSL only. Resumption in packages/tls skips certificate verification on the resumed path, so it is a design of its own. The cost comparison is a full handshake against a full handshake, with OpenSSL’s resumed cost as an extra row so the loss is visible. Reopened if §6 shows the pure handshake several times slower: that is the evidence a resumption design needs.

  3. The trust store (§2.5): SSL_CERT_DIR is not honoured, and nothing falls back silently. The pure backend reads tls-ca-file if given, otherwise SSL_CERT_FILE, otherwise a bundle from a short list of standard paths (Corrected (§9): SSL_CERT_FILE is not honoured either. cancho reads no environment variable without a foreign call, so the pure build, which holds none for TLS, cannot. It reads tls-ca-file, else the first of four usual bundle paths, and a deployment that sets either variable names the file with tls-ca-file.) (/etc/ssl/certs/ca-certificates.crt on Debian and Ubuntu). If none loads it refuses to start, status 21, as an unreadable tls-ca-file already does, and that includes a deployment that sets only SSL_CERT_DIR: a trust store other than the one an operator configured is worse than an error. The build PR checks two numbers: the engine’s roots capacity (1,048,576 bytes) against a real bundle (138,350 bytes for 128 roots, measured on another machine), and that a bundle over the capacity is refused, not truncated.

  4. Where the two backends differ on purpose (§2.5), each such case states its expected outcome per backend and is a pass when it gets it.

  5. The authority gate is §2.4’s reading: no libssl and no libcrypto scope, none of the 32 symbols, the libc entries unchanged.

8. Not done here

  • Any code. The packages’ stores, the adapter, the transform, the second binary, the tests on both and the measurements are the PRs after this one.
  • Making the pure backend the default. That is #209’s, and a person’s.
  • Revocation, client certificates, IPv6: hooks has none today, and this changes none of them.

9. What was built, and what building it corrected

Built in cancho-hooks (alpibrusl/cancho-hooks#42: pure/, scripts/make_pure.py; its own docs/pure-tls.md is the record of the results). #283 published the two packages as stores first. What it found that §1 to §7 did not know:

  • The pure build is a project of its own, not a second [[bin]] (§3, corrected above). Found by trying: with tls among the one project’s dependencies, the default build failed on function open is defined twice.
  • The transform is a list of exact-match replacements, each with the number of places it must find, not patterns: 11 functions’ parameters and rows, the calls of the module, the creation and closing of the engine in main, the trust store. The engine takes the Ffi’s place in the same parameter position, so most call sites change in one word. It keeps line numbers (a compiler error in the generated source is at the line of the real one: it was how three problems were found), and fails, saying which change, if the source is not what the list expects.
  • The adapter needs two things src/tls.cho never had: the connection’s slot (the engine’s slots are numbered, OpenSSL’s state is the caller’s integers) and the time (certificates’ dates). attempt.advance takes the time; open and drop take the slot, which their callers already hold.
  • The events log holds the file-system capability, so main reaches the trust store through evlog.lend, not a borrow of its own.
  • cancho has no environment access (§7, decision 3, corrected).
  • A compiler bug, in the LLVM backend: any fs_read(...) used directly as an operand fails to generate code (it builds on Cranelift, and binding the result first works on both). Reported as its own task.

Results, the commands and the machine in hooks’ docs/pure-tls.md: the https tests, 97 checks on each build, 96 the same and 1 different on purpose (SSL_CERT_FILE); 17 mutants of the adapter, 14 killed and 3 argued; fifteen more harnesses with exit 0 on both; the authority report 34 foreign symbols to 2 (libc:prctl, libc:statx), none added; a full handshake costs 4 to 7 times the CPU of OpenSSL’s (about 340 https deliveries a second a core against 1,430 to 2,500, on that machine) and a connection holds about twice the memory (106 KiB against 58 KiB a held handshake). §6’s expectation, that the pure backend is several times slower per handshake, held. Not measured: RSA chains, and the latency the 2.8 ms handshakes add to other requests (the service is one thread).

TLS 1.3 session resumption for packages/tls: the design

Status: built (#286), its results in §11. Designed as below, then built; what building it found is §11, and the sections it corrected say so in place. Pools of tickets (§12) answer §9’s question 2 after hooks measured a burst.

The design, as first written: #210 measured the pure client’s full handshake at about 2.8 ms of CPU in cancho-hooks, 4 to 7 times OpenSSL’s, with no resumption (docs/tls-hooks.md §9). docs/tls-pure.md §7.2 left resumption out “until it is designed with its hazard: a resumed session skips verification”. This is that design. Its claims are measured on the machine named, or say they are arithmetic from measured parts; where a later PR finds one false, that PR corrects it here, in place.


1. Where the 2.8 ms goes

Each operation measured alone, on the machine of #210’s handshake measurement (a 6-vCPU Linux VM on an Apple M4 Max, LLVM backend, cancho 7c1bd08), with the existing benchmarks:

OperationCommandCostIn a full handshakeTotal
X25519scripts/curve25519_bench.py (200 rounds)0.569 mstwice: the client’s share, then the shared secret1.14 ms
ECDSA P-256 verificationscripts/ecdsa_bench.py0.817 mstwice: the CA’s signature on the leaf, and CertificateVerify1.63 ms
sum2.77 ms

hooks’ https_cost.py measured a full handshake at 2.9 ms per delivery with a floor of 0.03 to 0.12 ms without TLS (cancho-hooks docs/pure-tls.md). So these four operations are the handshake: everything else (hashing, the key schedule, the AEAD, parsing) is a few percent. That decides what resumption can save. A certificate chain with an intermediate adds one verification per certificate; an RSA chain costs differently and was not measured.

2. The options, and what each saves

The savings are arithmetic from §1, not measured end to end. §8 makes measuring them the build’s gate.

OptionWhat it removesHandshake after itForward secrecySize of the change
A. PSK with (EC)DHE (psk_dhe_ke, RFC 8446 §4.2.9)both ECDSA verifications: no Certificate, no CertificateVerifyabout 1.2 ms (2.3 times cheaper)kept: a fresh X25519 exchangethe protocol change below
B. PSK only (psk_ke)all four operationsabout 0.1 mslost for resumed connections: the PSK alone protects themthe same, minus the key share
C. Cache a verified chainthe CA signature on a leaf verified before (same bytes, same trust store)about 2.0 mskeptsmall, no protocol change
D. Keep the connection (in hooks)the whole handshake, for every delivery after the first on that connection0.01 ms a request (docs/tls-nonblocking.md §8.2)kepta change to hooks’ attempt table, deadlines and retry accounting, not to TLS

The recommendation is A. It keeps forward secrecy, which matters for webhooks: a payload is the customer’s data, and a ticket stolen later should not decrypt it. B saves more and is refused: a client that offers only psk_ke gives up forward secrecy for every resumed connection, and the extra 1.1 ms is not worth that. C is compatible with A and helps the connections A cannot (the first, and servers that issue no ticket), so it is §9’s question 3. D is the larger saving and belongs to hooks (docs/tls-nonblocking.md §10.4 already says “resumption first”).

What A gives hooks, by the same arithmetic: about 1.2 ms a resumed delivery, about 800 https deliveries a second a core, against about 340 now and 4,000 to 5,000 for OpenSSL resumed on the same machine. OpenSSL stays faster (its X25519 took 0.038 ms against this client’s 1.08 ms on the Xeon of docs/x25519.md §6), and resumption does not change that.

3. The hazard, and the rules that bound it

A resumed connection skips certificate verification. The server proves it holds the PSK instead, and the PSK came from a connection that was verified. So a ticket is a stored verdict, and every way that verdict could go stale is a rule:

  1. Bound to the name. A ticket is offered only for the exact host name it was received for (the SNI and the name checked against the certificate). Never for another name, whatever the address. (docs/tls-nonblocking.md §10.4 said this for OpenSSL.)
  2. Bound to the trust store. The engine counts its trust calls. A ticket records the count when it was stored, and is not offered after the trust store changes: a root removed must not stay trusted through a ticket.
  3. Bound to the certificate’s life. A ticket records the leaf’s notAfter, and is not offered after it, even if the server’s ticket_lifetime says it may be. A full handshake would refuse an expired leaf, and a resumption must not accept one.
  4. Bounded in time. The shortest of the server’s ticket_lifetime, RFC 8446’s 7-day ceiling (§4.6.1), and the caller’s own limit (§4, max_age_s), which defaults to 1 hour. As built, the caller’s limit is counted from the full handshake that verified the server, not from the ticket: a resumed connection inherits that time, so a chain of resumptions, each issuing a new ticket, never outlives the verification it started from (§11). Why 1 hour: a certificate revoked is still trusted by every ticket issued before; there is no revocation checking at all (docs/tls-pure.md), and the window should be short.
  5. Used once. A ticket is removed when it is offered. RFC 8446 Appendix C.4 says clients SHOULD NOT reuse a ticket, so a passive observer cannot link connections. A server that sends several tickets lets the client keep a few (§4).
  6. No early data. 0-RTT is not offered: its data can be replayed (RFC 8446 §8), and hooks’ requests are not idempotent on the receiver’s side.
  7. TLS 1.3 only. TLS 1.2 resumption (session ids, RFC 5077 tickets) is not offered. A TLS 1.2 connection is always a full handshake.
  8. Nothing about it is trusted from the server. The ServerHello’s pre_shared_key must name the one identity offered (index 0), the suite must hash with the PSK’s hash, psk_dhe_ke means a key_share must be there, and a server that does not resume is a full handshake with the full verification, never a failure.
  9. Bound to the client identity. Since #386 (docs/tls-parity.md §6.5): a ticket records the engine’s trust generation, which adding, replacing or removing a client identity raises as trust does, so a ticket saved before the identity changed is not offered after. A resumed connection sends no certificate; the server carried the client’s identity in the ticket.
  10. Bound to the client certificate’s life. Since #386: the ticket’s notAfter (rule 3) is the earlier of the server leaf’s and the client leaf’s, when a client certificate was sent.
  11. Not bound to the ALPN offer. Since #386 (docs/tls-parity.md §6.6): RFC 8446 ties a ticket to a protocol only for early data, which is not sent; a resumed handshake carries ALPN again and the client checks that choice against this connection’s offer.

4. The interface

hooks’ OpenSSL module already has the shape (save_session, open(..., session), free_session, one integer a session). The engine keeps the secrets, so the caller holds a handle, never a PSK:

tls.save(engine, slot) -> handle | 0        // after a connection: keep its newest ticket (0: none, or not resumable)
tls.start_with(engine, slot, host, now_unix_ms, handle) -> 0 | refusal   // offer the ticket if every rule of §3 allows; else a full handshake
tls.forget(engine, handle)                   // the endpoint changed, or the caller is done with it; overwrites the secret
tls.resumed(engine, slot) -> bool            // known once established
tls.set_ticket_max_age(engine, seconds)      // §3 rule 4; default 3,600

As built (§11), two more: tls.set_resumption(engine, on), without which no ClientHello says the client can resume, and tls.open_with_tickets(heap, slots, tickets), for a table of a size other than slots.

  • Storage is the engine’s, bounded: a ticket table of slots entries (64 for hooks), each up to 2,048 bytes of ticket plus the PSK and §3’s fields. A ticket over 2,048 bytes is not stored (the connection is fine; it is not resumable), so a server cannot make the engine grow. That is about 140 KiB for 64, beside the 11.2 MiB of slots (docs/tls-pure.md §7.4). The 2,048 is a guess at what real servers send; the build measures what each server of §5’s matrix sends and corrects it here.
  • A handle is a generation-tagged index, so a handle that was forgotten, or whose entry was reused, is refused (0), never someone else’s ticket. Changed by §12: a handle names a pool, a number the engine issues and never reuses, so a forgotten or spent handle still names nothing of anyone else’s.
  • start is unchanged. start_with with handle 0 is start.

5. The protocol work in packages/tls

  • NewSessionTicket is kept, not dropped (message.cho parses it and drops it today): ticket_lifetime, ticket_age_add, ticket_nonce, the ticket, and an early_data extension, which is ignored.
  • The resumption secret (RFC 8446 §7.1: resumption_master_secret, from the master secret and the transcript through the client’s Finished), and each ticket’s PSK, HKDF-Expand-Label(resumption_master_secret, "resumption", ticket_nonce, Hash.length).
  • The ClientHello, when resuming: psk_key_exchange_modes with psk_dhe_ke only; a key_share as now; pre_shared_key last, with one identity, its obfuscated_ticket_age (ticket_age_add added, mod 2^32), and the binder: an HMAC over the truncated ClientHello with the binder key (RFC 8446 §4.2.11.2).
  • The server’s choice: a ServerHello with pre_shared_key selecting 0 goes straight to EncryptedExtensions and Finished, no Certificate or CertificateVerify, with the PSK in the key schedule (early_secret from the PSK, not zeros). Without it, the full handshake, verified.
  • HelloRetryRequest with a PSK: the second ClientHello recomputes the binder over the new transcript (§4.2.11.2), and the ticket age is recomputed. Corrected (review finding E-6, #209): the age is not recomputed. The second ClientHello sends the obfuscated_ticket_age computed at start_psk again (client.cho, send_client_hello), since the engine is given no clock between the two flights. So it is understated by the HelloRetryRequest’s round trip, within the tolerance RFC 8446 §4.2.11.1 leaves to the server, as the first connection’s duration overstates it (§11). Only the binder is recomputed.

Refusals, each with its tag (CLAUDE.md), all tls- and all a failed connection:

  • the ServerHello selects an identity other than 0 (tls-illegal-psk, a new tag);
  • pre_shared_key in a ServerHello with a suite whose hash is not the PSK’s (tls-illegal-psk);
  • pre_shared_key in a ServerHello to a ClientHello that offered none (tls-unsupported-extension, alert 110, as RFC 8446 §4.2 says for any extension the client did not send. Corrected: this said tls-illegal-psk, and the code sent illegal_parameter, until the OpenSSL differential showed OpenSSL sending the RFC’s alert);
  • a resumed ServerHello without key_share (tls-key-share: psk_ke was not offered);
  • a Certificate or CertificateRequest after a resumed ServerHello (tls-unexpected-message);
  • a NewSessionTicket that does not parse is still tls-decode-error, as now.

6. In cancho-hooks

tlsx maps its save_session, free_session and open(..., session) onto §4, as src/tls.cho maps them onto OpenSSL’s. The generated build changes in one place (open’s session argument is kept, not removed), and tests/sessions_test.py runs on both builds. docs/pure-tls.md’s “differences” row for resumption goes, and the cost table gains a measured “resumed” row.

7. How it is tested

  • The key schedule against RFC 8448 §3 and §4. §3’s simple handshake issues a ticket, and §4 resumes with it. Both use TLS_AES_128_GCM_SHA256, which the client has since #207 (docs/tls-core.md §6.1 could not use them before for that reason). The resumption master secret, the PSK, the binder key and the binder are checked byte for byte. §4 is a 0-RTT resumption, and this client sends no early data, so its traffic is not replayed; its values are what is checked.
  • Interop: resumption against every server of docs/tls-assurance.md §5 that issues TLS 1.3 tickets: the row resumes, with no Certificate on the wire, and fetches the body.
  • Differential: the same resumption by openssl s_client -sess_out / -sess_in and by this client against the same server, and §3’s rules against scripts/tls_liar.py: a liar that selects identity 1, omits key_share, changes the suite’s hash, sends a Certificate after resuming, sends a 64 KiB ticket, a ticket with a lifetime over 7 days.
  • The rules, each a test that fails if broken: a ticket never offered to another name, after a trust call, after the leaf’s notAfter, after max_age_s, or twice.
  • Fuzzing: fuzz_flight (docs/tls-assurance.md §3) gains NewSessionTicket bodies, and a new harness resumes from a fixed ticket into a fuzzed server flight.
  • Mutants: at least 12 killed, every survivor argued.
  • The timing test is unchanged: no new secret-dependent arithmetic, but the binder’s HMAC compares nothing (the server checks it, not this client).

8. Gates

The build PR shows, each with its command:

  • §7’s tests, on both backends;
  • a resumed handshake’s CPU, measured, beside §2’s arithmetic (about 1.2 ms), with the machine;
  • hooks: sessions_test.py passing on both builds, https_both.py with the resumption difference gone, and the cost table’s resumed row;
  • docs/tls-pure.md §7.2 corrected (resumption is no longer left out), and the ticket table’s memory in §7.4.

9. Open questions, for a person

  1. max_age_s’s default. Proposed: 1 hour (§3 rule 4), because there is no revocation checking. A day would resume more often for an endpoint delivered to rarely.
  2. Several tickets per server. Proposed: keep only the newest. RFC 8446 Appendix C.4’s single use needs a new ticket per connection, and servers send one or two after each handshake, so the newest is always fresh for the next connection. Corrected, and decided: pools (§12). The premise holds for one connection at a time, not for several at once: hooks runs up to 8 deliveries to one endpoint together, they all start from the one saved ticket, and only the first may use it. A burst to one endpoint resumed 401 of 600 deliveries (§11).
  3. Option C (cache a verified chain) as well. Proposed: not now. It saves 0.8 ms on connections A cannot resume, and it is a cache of a security verdict with its own rules (§3’s rules 2 and 3 again). Decide after measuring how often A resumes.
  4. Resumption on by default in hooks’ pure build. Proposed: yes, as OpenSSL’s is (tls-resume, default on).

10. Not done here

  • Any code. After this: the protocol and the engine API (packages/tls), then hooks’ adapter and the measurement.
  • 0-RTT, TLS 1.2 resumption, psk_ke: refused by §3, not deferred.
  • Keeping connections open in hooks (option D): the bigger saving, and hooks’ design to make.
  • Making X25519 faster: the 1.14 ms that resumption keeps. That is arithmetic work in std.x25519 and std.field25519, and is measured there.

11. What building it found, and the results

Built:

  • packages/tls: the ClientHello’s psk_key_exchange_modes and pre_shared_key with its binder, the ServerHello’s pre_shared_key (tls-illegal-psk, new), a resumed handshake with no Certificate, the resumption master secret, and NewSessionTicket kept with its PSK (message.cho, client.cho, slot.cho).
  • The engine’s ticket table with generation-tagged handles and §3’s rules (tls.cho).
  • Tests: ten lying-server cases (scripts/tls_liar.py, now 77), twelve cases of the engine’s rules (scripts/tls_tickets.py, replayed by conformance/tls.rs through tests/programs/tls_tickets.cho), a resumption row per TLS 1.3 server in scripts/tls_interop.py, a ticket offered by fuzz_client on inputs of odd length, and mutants.

What it found, and corrected here:

  • A server need not send a ticket to a client that does not say it can resume. The first ClientHello, with no ticket yet, sent no psk_key_exchange_modes. OpenSSL, nginx, GnuTLS and wolfSSL sent tickets anyway, and resumed. Go’s and rustls’s servers sent none, as RFC 8446 §4.2.9 allows, so their resumption rows completed and resumed 0 of 8. Fixed with tls.set_resumption(engine, true): every ClientHello then advertises psk_dhe_ke. It is off by default, so a client that never saves a ticket claims nothing it does not do, and every recording from before (the eleven traces, the 66 lying-server connections, the 64 streams) replays byte for byte as it did.
  • A ticket must carry its own host name. The first version compared the stored ticket’s name against the slot’s copy of the host, which close_notify in both directions overwrites with the other secrets. Every ticket saved after a clean close then failed rule 1, and the engine fell back to a full handshake, silently. OpenSSL’s s_server -tlsextdebug showed no pre_shared_key on the wire. The name is now kept beside the ticket.
  • Rule 4 is counted from the verification (§3, corrected in place).
  • A ticket’s “received” time is the start of the connection that got it: the engine has no clock between start and save. So its age is overstated by the handshake’s duration, which servers tolerate (RFC 8446 §4.2.11.1 leaves the window to them). Corrected: that start was first kept in whole seconds, which overstated the age by up to another 999 ms. wolfSSL 5.6.6 refuses a ticket whose age is more than 1,000 ms above its own count (internal.c, “Allow +/- 1000 milliseconds on ticket age”; below it allows 11 s) and answers with a full handshake. With eight handshakes at once on a busy CI runner, its resume row once resumed 6 of 8. The start is now kept in milliseconds.
  • A lifetime over 7 days is capped, not refused (§7’s liar case keeps it for 604,800 seconds).

Results:

  • Interop (scripts/tls_interop.py, a Linux VM on an Apple M4 Max): the resumption row resumes 8 of 8 on Go crypto/tls, rustls, wolfSSL, nginx and GnuTLS, and tls_many resume resumes 4 of 4 against openssl s_server. BoringSSL’s server would not link on that VM (aarch64); CI runs its row on x86-64.

  • The binder and every secret are checked by servers that are not this code. OpenSSL refuses a resumption whose binder is wrong, and the lying server (Python on RFC 8446 alone) checks the binder, after a HelloRetryRequest too, and asserts that the PSK the client derived from each ticket is its own.

  • The rules: thirteen cases, each deciding from the ClientHello’s bytes alone whether the ticket was offered, including the obfuscated age. The thirteenth, added after wolfSSL’s resume row failed once in CI (above), saves a ticket 999 ms into a second and checks its age 60 s later is 60,000 ms. The code that kept whole seconds fails it with 60,999.

  • Mutants (scripts/tls_mutants.py, now 89): 89 killed, the 89th a ticket age counted from a whole second. Corrected: this said 87 killed and 1 equivalent (a pre_shared_key accepted when none was offered, refused by the hash check with the same tag). Since the unoffered case has its own alert (above), that mutant changes the tag and is killed. Writing them found two gaps, closed here: no case resumed under SHA-384 (now one does), and the other host name in the rule cases was a different length, so the length check alone refused it (now it is the same length). The runner now replays the rule cases for every mutant, and a mutant listed as equivalent must survive or the run fails.

  • Fuzzing: fuzz_client offers a fixed ticket on inputs of odd length, and the ticket parser is fuzz_messages’s case 6. 20 minutes each of fuzz_client, fuzz_messages and fuzz_flight from the committed corpus: 8,459,281 executions, no crash and no hang; fuzz_client reached 1,824 edges, against 1,767 before.

  • In cancho-hooks (its docs/pure-tls.md): a resumed delivery costs about 1.35 ms of the service’s CPU against 3.0 ms for a full one. Under a burst to one endpoint fewer deliveries resume than with OpenSSL (401 of 600, against all of them), because a ticket is used once (rule 5) and hooks keeps one per endpoint, where OpenSSL reuses one session for every concurrent connection. §9’s question 2 is where that is decided. Decided: pools, §12.

  • Cost, measured (the VM of §1; tls_many, 64 connections, against openssl s_server -tls1_3 -www, which chose TLS_AES_256_GCM_SHA384; the client’s CPU over one round, two rounds resumed, and two rounds against -num_tickets 0; median of 5):

    the client’s CPU a connection
    a full handshake3.26 ms
    a resumed one1.63 ms

    The saving is 1.63 ms, which is §1’s two ECDSA verifications (2 × 0.817 ms) to the hundredth. The rest of each connection is the same in both: X25519, and tls_many’s request of one full 16 KiB record and the page back, under software AES-GCM (docs/tls-parity.md §3.1). That is why the resumed connection costs 1.63 ms and not §2’s 1.2: §2 counted the handshake alone. A first run against -no_ticket gave a full handshake of 2.14 ms: OpenSSL’s TLS 1.3 server still resumed 43 of 64 through its session cache with tickets “off”, so that baseline was not full handshakes. -num_tickets 0 is.

12. Pools: several tickets for one server

The problem (§9’s question 2, §11’s burst). A handle names one ticket, and a ticket is used once (rule 5). A caller that keeps one handle a server, as hooks keeps one an endpoint, resumes one connection of a burst: the others find the handle spent and make full handshakes. Every connection of the burst gets a ticket of its own, but each new one replaces the last.

The design: a handle names a pool. The interface keeps its four calls and its integer:

tls.save_to(engine, slot, pool) -> pool | 0       // keep the slot's ticket in `pool` (0, or a handle never issued: a new pool)
tls.save(engine, slot) -> pool | 0                // save_to(engine, slot, 0), as before
tls.start_with(engine, slot, host, now_ms, pool)  // offer the newest ticket of the pool that §3's rules allow, used once
tls.forget(engine, pool)                          // every ticket of the pool, overwritten
tls.set_tickets_per_pool(engine, n)               // at most n tickets a pool; 1 unless set
  • A pool is a number the engine issues (1, 2, 3, …, never reused), and each entry of the table records its pool. A handle is no longer a generation-tagged index: a pool that was forgotten, or all of whose tickets were used or replaced, holds nothing, so start_with makes a full handshake, never with another pool’s ticket. save_to into such a pool fills it again, which is what a caller holding one handle a server wants.
  • start_with takes the newest ticket of the pool that passes §3’s rules, and overwrites it. A ticket of the pool that fails a rule is overwritten too, as a refused handle’s ticket was: none of the rules becomes true later except a clock that went back (rule 4’s now >= received), and a ticket from before a clock went back is not worth keeping.
  • A pool holds at most n tickets (set_tickets_per_pool): a save into a full pool overwrites its oldest. With n = 1, the default, a pool is today’s handle: the newest ticket replaces the one before. hooks sets 8, its cap on deliveries in flight to one endpoint (its docs/design.md), so a burst of 8 can find 8 tickets.
  • The table stays one table of tickets entries, shared by every pool: a save into a full table overwrites the entry the clock hand points at, as before. A pool cannot hold more than n, so one busy server takes at most n entries from the rest.
  • Nothing in §3 changes. Every rule is checked per ticket; a pool is only which tickets a handle may name. The host name (rule 1) is still checked against the ticket, so a caller that files two servers under one pool offers neither the other’s.
  • Cost. start_with and save_to walk the table (hooks: 1,024 entries of 12 integers), against a handshake’s 1.6 ms.

How it is tested. tests/programs/tls_tickets.cho gains S <pool> (save_to) and P <n> (set_tickets_per_pool), and scripts/tls_tickets.py cases for: a pool of three resuming three connections, newest first, then none; a pool over its size losing its oldest; forget emptying a pool; a pool’s spent ticket beside a good one; a pool refilled after it was emptied; and a handle never issued. The liar’s server issues each ticket with a different identity, so a case reads from the ClientHello which ticket was offered. Mutants for each of those decisions. In hooks: the burst of §11 again, against the 401 of 600.

A TLS 1.3 server for packages/tls: the design

Status: steps 1 to 5 built: the signer (docs/ecdsa-sign.md), the TLS 1.3 server (§10, as built), the example the broker and the gateway copy, examples/tls_echo (§11), session tickets (§12) and client certificates (§13); its open questions (§9) answered as proposed (2026-10-07). Not independently reviewed (#209). packages/tls is a client (docs/tls-pure.md). Two programs of the toolbox need the other side: cancho-mqtt, a broker whose clients connect on 8883, and cancho-gateway, a reverse proxy that terminates HTTPS. Both are at the design stage and both list TLS as out of scope because “it needs foreign code and would make the authority report unbounded”. A server in packages/tls removes that reason. This document is the design; its numbers are measured where it names a measurement, and are otherwise arithmetic from measured parts, marked so. Where a later PR finds a claim false, that PR corrects it here, in place.


1. What a server needs that the client does not have

Most of the client is reused as it stands: the record layer and its AEADs (tls_record: ChaCha20-Poly1305 and AES-GCM, on the hardware path since #334), the key schedule, the handshake message codec (tls_message), the transcript hash, the slots, the DRBG, and the key exchanges (std.x25519, std.ecdh on P-256 and P-384, both constant time). What is new:

the client todaya server needs
a signature with a secret keyverifies onlysigns CertificateVerify in every full handshake. No signing in std is constant time today: std.ecdsa verifies only, std.rsa verifies only, and std.ed25519.sign branches on secret data (docs/ed25519.md, “Not constant-time”). This is the prerequisite, §3. Built since: std.ecdsa_sign (docs/ecdsa-sign.md).
a private keynoneread from a PEM file, held in the engine, never readable back (§4)
the handshake state machineClientHello out, ServerHello inClientHello in, choose, ServerHello to Finished out, client Finished in (§5)
choicesthe server makes themsuite, group, HelloRetryRequest, certificate by SNI, ALPN (§5.2)
attacker input at scaleone server, chosen by the programany peer that can reach the port, many at once, each able to cost a signature (§7)

2. Scope

2.1 Version 1

  • TLS 1.3 only. A ClientHello without 0x0304 in supported_versions is refused with a protocol_version alert (tls-server-version). TLS 1.2 is §9’s question 1.
  • All three TLS 1.3 suites. The server picks, in its own order: on a machine where hw_aes_gcm() is true, TLS_AES_128_GCM_SHA256 first, then TLS_CHACHA20_POLY1305_SHA256, then TLS_AES_256_GCM_SHA384; without it, ChaCha20 first (the software AES-GCM is several times slower: docs/crypto-builtins.md). This is OpenSSL’s and Go’s rule.
  • Groups X25519, P-256, P-384, in that order of preference. The server uses the first of its groups the client sent a key share for; if none, but the client’s supported_groups names one, it sends one HelloRetryRequest for it (stateful: no cookie). A second ClientHello that still has no share for it is refused (tls-server-retry-share). Browsers send an X25519MLKEM768 share with an X25519 share; the server ignores the hybrid and takes the X25519 one.
  • One certificate type: ECDSA P-256 (ecdsa_secp256r1_sha256), signed in constant time (§3). It is what Let’s Encrypt and most ACME clients issue on request, and what every TLS 1.3 client must verify (RFC 8446 §9.1). P-384, Ed25519 and RSA-PSS keys are later (§8), each behind its own constant-time signer.
  • Certificates chosen by SNI: up to 16 identities (a chain and its key, with the names it serves), the first being the default for a ClientHello without server_name or with a name no identity has. An identity can be replaced while the engine runs (a renewed certificate): connections already started keep the old one.
  • ALPN: the program gives a list (http/1.1 for the gateway, mqtt for the broker if it wants one). A client that offers ALPN with no protocol in the list is refused with no_application_protocol (RFC 7301 §3.2, tls-server-alpn); a client that offers none is accepted and the program sees none.
  • Middlebox compatibility (RFC 8446 Appendix D.4): the legacy session id is echoed and a change_cipher_spec record is sent after the ServerHello, as OpenSSL does, so a client in compatibility mode is not surprised. As built: once, after the first ServerHello or HelloRetryRequest the server sends (so after a HelloRetryRequest, not again before the ServerHello), and whatever the session id; every TLS 1.3 client must drop one (RFC 8446 §5).

2.2 Not in version 1, and why

why not yetwhere
client certificates (mutual TLS)MQTT deployments use them, so it is wanted; it is a second verification path (x509_verify without a host name) and an authorization interfacestep 4 (§8), §9’s question 2; designed in §13
session tickets (resumption)built: saves the signature, which is most of the server’s cost (§6); stateless tickets with a ticket key and its rotationstep 5 (§8), §12
0-RTT early datareplayable by design; no program here needs itnever, unless a design argues for it
TLS 1.2a second server state machine; who needs it is §9’s question 1step 6, if answered yes
renegotiation, compression, PSK without (EC)DHE, post-handshake authenticationnot in TLS 1.3, or not needednever
OCSP stapling, certificate transparencythe server would send what it is given; nothing reads it hereif a program asks

3. The prerequisite: ECDSA P-256 signing in constant time

3.1 The pieces already constant time

std.ecdh multiplies a point by a secret scalar in constant time (docs/ecdh.md §2): a four-bit window over complete formulas, the table read under masks, std.bigmod’s register arithmetic with masked reductions, and a timing test that found and fixed a leak. std.bigmod.inverse is Fermat’s a^(n-2): it branches on the exponent’s bits, and n - 2 is public, so it is constant time in a. ECDSA signing is built from these:

k      = the nonce (§3.2), 1 <= k < n
(x, _) = k·G                              std.ecdh's scalar multiplication
r      = x mod n;  r == 0 -> next k       public once the signature is sent
s      = k^-1 · (e + r·d) mod n           bigmod: inverse (public exponent), mul, add; d and k secret
s == 0 -> next k

The new code is a fixed-base multiplication (the existing variable-base one, given G; a fixed-base table is a later optimisation) and a few register operations mod n. What stays variable time is on public values only: the r == 0 and s == 0 tests (probability about 2^-256), and the encoding of the signature.

Corrected (step 1): “a few register operations” left one out. bigmod.load_reg, the only way into a register, skips zero bytes and compares with n limb by limb, stopping at the first that differs: a branch on a private key or a nonce. ECDH never loads its scalar into a register, so nothing had needed more. bigmod.load_secret places bytes by position only (docs/ecdsa-sign.md §2.2).

3.2 The nonce

A biased or repeated nonce gives the key away. The nonce is RFC 6979’s deterministic nonce with added randomness (§3.6 there): HMAC-DRBG over SHA-256, seeded with the key, the message hash and 32 bytes from the engine’s DRBG. Deterministic so a bad DRBG cannot repeat a nonce for two messages; hedged so that a fault injected into one signature does not recur for the same message. RFC 6979 Appendix A.2.5’s P-256 vectors test the deterministic half, with the added randomness empty.

3.3 After signing

The server verifies its own signature with std.ecdsa.verify before sending it (about 0.8 ms, docs/tls-resumption.md §1), and refuses the handshake with tls-server-sign-check if it fails. A fault during signing then sends nothing that would help recover the key. §9’s question 3 asks whether that cost stays.

3.4 How it is tested

  • RFC 6979 A.2.5 vectors, and 10,000 signatures from random keys and messages, each verified by OpenSSL (openssl dgst -verify) and by std.ecdsa; each with a bit flipped, refused by both.
  • A dudect timing test, scripts/ecdsa_sign_timing.py, the shape of scripts/ecdh_timing.py: a fixed key against random keys, and a key of 1 against random ones (the case that found std.ecdh’s leak). The gate is |t| below 4.5 at 10^6 measurements, as there.
  • The value-barrier audit (docs/value-barrier.md) over the new functions.
  • Mutants over the new code (scripts/mutate.py), every one killed or argued equivalent.

Corrected (step 1): there is no scripts/mutate.py; each crypto module has its own mutants script, and the signer’s is scripts/ecdsa_sign_mutants.py. OpenSSL verifying a signature does not show that its nonce is RFC 6979’s, so the 10,000 signatures are also compared byte for byte with RFC 6979 written in Python (docs/ecdsa-sign.md §5.2). Results: docs/ecdsa-sign.md §5 and §6.

3.5 The check against the identity’s table (B2 of #378, #380)

Added after #397, when the check of §3.3 had become a quarter of a full handshake (docs/tls-performance.md §10.4): the design is docs/p256-fast.md §11, measured before it was chosen. The key that verifies every signature the server makes is always the same, its identity’s, so each identity keeps a table of its public point, built once, and the check verifies against it: 2.03 times cheaper (687k to 339k cycles on gram), the full server handshake 24% fewer instructions and 1.85 to 1.46 times s_server’s cycles (docs/p256-fast.md §11.4, §11.5).

  • Where: in the identity’s stride of the configuration (tls_identity, the engine’s ids), after the leaf’s notAfter: o_table, p256_vf.table_len() = 10,305 bytes. One per identity, engine-wide; no slot holds one. The configuration grows from 314,106 to 478,986 bytes of address space for its 16 identities; an identity never loaded is never written, so it is not resident.
  • When: tls_identity.load, the body of add_identity and replace_identity, builds it from the public point the key parser derived (p256_vf.build_table), once every check has passed and while the identity is written. 151 to 159 µs on the M4. A replacement builds the new key’s; a refused one changes nothing, the table included. A build that failed (it cannot, for a point the parser derived) would leave zeros, which match no point, and the check would verify without a table.
  • What it is: public data, the multiples of the public point; the private key is not read to build it. The check is the same verification of the same signature as §3.3, on public data, with the same answers: sign_checked is given the table in the point’s place (it begins with the point, docs/p256-fast.md §11.2), by tls_identity.check_key, which gives the point instead when the table is not the point’s.
  • Cleanup: tls.close wipes the whole configuration (tls_identity.wipe), tables included. tls.residue counts the bytes of the tables of identities not in use, which nothing may write.

4. Keys and certificates

  • Formats. A chain as PEM certificates, leaf first. A key as PEM PRIVATE KEY (PKCS#8, unencrypted) or EC PRIVATE KEY (SEC 1), P-256 only; anything else is refused with its tag (tls-server-key-type, tls-server-key-format). The engine checks that the key is the leaf’s (its public point equals the certificate’s) before it accepts the identity (tls-server-key-mismatch).
  • The engine never reads files. The program reads them (it holds the capability), and gives the bytes; so the authority report shows the reads where the program makes them, and the engine stays a pure package.
  • Held, not shown. There is no function that returns the key. Replacing or removing an identity overwrites its key, best effort, as tls.drop does with session keys (docs/tls-pure.md §7.3).
  • The chain is not verified by the server. It is sent as given; a chain that clients refuse is the operator’s to fix. The engine checks only that the leaf parses, that its key is P-256, and that it is not expired at the time the identity is added (tls-server-cert-expired), so a stale file fails at start, not at the first client.
  • As built, the room (packages/tls/identity.cho): a chain of at most 16 KiB as Certificate sends it (each certificate’s DER and 5 bytes), names of at most 1 KiB, an ALPN list of at most 512 bytes; over them, tls-server-chain, tls-server-names and tls-server-alpn-list. A chain block that does not decode, or no certificate at all, is tls-server-chain too. 16 identities take 274 KiB of the engine, allocated by open_server only.

5. The interface and the protocol

5.1 The interface

The same shape as the client: slots, bytes in and bytes out, no socket and no capability inside.

srv = tls.open_server(heap, slots)         // an Engine whose slots are server slots
tls.seed(srv, entropy)                     // 32 bytes, as for the client
id  = tls.add_identity(srv, chain_pem, key_pem, names, now_unix_ms)   // names: "a.example b.example *.example"
tls.replace_identity(srv, id, chain_pem, key_pem, now_unix_ms)
tls.set_alpn(srv, "http/1.1")              // a list, in the server's order of preference
tls.serve(srv, slot, now_unix_ms)          // a new connection in `slot` (*as built: not `accept`, below*)
... tls.feed / take / send / recv / event / finish / eof / drop, as for a client ...
tls.server_name(srv, slot, out)            // the SNI the client sent, once established
tls.alpn(srv, slot, out)                   // the protocol chosen

A separate Server type was considered, with the shared calls duplicated; one Engine with a role per slot keeps one set of byte-moving calls and the existing tests of them. An engine is all clients or all servers: accept on a client engine and start on a server engine are refused (tls-role).

Corrected (step 2): accept is a builtin’s name (tcp_accept’s family: accept itself, edition 2), and a function may not take one, so the call is tls.serve. tls-role also refuses trust on a server engine and add_identity, replace_identity, set_alpn, server_name and alpn on a client one. server_name and alpn answer once the ClientHello is answered, not only once established. Added, for a program’s log and the interop matrix: tls.suite, tls.group and tls.retried (what the connection negotiated), and tls.alert_received (the alert the peer sent, when failure is tls-alert), on either role.

5.2 The handshake

  1. ClientHello, reassembled up to 16 KiB (an ML-KEM-768 share alone is 1,184 bytes, so a browser’s ClientHello is a few KiB; step 2 records the largest of the interop clients; measured: Chromium’s, 1,818 bytes with its X25519MLKEM768 share; of the command-line clients curl’s, 512; OpenSSL and mosquitto 308, wolfSSL 395, Go 240, §10.4), parsed with the bounds tls_message already applies to server messages, plus the rules the server owns: legacy_version 0x0303 (corrected: RFC 8446 §4.2.1 says a server MUST NOT negotiate with legacy_version once supported_versions is there, and openssl s_server takes 0x0301; it is not read); 0x0304 in supported_versions; compression null only; signature_algorithms including ecdsa_secp256r1_sha256; no extension twice; pre_shared_key last if present (and ignored in version 1, with psk_key_exchange_modes); early_data ignored, so its data is skipped as RFC 8446 §4.2.10 allows for a server that rejects it, up to 16 KiB, then refused. Each refusal is an alert and a tag (§5.4).

    As built, the rules the list left out: signature_algorithms, supported_groups and key_share all present (tls-server-missing-extension, RFC 8446 §9.2); a share of this server’s groups at its group’s length, at most one a group, and only for a group in supported_groups, and one host_name in server_name (tls-server-illegal-parameter, which also takes a compression method other than null and pre_shared_key not last); an ALPN list of non-empty names. A share of a group the server does not have (an X25519MLKEM768 hybrid, GREASE) is skipped, as is any extension it does not use. A server_name over 255 bytes is kept as none, so the default identity answers it. The rules about what is offered are checked after the whole message is read, in a fixed order: version, compression, a missing extension, a share outside supported_groups, suite, signature scheme, group. The second ClientHello, after a HelloRetryRequest, must keep the session id, still offer the suite the retry named, send a share of the group it named, and not offer early data (tls-server-retry-share; RFC 8446 §4.1.2 lets a server check this). Records: one change_cipher_spec of 01 is dropped after the first ClientHello and before the client’s Finished (Appendix D.4), and any other is tls-unexpected-message; a ClientHello, a Finished or a KeyUpdate must end its record (§5.1), checked before the message is handled, so nothing is sent for one that does not; a plaintext alert is read after the server’s flight, since a client that refuses the flight before it has the handshake key alerts in the clear. Early data is skipped as RFC 8446 §4.2.10 says for a server that rejects it: after the flight, every record that does not open under the client’s handshake key is dropped until one opens, and before a second ClientHello every application_data record is; at most 16 KiB of ciphertext in all, then tls-server-early-data-size.

  2. Choose: suite, group (or HelloRetryRequest, then back to 1 once), identity by SNI, ALPN.

  3. ServerHello, change_cipher_spec, then under the handshake keys EncryptedExtensions (ALPN, server_name empty if it was used), Certificate, CertificateVerify (§3), Finished, all in one flight from take.

  4. Client Finished checked in constant time against the expected MAC; then the application keys, and event says established.

The handshake needs one ECDHE key pair and one shared secret on the chosen group, and one signature.

5.3 Limits

A slot holds one connection. A program that accepts more connections than slots holds them unaccepted or closes them; the engine does not queue. The ClientHello must arrive within the program’s own deadline; the engine has no clock of its own beyond the now_unix_ms it is given.

5.4 Refusal tags

Each refusal sends the alert RFC 8446 names and has a tag beginning tls-server-: version, suite, group, retry-share, sigalg, alpn, client-hello-format, client-hello-length, extension-repeat, early-data-size, finished, sign-check, key-type, key-format, key-mismatch, cert-expired, identities-full, and tls-role. The existing record-layer tags (tls-record-*) apply unchanged.

As built (packages/tls/record.cho, slot.cho): six more, for refusals the list had no tag for, and the alerts. The “record-layer tags” are the client’s (tls-unexpected-message, tls-record-overflow, tls-bad-record-mac, tls-decode-error, tls-protocol-version, tls-key-share, tls-too-many-messages, tls-alert, tls-peer-closed); there is no tls-record-*.

tagalertwhen
tls-server-versionprotocol_version (70)no supported_versions, or one without 0x0304, or no extensions at all
tls-server-suitehandshake_failure (40)no TLS 1.3 suite
tls-server-grouphandshake_failure (40)no share and no supported group of X25519, P-256 or P-384
tls-server-sigalghandshake_failure (40)no ecdsa_secp256r1_sha256
tls-server-retry-shareillegal_parameter (47)the second ClientHello changed what it may not, or has no share of the group asked for
tls-server-extension-repeatillegal_parameter (47)an extension twice
tls-server-illegal-parameterillegal_parameter (47)added: a value RFC 8446 forbids (§5.2 above)
tls-server-missing-extensionmissing_extension (109)added: no signature_algorithms, supported_groups or key_share
tls-server-alpnno_application_protocol (120)ALPN offered, none in common
tls-server-client-hello-formatdecode_error (50)a ClientHello that does not parse
tls-server-client-hello-lengthdecode_error (50)a ClientHello over 16 KiB
tls-server-early-data-sizeunexpected_message (10)over 16 KiB of early data
tls-server-finisheddecrypt_error (51)the client’s Finished
tls-server-sign-checkinternal_error (80)the signature did not verify before it was sent
tls-server-key-type, -key-format, -key-mismatch, -cert-expired, -identities-fullnone: add_identity’s answer§4
tls-server-chain, -namesnone: add_identity’s answeradded: §4, as built
tls-server-alpn-listnone: set_alpn’s answeradded: a name over 255 bytes, or a list over 512
tls-server-no-identitynone: serve’s or replace_identity’s answeradded: no identity added, or none of that number
tls-rolenonea call for the other role (§5.1)
tls-server-binderdecrypt_error (51)added (§12.5): a ticket this server made, passing every rule, whose PSK binder does not match
tls-server-ticket-confignone: set_tickets’ answeradded (§12.5): a count over 8 or a lifetime outside one second to seven days
tls-server-ticket-keynone: set_ticket_keys’ answeradded (§12.5): no key, a length that is not a whole number of 32-byte keys, or more than 4
tls-poolnone: serve’s answeran engine opened with open_server_with_areas had every work area leased, so the connection is not started (the program keeps it queued) (docs/tls-memory.md §7.3)

6. Cost

Corrected (#380, docs/p256-fast.md §9): the signature is 59 µs on the M4 and the check of §3.3 104 µs; the 1.5 ms and 0.82 ms below are main before that change.

The signature is measured (step 1, docs/ecdsa-sign.md §7); the total is arithmetic from measured parts, and step 2 measures it. One full handshake on the server, P-256 certificate:

operationcostsource
X25519 key pair and shared secret1.14 msdocs/tls-resumption.md §1 (6-vCPU Linux VM on an M4 Max)
ECDSA P-256 sign (std.ecdsa_sign.sign)1.5 ms on an Intel i7-1260P, 0.82 ms on an Apple M4 Maxmeasured, LLVM backend, docs/ecdsa-sign.md §7
verifying the signature (§3.3)1.5 ms on the i7-1260P, 0.80 ms on the M4 Maxmeasured: sign_checked less sign, same section
totalabout 4 ms of CPU on the i7, about 2.8 ms on the M4the X25519 row is the M4 VM’s; the i7’s X25519 is not measured

Corrected (step 1): the estimate was “about 1.3 to 2.6 ms” to sign and 3 to 5 ms in all. Measured, the signature is at the bottom of that range, and the check of §3.3 costs as much as the signature on the i7 (not “about a fifth of the cost” as §9’s question 3 has it; on the M4 it is about 0.8 ms, as estimated). OpenSSL 3.0.13 signs in 23 µs on the same i7 (openssl speed ecdsap256, 44,132 a second), 65 times faster.

Resumed handshakes (step 5, §12.9): 1.52 ms against 3.15 ms of examples/tls_echo on the M4, and about a third to a half of a full handshake on an x86-64 box too loaded to give an absolute figure.

Measured (step 2), in place of the estimate. python3 scripts/tls_server_cost.py <tls_serve> 20: the server (tests/programs/tls_serve.cho, LLVM backend, echo mode, one P-256 identity) under openssl s_time -new, full handshakes one after another for 20 seconds a row, the server process’s CPU (user and system, /proc/<pid>/stat) divided by the handshakes s_time completed. The machine: Ubuntu 24.04, linux-aarch64, in Docker’s 6-vCPU VM on the Apple M4 Max of docs/tls-assurance.md §6.1, the host busy with other builds (load average 4 to 5 of 6). Suite AES-128-GCM (the server’s choice: the CPU has AES instructions). Two runs:

client’s sharems of server CPU a handshakehandshakes a second a core
X255193.86, 4.06259, 246
P-2563.44, 3.55290, 282
P-3846.09, 6.06164, 165
P-521, then a HelloRetryRequest to P-2563.65, 3.47274, 288
openssl s_server 3.0.13, X25519, the same client0.204,975

So about 3.5 to 4 ms a handshake on X25519 or P-256, 6 ms on P-384: inside the design’s 3 to 5 ms for the first two, and above step 1’s arithmetic for the M4 (2.8 ms), since the measured figure is the whole process (the socket, the poller, the transcript, HKDF and the records) and not the three operations alone. A HelloRetryRequest costs nothing measurable (one more message hashed). OpenSSL’s server is 17 to 20 times cheaper, at the bottom of the “10 to 50 times” said here. Not measured: x86-64 (the i7 of docs/ecdsa-sign.md would be slower: its signature and check are 3 ms against the M4’s 1.6), and the Cranelift backend.

Measured later, x86-64 and with the profile inside (tls-performance.md §3.3): tls_echo on an i7-1260P costs 4.51 ms of CPU a handshake in a quiet minute and 16.5M cycles (OpenSSL’s s_server 1.09M, 15 times less); the two X25519 operations, the signature and the check are 89% of it, and malloc/free/brk of the 64 KiB region chunks 5.7%.

Corrected (step 3, §11.4): about 0.9 ms of the X25519 row is tls_serve’s own loop, not the handshake. On the same machine, in one session, alternating, examples/tls_echo costs 3.0 ms a handshake where tls_serve costs 3.8 to 3.9 ms, with the same engine, client and identity. Where tls_serve spends the difference was not investigated; the engine’s share is at most the 3.0 ms.

The paragraph that follows was the estimate’s: about 250 full handshakes a second a core on the i7, by the arithmetic above. OpenSSL’s P-256 signing is tens of microseconds with its NIST-prime arithmetic; this server’s handshake is in the order of 10 to 50 times OpenSSL’s. For the two programs that is acceptable: an MQTT client and an HTTP keep-alive connection handshake once and then stay. What changes it is in §8: session tickets (no signature on a resumed connection), a fixed-base table for k·G, and arithmetic specialised to the NIST primes (docs/ecdsa.md §5.4). Memory: a server slot is the client slot (about 179 KiB, docs/tls-pure.md §7.4; 263 KiB, and 119 KiB resident for an established connection, until #383: docs/tls-memory.md) plus nothing significant; each identity holds its chain (a few KiB) and key in the engine. As built: five words a slot; 274 KiB of identities and 75 KiB of key-parsing work in a server engine (§10.1).

With a client certificate (§13.17, measured). A handshake that verifies a client’s chain and its CertificateVerify retires 50.6 million more user-space instructions with a P-256 key under the CA than the 75.6 million of a handshake with none (x86-64, perf stat), and 25.1 million more for each intermediate; on the M4 it costs 1.7 ms more (3.23 to 4.94 ms), and 2.5 ms more with an intermediate. P-384 (+3.2 ms on the M4), RSA-2048 (+1.0) and Ed25519 (+4.2) cost differently. A program that bounds handshakes by rate (§11.2) sets the rate from this figure when it asks for certificates.

7. Threat model, the server’s side

docs/tls-pure.md §4 holds for the record layer. What a server adds:

  • CPU exhaustion. A client can make the server sign by sending one ClientHello, and abandon the connection. At about 4 ms a handshake, 250 ClientHellos a second take a core. The engine cannot bound this: it does not see the network. The program does, and the design asks each to bound handshakes started per second and in progress, with a counter the engine keeps (tls.handshakes_in_progress). The broker and the gateway design their limits with it. As built (step 3, §11.2): examples/tls_echo bounds both, delays the excess rather than refusing it, and gives a handshake a deadline; the whole server process measured 3.0 ms a handshake on the M4 (about 330 ClientHellos a second take a core, not 250) and 5.5 ms on CI’s x86-64 (about 180).
  • Memory exhaustion. Bounded by the slots: a ClientHello cannot grow a slot past its 16 KiB reassembly limit.
  • Timing of the key. §3. The record layer and the key exchanges are constant time already.
  • Choosing the identity. SNI is attacker-chosen: a name with no identity gets the default, never an error that tells names apart, and the name is compared as bytes after lower-casing ASCII, as packages/x509/names.cho does for the client.
  • Parsing. The ClientHello is now the input every peer controls. It goes to the fuzz harness (scripts/tls_fuzz.py) and the AFL setup (scripts/fuzz_afl.py) with a corpus of real ClientHellos (OpenSSL, Go, curl, Firefox, Chrome, mosquitto), and the gate is no panic and no trap. As built (§10.5): scripts/tls_fuzz.py --server mutates the lying client’s honest connections; AFL++ runs two harnesses, fuzz_hello (the parser) and fuzz_server (the engine from serve), from the ClientHellos of OpenSSL, curl, Go, wolfSSL, mosquitto and Chromium. Firefox’s is not in it: no Firefox could be run here.

8. Steps, each its own PR with its gates

  1. std.ecdsa.sign on P-256 (§3; built as std.ecdsa_sign, since std.ecdh imports std.ecdsa and the signer needs std.ecdh’s ladder: docs/ecdsa-sign.md §1), the key parsers (§4), with the vectors, the 10,000 OpenSSL cross-checks, the timing test, the audit and the mutants. Gate: all pass; the timing test’s |t| below 4.5.
  2. The TLS 1.3 server (§5), with:
    • interop: openssl s_client, curl, Go crypto/tls, wolfSSL’s client, and mosquitto’s clients (mosquitto_pub over 8883, against a cancho echo program), each across the three suites and three groups, with and without HelloRetryRequest;
    • a lying client: scripts/tls_liar_client.py, the shape of scripts/tls_liar.py, one case per refusal tag and per rule of §5.2;
    • a differential: the same malformed ClientHellos to openssl s_server, the alerts compared (an EXPECTED table for the cases where the RFC allows either);
    • the fuzz corpus of §7, mutants over the new code, and the cost of §6 measured. Gate: every case passes; the cost measured and written here.
  3. An example that the two programs can copy: examples/tls_echo (a server over std.conns and a poller), and a section in docs/http-server.md on serving packages/http-server over TLS. Built (§11): the example, its tests and its cost; http.server could not take bytes that did not come from its own sockets; corrected: it can now (docs/http-server.md §11, a byte-fed mode), and examples/https_hello is the HTTPS server built on it.
  4. Client certificates: CertificateRequest, the client’s chain verified against a configured trust store with no host name, the verified subject and SANs given to the program. §9’s question 2 decides whether it moves before step 3. Built (§13), after step 5, on the tickets’ head.
  5. Session tickets: stateless, sealed with a ticket key from the DRBG, rotated, psk_dhe_ke only (a fresh key exchange every time, so forward secrecy stays), never 0-RTT. Saves the signature and its check: about 2 to 3.5 ms of the 3 to 5. Built (§12, #379): the design is §12.1 to §12.10, what was built and measured §12.11 to §12.15.
  6. TLS 1.2, ECDHE-ECDSA with AEAD suites and the extended master secret required, if §9’s question 1 says so.
  7. More key types, each with its own constant-time signer and timing test: P-384, Ed25519 (on std.field25519, which is constant time, replacing std.ed25519’s big-number signing), RSA-PSS (needs blinding; std.bigmod.pow_mod branches on its exponent, so it cannot take a private exponent as it is).

9. Open questions, for a person

Decided: all five as proposed (2026-10-07).

  1. TLS 1.2 for the broker. Many embedded MQTT clients (older mbedTLS and wolfSSL builds on microcontrollers) speak TLS 1.2 only. Proposed: version 1 is 1.3 only; the broker’s design counts the clients it must serve, and step 6 is built if any need 1.2.
  2. Client certificates before the example? Proposed: no; step 4 follows step 3, unless the broker’s design makes them its default way to authenticate, in which case step 4 moves ahead of step 3.
  3. Verify every signature before sending it (§3.3): 0.8 ms a handshake, about a fifth of the cost. Proposed: yes, until step 7’s fixed-base and NIST-prime work makes signing cheap enough that the check’s share is worth measuring again.
  4. One engine type, or two (§5.1). Proposed: one, with a role.
  5. Independent review. The client is “not independently reviewed” (#209). A server that signs with a long-lived key is a larger exposure than a client with ephemeral keys. Proposed: the server carries the same notice, and the broker and the gateway say so in their READMEs until #209 is answered.

10. As built: step 2, the server

PR #339. The numbers are measured, each with the command that gives it; where building found this document wrong, the section that said so is corrected in place, marked “corrected (step 2)” or “as built”.

10.1 What was built

FileModuleWhat
packages/tls/hello.chotls_hellothe ClientHello parsed with the rules of §5.2, the choices (suite, group, retry group, ALPN), and the messages a server sends: ServerHello, HelloRetryRequest, EncryptedExtensions, Certificate, CertificateVerify
packages/tls/server.chotls_serverone server connection: start, feed and its record loop, the key schedule, the flight, the one function that calls the signer, the client’s Finished compared in constant time, KeyUpdate
packages/tls/identity.chotls_identitya server engine’s configuration in one byte slice: 16 identities (key, public point, names, the certificate_list as Certificate sends it) and the ALPN list
packages/tls/tls.chotlsopen_server, add_identity, replace_identity, set_alpn, serve, server_name, alpn, handshakes_in_progress, suite, group, retried, alert_received; the role; feed sends a server slot to tls_server
packages/tls/slot.cho, record.cho, message.chothe server’s states, flags and five slot fields; its 24 refusal codes and their alerts; hrr_random made public

Reused unchanged: the record layer and its AEADs, tls_slot’s transcript, keys, record queue, fail and forget, the ECDH share a HelloRetryRequest needs (new_ecdh_share: the P-256 or P-384 scalar comes from the X25519 secret by HKDF, as for the client), and tls_client‘s take, send, recv, finish, event, peer_eof and drop, which serve a server slot as they are, and its finished_mac, on_alert and compact_recv, made public for the server. client.cho changes in nothing else, so its mutants’ texts still match.

What is not shared, and why. The record loop (feed, on_record, the handshake reassembly) is the server’s own, about 200 lines the shape of the client’s. The client’s loop calls the client’s message handler, and cancho has no function values (docs/function-values.md), so sharing it would mean tls_client importing tls_server, and every client build (tls_driver, hooks) taking the server and x509_key with it. The server’s loop also differs where it must: early data, a plaintext alert after the flight, one change_cipher_spec between the ClientHello and the Finished, and the “message ends its record” rule checked before a message is handled.

The signer. tls_server.sign is the one caller: SHA-256 of CertificateVerify’s content, ecdsa_sign.sign_checked under the identity’s key, hedged with 32 bytes of the engine’s DRBG drawn at serve (RFC 6979 §3.6), verified under the identity’s public point before it is used (§3.3, tls-server-sign-check), then ecdsa_sign.to_der. Its work is the slot’s std.ecdh work area, which the key exchange has finished with. Each connection draws 96 bytes at serve: the ServerHello random, the X25519 secret, and the hedge; all three are in the slot’s keys, which forget and drop overwrite.

Memory. A server slot is the client’s (docs/tls-core.md §9.1) and five words. (#383: and an established connection holds 4.5 KiB of it, docs/tls-memory.md.) A server engine adds its identities, 274 KiB for 16, and 75 KiB of work for the key parser (std.ecdh’s), and gives up the client’s 1 MiB of roots; a client engine adds nothing (open allocates neither).

Editions. server.cho is edition 7, for hw_aes_gcm(); identity.cho edition 6, as x509_key is.

10.2 Interop

python3 scripts/tls_server_interop.py <tls_serve>, in the image scripts/interop/server.Dockerfile describes (Ubuntu 24.04 on linux-aarch64, in Docker on the M4 Max of docs/tls-assurance.md §6.1), tests/programs/tls_serve.cho built with the LLVM backend. Each row is one connection: the client verifies the chain and the name against the row’s CA, its data comes back, and the server’s own line for the connection says the suite, group, name and protocol the row asked for, and whether a HelloRetryRequest came first. For a retry row the client’s only share is P-521’s, with the group after it in supported_groups. curl and mosquitto have no option for TLS 1.3’s groups or suites; they were set through an OPENSSL_CONF. 88 rows, 88 ok:

ClientVersionRowsWhat
openssl s_clientOpenSSL 3.0.13233 suites × 3 groups, each direct and after a HelloRetryRequest; SNI choosing the second identity, a wildcard name, no SNI (the default); ALPN http/1.1 agreed; ALPN h3 alone refused, no_application_protocol
curl8.5.0, on OpenSSL 3.0.13183 × 3 × 2, an HTTP request to the server’s http mode
Go crypto/tls1.22.293 groups × 2 (Go does not let a client choose TLS 1.3’s suites: the server’s, AES-128-GCM); SNI; ALPN h2,mqtt agreed on mqtt, the server’s order; ALPN h3 refused
wolfSSL5.6.6193 × 3 × 2 (scripts/interop/wolfssl_client.c), ALPN mqtt
mosquitto2.0.18193 × 3 × 2, each row mosquitto_sub receiving the server’s message and mosquitto_pub publishing one the server prints, against tls_serve’s mqtt mode; ALPN mqtt

Not run: Firefox and Chrome (no browser in the image). A Chromium ClientHello was caught instead (the Claude desktop app’s built-in browser, a Chromium, sent to a listener on this machine) and answered by the server in the driver: it chose AES-128-GCM, X25519 over the X25519MLKEM768 hybrid, and h2. It is in the fuzzing corpus (§10.5).

10.3 The lying client

python3 scripts/tls_liar_client.py <server driver> tests/vectors/tls/liar_client.txt: 110 connections, each a client that changes one thing, recorded and replayed byte for byte on both backends by conformance/tls_server.rs (and the honest ones again with the client’s bytes fed one byte a line). The client is pyca/cryptography’s primitives and RFC 8446, written apart from the server; it checks every byte the server sends, the CertificateVerify signature under the leaf’s key included. 29 end ok: 26 honest connections (every suite and group, a HelloRetryRequest to each group, SNI by name, by wildcard, unknown and absent, ALPN in the server’s order and ignored without a list, early data skipped before and after a HelloRetryRequest and at exactly 16 KiB, a ClientHello in one-byte records and one of exactly 16 KiB, a KeyUpdate answered, close_notify both ways, legacy_version 0x0301, GREASE, a hybrid share, pre_shared_key ignored), the server’s suite order with and without AES instructions, and an identity replaced and a replacement refused. The other 81 each end with their tag and, for a connection, the alert §5.4 names, in the clear or under the key the server then holds. Every tag of §5.4 is reached but tls-server-sign-check, which needs the signer to fault; its path is shown by the mutant that checks the signature under another identity’s point (§10.6), which every honest connection kills.

10.4 The differential against openssl s_server

python3 scripts/tls_server_differential.py: the lying client’s connections decided on a ClientHello (72 of the 110: the client’s bytes there do not depend on the server’s) sent as recorded to openssl s_server 3.0.13 with the same identity, groups, suites and ALPN list, and the outcomes compared. 59 agree, 7 differ in the alert only, 6 differ as EXPECTED says, 0 otherwise.

Casepackages/tlsOpenSSLWhy
a host name of 300 bytesaccepted, the default identityunrecognized_nameRFC 6066 §3 allows either; this server never tells names apart (§7)
an unknown extension twiceillegal_parameteracceptedRFC 8446 §4.2: no extension twice; OpenSSL checks only those it knows
two X25519 sharesillegal_parameteracceptedRFC 8446 §4.2.8 lets a server refuse it
a ClientHello over 16 KiB (two cases)decode_errorwaits, or acceptsthis server’s limit (§5.2); OpenSSL’s is larger
a second ClientHello with another session idillegal_parameteracceptedRFC 8446 §4.1.2 lets a server check it

The alerts that differ: an empty supported_versions and an odd cipher_suites length (decode_error here, protocol_version there); two host names (illegal_parameter against decode_error); a low-order X25519 share (illegal_parameter against internal_error); a record of version 2.0 (protocol_version against none); a fatal alert or close_notify instead of a ClientHello (none here, unexpected_message there). Found by it, and fixed: this server refused a ClientHello whose legacy_version was 0x0301, as §5.2 said to; OpenSSL takes it, and RFC 8446 §4.2.1 forbids a server to negotiate with that field once supported_versions is there. §5.2 is corrected, and the case is an honest one.

10.5 Fuzzing

  • AFL++ (scripts/fuzz_afl.py, 4.09c in the image of §10.2, one core a harness), two new harnesses: fuzz_hello, a ClientHello’s body through tls_hello.client_hello and every choice made from it, and fuzz_server, the engine from serve with a fixed identity, fed a client’s bytes in chunks the input names (odd-length inputs without an ALPN list). Seeded from the ClientHellos of §10.2’s clients and Chromium (tests/vectors/fuzz/server/, hello/). Two runs, the second resuming the first’s queue on the final code:

    HarnessExecutionsHoursPer secondEdgesCrashesHangs
    fuzz_hello11,501,7510.754,246130 of 34800
    fuzz_server1,391,7221.253091,283 of 11,06900
    total12,893,47300

    No crash and no hang. fuzz_server is slow because every input loads the identity and signs: 3 ms of each 3.2. --minimize kept 74 and 131 inputs, committed with the six real ClientHellos beside them. CI’s tls-assurance job fuzzes both for two minutes on x86-64 (its first run: 352,994 and 18,029 executions, 0 crashes, 0 hangs).

  • Mutation (scripts/tls_fuzz.py --server): 20,000 of the lying client’s honest connections, one line of the client’s bytes mutated in each (bits flipped, bytes set, cut short, slices duplicated or dropped, bytes inserted, a length set), replayed: 0 traps.

  • The corpus is a regression test: conformance/tls_fuzz.rs runs every committed input of both harnesses on both backends.

10.6 Mutants

python3 scripts/tls_server_mutants.py <cancho>: 71 of 71 killed, none argued equivalent. Each is one bug in hello.cho, server.cho, identity.cho, or the server’s part of tls.cho and slot.cho, against the 110 recorded connections. Two survived a first run, and each got the case that kills it: a leaf of another curve was caught later anyway, by the key’s match against the certificate (a P-384 leaf with a key that is not PEM now says key-type, not key-format), and handshakes_in_progress was never read mid-handshake. The client’s scripts/tls_mutants.py still kills 103 of 103, after two of its mutants’ texts were brought up to date with set_read_keys and set_write_keys as the hardware AES change (#334) left them: on main the script stopped with “the text occurs 0 times”.

10.7 The existing suites

Unchanged by this PR, run again: the client’s 84 lying-server cases and 20 ticket cases re-recorded byte for byte identical; scripts/tls_differential.py 59 agree, 17 alert only, 8 as documented, 0 otherwise, and --handshakes 21 of 21; scripts/x509_matrix.py 35 cases, 0 wrong; scripts/publish_packages.py --check with the server’s three modules published.

10.8 Where this differs from the design

  • tls.accept is tls.serve (a builtin owns accept), §5.1.
  • legacy_version is not read, §5.2 (the differential found it).
  • Six refusal tags more than §5.4 named, and the “record-layer tags” are the client’s, §5.4.
  • The record loop is the server’s own, not the client’s, §10.1.
  • suite, group, retried and alert_received added, for logs.
  • An identity’s chain is at most 16 KiB, names 1 KiB, the ALPN list 512 bytes, §4.

10.9 Not done, and not verified

  • tls-server-sign-check is not reached by any input: it needs the signer to fault. A mutant reaches its path.
  • Firefox’s and Chrome’s own clients were not run; a Chromium ClientHello was answered in the driver, not over a socket to a completed handshake.
  • x86-64: the cost ran on linux-aarch64 only. CI’s tls-assurance job (ubuntu-latest, x86-64) runs the interop matrix (88 of 88 there too; its Go sends a second share, so its three retry rows are taken directly, which the harness allows for Go alone), the differential (the same 59, 7 and 6) and two minutes of each fuzzer.
  • Timing: no new secret-dependent code here but the Finished comparison, which is the client’s pattern; the signer’s timing is step 1’s. No dudect test of the server’s handshake as a whole.
  • Not independently reviewed (#209), as the client; the broker and the gateway must say so in their READMEs.

11. As built: step 3, the example

PR #346. The numbers are measured, each with the command that gives it.

11.1 What was built

examples/tls_echo/, four files, edition 6, no foreign code (corrected, http-server.md §11: it was three; examples/https_hello needed the same pieces, so what was in echo.cho and tls_echo.cho and has nothing to do with an echo moved, unchanged, to front.cho):

FileModuleWhat
tls_echo.cho(root)main: the engine, the identities, the listener, the signals, and the loop; usage
front.chotls_frontwhat any program that terminates TLS here has: the options and their parsing (std.flags), the per-slot state, the log, admission (the bounds), accepting, writing the engine’s bytes, ending a connection, reload
echo.choecho_loopthe loop: one Poller, std.conns, each connection in the same slot of the table and of the engine; the echo with back-pressure (pump), the timeouts, the stop
identity.choecho_identityan identity’s chain.pem, key.pem and names read beneath the directory handle and given to add_identity or replace_identity; the key’s buffer overwritten after
tls_echo --port <n> --dir <directory> [--identity <subdirectory>]... [--alpn <p1,p2>]
         [--connections <n>] [--handshakes <n>] [--rate <per second>] [--handshake-timeout <ms>] [--idle <ms>]
         [--per-address <n>] [--per-address-rate <per second>] [--areas <n>]

It logs, per connection, what was negotiated and how it ended:

conn 7 peer=127.0.0.1:51109 established suite=TLS_AES_128_GCM_SHA256 group=x25519 sni=echo.lex-sys.test alpn=echo hrr=no waited=0 handshake=5
conn 7 peer=127.0.0.1:51109 closed ok in=11 ms=1009

The authority report is bounded and pinned by conformance/tls_echo.rs: args, clock, conn_accept, conn_read, conn_write, dir_read, err_write, file_read, fs_read(""), heap, io_write, net_in(""), poll, signals("HUP,INT,TERM"), signals_read; no ffi, no foreign symbol. The other examples pin theirs in a test, not a file, and so does this one.

Reading the files, as narrowly as the language allows. narrow takes a literal, and the operator names the directory, so a path read is fs_read("") whatever the program does (docs/agent-toolbox.md §2.2). What the language does allow is to spend it once: main reads 32 bytes of /dev/urandom and opens the directory with open_dir, and the rest of the program, the reload included, is handed the Dir and never the Fs. So serve, run and reload say dir_read and file_read, not fs_read: the code that faces the network can read beneath that one directory and nothing else, one component at a time and following no link (docs/directory-handles.md). The cost is that a symbolic link there is refused (ELOOP): certbot’s live/ is links into archive/ and a Kubernetes secret volume is links into ..data/, so a deployment copies the files in (a certbot --deploy-hook does, and sets the permissions this process needs at the same time). A program that knew its directory at build time could narrow to the literal instead (Fs("/etc/cancho-mqtt")), and its row would say so.

(Built, PR #364, narrowing-into-several.md §11.1: examples/tls_echo_fixed is that program. Its main is narrow(fs, "/dev/urandom", "/etc/cancho/tls_echo"), one narrow for the two paths it reads, so its pinned report (conformance/tls_echo_fixed.rs) has fs_read("/dev/urandom") and fs_read("/etc/cancho/tls_echo") where tls_echo’s has fs_read(""), and the same labels otherwise. It shares serve and the loop with tls_echo; the cost is that the directory is fixed when it is built and --dir is refused.)

11.2 The two decisions

Reload is SIGHUP, not a file’s modification time. Both are possible today (dir_stat answers an mtime beneath a Dir, #263). The signal is chosen because a renewal replaces two files, and a poll can see one changed and not the other: the engine refuses that pair (tls-server-key-mismatch, and the old identity keeps serving), so a poll is not unsafe, but it reloads at a moment nobody chose, logs a refusal for a correct deployment, and costs a stat per file per interval. A signal is sent by the deploy hook after both files are in place, which is the moment the operator means, and it is what nginx, HAProxy and mosquitto do. On SIGHUP every identity is read again and given to tls.replace_identity; a connection whose ClientHello was answered keeps the certificate it was sent, and a refused replacement is logged (reload 0 refused tls-server-key-mismatch) with the old identity still serving. Names are not reloaded (replace_identity keeps them); a change of names is a restart.

The handshake bounds delay; the table refuses. Every connection is accepted into a slot of the table and a queue, unwatched, so its ClientHello waits in the kernel and costs nothing. A queued connection starts (tls.serve, then it is watched) when fewer than --handshakes are in progress (tls.handshakes_in_progress) and fewer than --rate were started in this second; oldest first. A connection over the table (--connections) is accepted and closed at once, before any TLS. Each phase has --handshake-timeout (queued, and in progress, separately), so a peer that connects and sends nothing holds a handshake place for that long and no longer; an established connection gets close_notify after --idle. Why delay and not refuse: a burst of honest clients (a broker restarted, a thousand MQTT clients reconnecting at once) is the common case of “too many handshakes”, and delaying it costs each a wait while refusing costs each a retry with backoff; the attacker’s case is bounded either way, by the same two numbers. The alternative of leaving the excess in the kernel’s listen queue is not available: there is no way to stop watching a Listener short of closing it (poller_remove takes a Conn), so a listener that is not accepted from wakes poller_wait at once, every time.

Per address (conn-peer.md). Every line the example prints about a connection says who it is from (peer=<address>:<port>: conn 3 peer=127.0.0.1:51109 established ..., refused 7 peer=... full), from conns.peer asked once after the connection is put in its slot. Two more bounds count under the peer’s key (addr.key: an IPv4 address, an IPv6 /64), both off unless given: --per-address <n> closes a connection at once, as the table does (refused <id> peer=... per-address), when <n> from its key are already held; --per-address-rate <n> leaves a queued connection queued while <n> handshakes from its key were already started in this second, and does not hold up the connections behind it. The second keeps a table of 1,024 keys per one-second window; a 1,025th key in a window waits for the next. A client behind a proxy or NAT is the proxy’s or the NAT’s address and counts with everything behind it.

The defaults: 256 connections, 32 handshakes in progress, 100 started a second, 10 s to finish a handshake, 60 s idle. Corrected on this PR: the rate’s default was 200, “most of one core” at 4 ms; CI’s x86-64 runner measured 5.5 ms a handshake (§11.4), where 200 a second is more than the one core the loop has and the bound bounds nothing. At 100 it is a third of a core on the M4 (34%) and 59% on that runner, both measured. A deployment sets it from its own machine’s figure.

Work areas (--areas <n>, at least 2, default --connections; docs/tls-memory.md §7). The engine keeps what an established connection needs, 4.5 KiB, for every connection, and leases the rest, a handshake’s scratch and the record buffers, for as long as a handshake runs or bytes are in flight, clearing it when it comes back. With the default there is an area for every connection and nothing is ever refused or delayed for want of one; the address space is what it was. With --areas smaller, a queued connection is delayed until its handshake can have an area (at most half of the areas, rounded down, are in handshakes at once, so traffic always has one); an established connection that needs an area for a record and finds none waits (the engine takes nothing, and the bytes are offered again); and one that holds an area for longer than --handshake-timeout (half a record and nothing more, or bytes it does not read) is ended record-timeout, so one byte every --idle cannot keep an area. Size it for the connections that can be in a handshake or hold a record at one instant, not for the connections. --connections (and so --handshakes and --per-address) may now be up to 1,048,576; it was 4,096, the width of a queue entry’s slot field, until tls-memory.md measured 10,000.

Back-pressure. A connection’s socket is read only when everything read before it has been echoed and written, so a peer that does not read its echo stops being read, and its slot’s three 16 KiB buffers are the most it can make the server hold. A stop (SIGINT, SIGTERM) refuses new connections, closes queued and handshaking ones, sends close_notify on established ones, and exits once those are written or after two seconds.

11.3 Tests

  • conformance/tls_echo.rs, in cargo test on both CI targets, against packages/tls’s own client (tests/programs/tls_many.cho), so it needs no TLS library outside the repository: the authority report pinned; 64 connections at once with at most 4 handshakes in progress, each verifying the chain and the name, sending 16,384 bytes, getting them back and getting close_notify when idle; a reload that takes and one that is refused; then 8 more connections on the renewed identity; then SIGTERM, exit status 0.

  • python3 scripts/tls_echo_test.py <tls_echo>, in CI’s tls-assurance job (linux-x86_64) and run here in the image of §10.2 (linux-aarch64) and on macOS 26 (darwin-aarch64, OpenSSL 3.6): 8 of 8 cases on each of the three:

    casewhat
    suitesopenssl s_client, 3 suites × 3 groups and a HelloRetryRequest (P-521 then P-256): the echo comes back, the log line says the suite, group, SNI, ALPN and hrr asked for
    many200 connections at once (Python ssl), 20 echoes of 1 to 20,000 bytes each, every byte compared, each closed ok with close_notify both ways
    reloada connection opened before SIGHUP keeps echoing; one after it gets the renewed certificate (by serial); after a refused reload the renewed one still serves
    bound--handshakes 2: two peers that send nothing hold both places; an honest client is delayed 1.26 s, not refused, and completes when their 1.5 s deadline frees one
    rate--rate 5: 15 clients at once all complete in 2.0 s, the last having waited 2.0 s
    full--connections 4: a fifth connection is closed at once (0 ms), before a handshake
    idle--idle 1000: close_notify after 1.00 s
    shutdownSIGTERM with 10 connections: 10 close_notify, exit 0
    peerthe established, closed and refused lines each say peer=127.0.0.1:<the client's source port>
    per-address--per-address 3: a fourth connection from 127.0.0.1 is closed at once (0 ms) as per-address; a client from 127.0.0.2 (Linux only; macOS has no second loopback address, and the step says it was skipped) is served; a place freed by a close is reusable
    addr-rate--per-address-rate 2: six clients from one address complete in 2.0 s (the last waited 1.99 s); a client from 127.0.0.2 meanwhile took 48 ms (Linux)
    areas--areas 16: 1,000 clients are all served (their handshakes delayed to the areas free, not refused), 6 of the 1,000 established connections used at once, all closed ok; on Linux the server’s resident memory for the 1,000 idle ones is measured and must be under 60 KiB each (17.8 on arm64; 1,000 and not 120 because CI’s transparent huge pages count the areas in use in 2 MiB units)
    stall--areas 2: a peer that sends half a record and stops is ended record-timeout after --handshake-timeout, while another connection is echoed on the second area; --areas 1 is a usage error

    curl and mosquitto are not in it: they speak HTTP and MQTT, which an echo answers with their own request; their interop with this engine is §10.2’s matrix.

  • Mutants. Eight single edits to echo.cho, each built and run against the case that should catch it; 8 of 8 killed: the handshake bound ignored (bound: not delayed), the rate ignored (rate), no handshake deadline (bound), no idle timeout (idle, and conformance/tls_echo.rs), a stop that leaves established connections to the drain (shutdown), a reload that adds an identity instead of replacing it (reload: the old certificate still served), the connection ended as soon as the peer’s close_notify is read (many), and no table bound (full).

  • Found while building it, both in the example and fixed there: tls.event says closed as soon as the peer’s close_notify is read, before the server’s own is queued, so a loop that ends a connection on closed closes the socket without answering (Python’s ssl saw UNEXPECTED_EOF on every unwrap); the example ends a connection on closed only once it has queued its own. And a client’s Finished and its end of file read in one turn made the connection fail before the loop had logged it established (openssl s_time’s connections were never logged); the socket is now read only when nothing else moved.

11.4 Cost

python3 scripts/tls_echo_test.py <tls_echo> --cost 20: openssl s_time -new (OpenSSL 3.0.13, its default X25519 share, the server’s AES-128-GCM) for 20 s a row, the server’s CPU from /proc/<pid>/stat divided by the handshakes. LLVM backend; the machine of §6 (Ubuntu 24.04, linux-aarch64, Docker’s 6-vCPU VM on the Apple M4 Max, the host’s load average 2 to 4):

clients, server boundshandshakes a secondserver CPUms of CPU a handshake
1, none31694% of a core2.98
4, none341104% of a core (one thread: saturated)3.04
4, --rate 200 (the first default)210 (4,204 in 20 s, 21 windows)66% of a core3.15
4, the defaults (--rate 100)105 (2,104 in 20 s)34% of a core3.27

So about 3.0 ms of CPU a full handshake, 330 to 340 handshakes a second on the one core the loop runs on, and the rate bound holds a flood of handshakes to the share of a core it was set for. Beside it, alternating in one session, tls_serve (§6) under the same client measured 3.82 and 3.94 ms against the example’s 3.03 and 2.99: the correction in §6.

x86-64, CI’s tls-assurance job on this PR (GitHub’s ubuntu-latest runner, OpenSSL 3.0.13, the same command), the first measurement of the server on x86-64:

clients, server boundshandshakes a secondserver CPUms of CPU a handshake
1, none17094% of a core5.49
4, none189104% of a core5.53
4, --rate 200188104% of a core: the bound above what the core can do5.55
4, the defaults (--rate 100), the next run105 (2,104 in 20 s)59% of a core5.58

About 5.5 ms a handshake there, 1.8 times the M4’s, in line with docs/ecdsa-sign.md §7’s signer (1.5 ms on an i7 against 0.82 on the M4, and as much again for the check). It is why the rate’s default is 100, not 200 (§11.2). The 8 cases passed there too (many in 1.9 s).

11.5 Not done, and not verified

  • http.server over TLS: built after this section (docs/http-server.md §11): the package has a byte-fed mode and examples/https_hello is this example with it where the echo is.
  • More than one core. One thread, as the design’s programs are today; a second core is a second process on the same port, which tcp_listen’s SO_REUSEPORT flag allows, not tried.
  • A flood from many addresses was not run; the bounds are per process, not per peer. Corrected (conn-peer.md): std.conns now gives the address (conns.peer), and examples/tls_echo has the per-address bounds (--per-address, --per-address-rate, front.cho), tested with clients from two addresses (scripts/tls_echo_test.py: per-address, addr-rate). A flood from many addresses against the table of 1,024 keys is still not run.
  • Cranelift: the tests in CI build the example with the LLVM backend. Built with Cranelift by hand on darwin-aarch64, it passes the same 8 cases (many in 3.4 s against LLVM’s 0.8); its cost is not measured.
  • Not independently reviewed (#209), as the engine.

12. Session tickets: the design

Step 5 of §8, issue #379, part of #378. Written before the code. The sizes are arithmetic from the layout below and are checked by the build (§12.10); no cost is claimed here, §12.9 holds what is measured. Where building finds this false, the section that said it is corrected in place.

What it buys. A full handshake costs 3.0 to 5.5 ms of CPU, of which the certificate signature and its check are 2 to 3.5 ms (§6, §11.4). A client that comes back should not pay them again. A resumed handshake still does one key exchange and no signature: the same shape as the client’s option A (docs/tls-resumption.md §2), seen from the server.

12.1 The ticket: stateless, sealed

A ticket is the server’s own state, handed to the client and read back (RFC 8446 §2.2, §4.6.1; RFC 5077’s idea). The server keeps nothing per ticket, so a restart, a second process and a second host resume each other’s tickets when they hold the same ticket key and the same identity (§12.3).

ticket  = key_name (16) || salt (32) || AEAD(subkey, nonce = 0, aad = version || key_name || salt, plaintext) || tag (16)
subkey  = HKDF-Expand(HKDF-Extract(salt, ticket_key), "cancho tls ticket v1", 32)
plaintext (version 1) =
    version (1) = 1          the format; a ticket of another version is not read
    auth (1) = 0             how the client was authenticated: 0 none (§12.5 g)
    issue_ms (8)             the engine's clock when the ticket was made
    expiry_ms (8)            issue_ms + lifetime, never past the certificate's notAfter
    age_add (4)              this ticket's ticket_age_add, so the client's obfuscated age can be undone
    suite (2)                the cipher suite of the connection
    hash_len (1), psk        the resumption PSK of this ticket, 32 or 48 bytes
    fp (32)                  SHA-256 of the leaf certificate (DER) of the identity that issued it
    sni_len (1), sni         the host name the client sent, lowercased, as the engine kept it (0 bytes: none)
    alpn_len (1), alpn       the protocol chosen, if any
  • The AEAD is ChaCha20-Poly1305 (std.chacha20): constant time in software on every CPU, so a ticket opens in the same time whatever the key, and the same code on both backends.
  • A nonce that cannot repeat: no nonce is shared. The 12-byte nonce is zero, and the key is not: every ticket has its own subkey, derived from the ticket key and 32 fresh bytes (salt) of the engine’s DRBG. A (key, nonce) pair then seals one message because the key is new each time, and two tickets share a subkey only if two draws of 256 bits agree. This is why a counter was rejected: a counter in each process collides across the processes of §12.3, and a 96-bit random nonce under one shared key reaches a 2^-32 collision after 2^32 tickets, which a busy fleet makes in days, and a repeated ChaCha20-Poly1305 nonce gives away the Poly1305 key and the XOR of two PSKs. The cost is one HKDF-Extract and Expand (a few HMAC-SHA-256 blocks) a ticket.
  • The ticket is 64 bytes of overhead and at most 681 bytes in all: 16 + 32 + (1+1+8+8+4+2+1+48+32+1+255+1+255 = 617)
    • 16 = 681 at the largest (a 255-byte name and protocol, SHA-384); typically a name of 20 bytes and h2: about 250. The client keeps tickets of up to 2,048 bytes (docs/tls-resumption.md §4), so every ticket fits.
  • What the client sees is opaque bytes. It learns the lifetime, ticket_age_add and a nonce (the index of the ticket on the connection) in the clear, all of which it knows anyway.
  • The PSK is RFC 8446’s: HKDF-Expand-Label(resumption_master_secret, "resumption", ticket_nonce, Hash.length), with ticket_nonce the one byte index of the ticket on the connection, so tickets of one connection have different PSKs. Storing the PSK in the ticket, not the resumption master secret, means a ticket opens to exactly one thing and a stolen ticket’s contents give nothing about its siblings.

12.2 The ticket key, and its rotation

  • A key is 32 bytes. Its name is the first 16 bytes of HMAC-SHA-256(key, “cancho tls ticket key name v1”), so the name is a function of the secret and every host given the same key computes the same name; nothing else needs to be agreed.
  • The engine holds up to 4 keys: one current, which seals, and up to 3 previous, which only open. A previous key has an open_until of the moment it stopped being current plus the ticket lifetime: a ticket it sealed is not valid longer than that anyway (the ticket carries its own expiry), so the key is dead weight afterwards and refuses (unknown). That is the lifetime bound: a key is useful for at most two lifetimes (one as current, one as previous).
  • The engine makes the key when the program gives none: 32 bytes from its DRBG, the first time a connection is served with tickets on, and again every lifetime (so a ticket sealed at the end of a key’s life is still opened during the next). The program calls tls.set_time(engine, now_unix_ms) or serve, and the engine rotates on its own clock. A key made this way is the engine’s alone: it is never shown, as the identity keys are not (§4). So tickets made by it are good for this process only, and for a restart none survive.
  • The program supplies keys for anything wider than one process: tls.set_ticket_keys(engine, keys, now_ms) takes n × 32 bytes (1 ≤ n ≤ 4), the first current and the rest previous. It is declarative: the engine reconciles with what it holds (a key already held keeps its history; a key not listed is overwritten; a listed key it did not hold opens tickets for one lifetime from now_ms), and a call with the keys it already has changes nothing, so a program may call it on every SIGHUP. A program that supplies keys turns the engine’s own rotation off; rotating is then the operator’s: prepend a new key to the file, keep the old ones for a lifetime, drop them. tls.rotate_ticket_key(engine, now_ms) is the single-process version: draw a key, make it current, keep the old as previous.
  • A fleet gives every process the same file. The processes agree on the key name by construction (above), so one process’s ticket opens in another, provided they also hold the same identity (§12.3). The ticket key is the only secret they share, and it protects tickets, not the sessions of the full handshake. The file is as sensitive as an identity’s key: anyone who has the ticket key can make tickets, and so can make a client believe, for the lifetime, that it is resuming a session it never had. (They cannot read past sessions: a resumed connection does a fresh key exchange, §12.5 a.)

12.3 What a ticket is bound to, and what it is refused for

A ticket is a stored verdict that a full handshake happened, as the client’s tickets are (docs/tls-resumption.md §3): every way the verdict could be stale is a rule. The model is the client’s eight rules; the server’s are these. A ticket that fails any rule is not an error: the server ignores pre_shared_key and does a full handshake, which is always possible and always safe, and the connection’s verdict says why (§12.7). The exceptions are the two failures RFC 8446 demands as aborts, below.

rulethe client’s rule it mirrorsthe decision
apsk_dhe_ke only. The ClientHello’s psk_key_exchange_modes must list psk_dhe_ke (1). A hello that lists only psk_ke (0) is a full handshake; pre_shared_key without psk_key_exchange_modes is missing_extension (RFC 8446 §4.2.9: a MUST). The ServerHello always carries a key_share. Tickets are sent only to a client that listed psk_dhe_ke, as Go’s and rustls’s servers do (§4.2.9 says a server SHOULD NOT send tickets the client’s modes cannot use).rule 8 (the ServerHello must carry a key_share; a resumption without one is refused)A fresh X25519, P-256 or P-384 exchange on every resumption: forward secrecy is kept; the ticket key and the PSK together do not give a resumed session’s keys.
bNever early data. early_data in a ClientHello that offers a ticket is accepted as an extension and not accepted as 0-RTT: EncryptedExtensions has no early_data, and the client’s early records are skipped by trial decryption (RFC 8446 §4.2.10), up to 16 KiB, as §5.2 already does for a full handshake. A NewSessionTicket never carries early_data (max_early_data_size), so no compliant client sends any.rule 6
cLifetime and age. expiry_ms is issue_ms + lifetime, capped at the leaf’s notAfter; a ticket is refused (expired) at or after it. The ClientHello’s obfuscated_ticket_age minus the ticket’s age_add, mod 2^32, is the age the client claims in ms; the server’s own age is now - issue_ms; they must agree to within 30 s (age). A ticket from the future (another host’s clock ahead) is the same test: its server age is negative and the two ages differ by more than the window. The lifetime sent is the setting (default 1 hour) capped at 7 days (RFC 8446 §4.6.1) and at the certificate’s remaining life.rules 3 and 4The window is wide because the client’s age is counted from when it received the ticket (this package’s client counts from the start of the connection that got it: docs/tls-resumption.md §11) and the server’s from when it issued it, and the engine’s clock is the one the program last gave it. It is not a replay defence (§12.4), only a freshness one.
dBound to the name, the identity, the suite. The ticket carries the host name the client sent and a SHA-256 of the issuing identity’s leaf certificate. A resumption must send the same name (name), and the identity that name selects now must have that same certificate (identity): replace_identity with a different certificate refuses every ticket of that identity, because the fingerprint changes; with the same certificate (a reload that found nothing new) it refuses none, and tickets stay good across a process restart that loads the same files. The suite chosen for the new connection must hash as the ticket’s (suite, RFC 8446 §4.2.11: the PSK’s hash must be the cipher suite’s); the ALPN protocol the new connection chooses must be the one the ticket records (alpn).rules 1, 2, 3 (name, trust store, certificate)The fingerprint, not an identity number or a generation counter: a counter is per process and would make §12.3’s fleet refuse its own tickets, and a number can be reused. The certificate’s chain is not part of the fingerprint: the leaf is what the client verified the name against, and a changed intermediate is the operator’s affair (§4).
eReplay. §12.4.rule 5 (used once)A ticket is not single use on the server; the argument and the bound are in §12.4.
fThe binder, in constant time. The binder is recomputed over the transcript through the ClientHello’s pre_shared_key truncated before its binders (RFC 8446 §4.2.11.2) and compared with every byte examined, as the client’s Finished is (§10.1). A ticket that opened and passed the rules above but whose binder is wrong aborts the handshake (RFC 8446 §4.2.11: the server MUST abort) with decrypt_error (tls-server-binder; §6.2 names that alert for a PSK binder that does not validate), after a HelloRetryRequest too (the transcript then holds it). OpenSSL aborts too, but with illegal_parameter (47); the differential, §12.12, records it.the client’s own binder check, which the client does not needAn unopenable ticket is no abort (the server cannot know it was ours): a full handshake. Only a ticket the server authored and a binder that does not match are an abort: nothing a bystander can use as an oracle, since opening the ticket needs the ticket key.
gNo client certificate yet. A resumed session skips every step of a full handshake that is not the PSK’s proof, among them the client’s certificate (#384). The ticket’s auth byte is 0 today because this server asks for none. When #384 lands, a connection whose client authenticated carries a ticket only if the program asks for it, and the ticket then holds the client’s certificate fingerprint and notAfter, is refused after the client trust store changes, and its auth is 1. Until then a ticket with auth ≠ 0 is refused (auth), so an older server that meets a newer server’s ticket in a fleet never resumes a connection the client authenticated and the older cannot represent.rule 2 (trust store) and the “verify-nothing” hazard of docs/tls-resumption.md §3The safe default for #384 is no tickets on authenticated connections; the conservative path costs a client-authenticated peer a full handshake (the signature is the server’s, which is the cost this document removes, but the client’s certificate chain verification is the other half, and it too is saved only if the ticket carries the verdict).
hHow many, when, and KeyUpdate. §12.6.

12.4 Replay (rule e)

What RFC 8446 requires. Replay protection is a requirement of early data: §8 (“Replay Attacks on 0-RTT”) exists because 0-RTT data is accepted before the handshake completes and can be delivered twice. The three defences it describes (single-use tickets, a ClientHello record, freshness checks) are for a server that accepts early data. A server that never accepts early data and only accepts psk_dhe_ke has no data that a replay can deliver twice. Section 8 asks it for nothing further, and §4.6.1 does not require a server to make tickets single use (it asks the client to use each once, Appendix C.4, to avoid linking).

Why a replayed resumption is harmless. An attacker who records a ClientHello with a valid pre_shared_key and sends it again can make the server do three things: open the ticket, compute a key exchange against the client’s old key share, and send its flight. It cannot continue: the handshake keys depend on the DHE secret, which needs the client’s ephemeral private key, so the attacker can neither read the server’s EncryptedExtensions nor produce the client Finished, and the server’s flight is encrypted to a key only the original client could derive. The server then waits for a Finished that never comes. Nothing was authenticated, no application data was exchanged, no state changed. What the attacker gains is one key exchange of the server’s CPU a replay (about 1.1 ms; a fresh full ClientHello costs the attacker nothing more and the server a signature besides, so the replay is cheaper for the server than the attack the program must already bound): it is inside the handshake bounds the program sets (§7: tls.handshakes_in_progress, the rate, the per-address limits).

The bound. (1) The ticket’s expiry (lifetime, 1 hour by default) bounds the whole ticket. (2) Because the ClientHello repeats the original age, a captured ClientHello is accepted only while the server’s own age is within 30 s of the age it claims: after that, age refuses it, so a recording is replayable for 30 seconds, not for the lifetime. (A client that reuses a ticket gives a fresh age each time and is accepted until expiry: that is a client reusing a ticket, which the packages/tls client does not do, and which the server cannot tell from a replay and does not need to.) (3) A program that wants single use can keep state of its own, a set of binder values seen in the window, and refuse a repeat before serve (the binder is in the ClientHello it reads); the engine keeps none, because state shared by a fleet is exactly what a stateless ticket exists to avoid. Tested with a lying client that replays one ticket N times, and a captured ClientHello twice, in and out of the window (§12.8).

What this does not claim: a stolen ticket key is a different attack (§12.2), and a replay is not a denial-of-service defence: that is the program’s bounds.

12.5 The refusals that are not fallbacks, and the tags

The ones that end the connection, each a tls-server- tag and the alert RFC 8446 names, all reached by a lying-client case:

tagalertwhen
tls-server-binderdecrypt_error (51)the ticket opened and passed the rules, and the binder does not match (RFC 8446 §4.2.11 abort, §6.2 alert)
tls-server-missing-extension (existing)missing_extension (109)pre_shared_key without psk_key_exchange_modes (§4.2.9)
tls-server-client-hello-format (existing)decode_error (50)pre_shared_key or psk_key_exchange_modes that does not parse; a number of binders that is not the number of identities is tls-server-illegal-parameter (47)

The configuration calls answer tls-server-ticket-config (a count over 8 or a lifetime outside 1 s to 7 days) and tls-server-ticket-key (a key list that is empty, not a multiple of 32 bytes or over 4 keys), on the line, as set_alpn’s tls-server-alpn-list does.

12.6 Number of tickets, NewSessionTicket, KeyUpdate (rule h)

  • Count. count tickets (0 turns tickets off and is the default; at most 8; --tickets 2 in the examples, OpenSSL’s number) are sent on each connection that listed psk_dhe_ke, after the client’s Finished has been checked (RFC 8446 §4.6.1: a NewSessionTicket is a post-handshake message, under the server’s application key), in the same turn as the check, so a client that starts to read finds them. A resumed connection is sent count new tickets too (a new ticket per connection keeps the client’s use of a ticket single, as the client does). They are not sent on a connection that failed.
  • event says want_write while the engine has output, so a connection that ends its handshake with tickets queued does not report established until they are taken. Found by the interop run, not by design: tests/programs/tls_serve.cho answered a request only when event said established after a read, and a client that sent its Finished and its request in one segment (OpenSSL does) hung in about one connection in six with tickets on and never without. The engine is as documented (docs/tls-pure.md §2.2: the event once the output is taken); the program, which has data waiting, must not wait for the event (the harness now answers whenever it has data), and examples/tls_echo and examples/https_hello, which flush before they look at the event, never had it: 1,440 connections of 120 clients at once, 1,320 resumed, no error.
  • A connection that never reads them costs count × about 250 to 700 bytes of the slot’s output, which feed already bounds (it stops reading while less than 1 KiB is free).
  • KeyUpdate. A ticket is made once, at the end of the handshake, under the server’s first application key; it does not depend on a later key. A KeyUpdate from the client changes the keys of records after it, not the ticket, which is already queued under the old; a NewSessionTicket is never sent after one. No interplay.
  • Settings: tls.set_tickets(engine, count, lifetime_s): count 0 is off (the default, so a program that has not asked sends none, and the 110 recorded connections of §10.3 are unchanged), lifetime 3,600 s unless set.

12.7 The interface and the engine’s verdict

tls.set_tickets(engine, count, lifetime_s) -> 0 | tls-server-ticket-config  // server engines: tls-role otherwise
tls.set_ticket_keys(engine, keys, now_ms)  -> 0 | tls-server-ticket-key      // n x 32 bytes, first current (§12.2)
tls.rotate_ticket_key(engine, now_ms)      -> 0 | tls-no-entropy             // draw a key, make it current
tls.set_time(engine, now_ms)               -> 0                              // the engine's clock for tickets (§12.3 c)
tls.ticket_keys(engine)                    -> int                            // how many keys open tickets now
tls.resumed(engine, slot)                  -> bool                           // as for a client
tls.ticket_verdict(engine, slot)           -> int                            // why a connection did or did not resume
tls.ticket_verdict_tag(verdict)            -> "tls-ticket-..."

The clock only moves forward, and any value forward is taken (decided after review). A jump of more than a lifetime cannot be told from a server that was idle that long, which is common (no connection for a night), so refusing it would stop the clock of an honest server; and a wrong value far ahead fails safe: every ticket made then is dated in the future and its client’s age is refused (age), every ticket made before is expired, the engine’s own key is not replaced until real time passes its date, and nothing is ever accepted that a correct clock would refuse. The cost of the mistake is full handshakes until the real time catches up, which is the program’s to avoid: it gives the engine the same clock it gives serve.

The engine keeps no clock of its own (§5.3): tickets are issued and checked at the time the program last gave it, through serve, set_time or set_ticket_keys. A program with connections that last longer than the lifetime calls set_time each turn of its loop, as the example does; otherwise a ticket’s issue time would be the start of its connection. The verdict is a small integer with a tag, for logs, tests and the differential; it is not a failure. The verdicts, in the order the rules are tried (§12.3): resumed, off, no-psk-dhe-ke, unknown (not our key, a key past its open_until, or a size no ticket has), tampered (our key, and the AEAD says no), format, auth, expired, age, name, identity, suite, alpn, and none for a hello with no pre_shared_key. At most the first 4 identities of a pre_shared_key are tried, a ticket of more than 681 bytes is unknown without being opened, so a hello cannot make the server do more than 4 small AEAD opens.

12.8 How it will be tested (the results are §12.12)

  • Unit and conformance, both backends: the ticket module sealed and opened (every field, every rule), run through tests/programs/tls_server_driver.cho’s new lines and recorded with the rest of liar_client.txt (crates/cancho/tests/conformance/tls_server.rs).
  • The lying client (scripts/tls_liar_client.py, one case per rule): an expired ticket, a wrong binder, a wrong name, a wrong suite hash, a tampered and a truncated ticket, a ticket from a rotated-out key, a ticket after an identity was replaced (and after it was reloaded unchanged), an early-data attempt with a good ticket, psk_ke offered, pre_shared_key without modes, a ticket replayed N times and a ClientHello replayed in and out of the age window, a ticket of 15,000 bytes (a 64 KiB one cannot be sent: a ClientHello over 16 KiB is refused before any ticket is read, §5.2), a wrong age, an honest resumption (every suite, each group, after a HelloRetryRequest). Every refusal reached, every verdict reached.
  • Interop (scripts/tls_server_interop.py): OpenSSL (s_client -sess_out/-sess_in), curl, Go, wolfSSL, GnuTLS, rustls, mosquitto if its clients resume, and packages/tls’s own client resumes against the server.
  • Differential against openssl s_server: the same resumption ClientHellos, the outcome (resumed or not, the alert) compared.
  • Fuzz: the corpora gain ClientHellos with a ticket; fuzz_hello and fuzz_server run them.
  • Mutants: scripts/tls_server_mutants.py gains one mutant a decision above.
  • Cost: a resumed handshake against a full one, on x86-64 and arm64 (§12.9).

12.9 Cost

Measured (the expectation written before the build was a resumed handshake of about one key exchange, 1.1 to 1.5 ms, against 3.0 to 5.5 ms). python3 scripts/tls_echo_test.py <tls_echo> --cost 6 --tickets --rounds 7: the server of examples/tls_echo (LLVM backend, one thread, a P-256 identity, AES-128-GCM chosen by the server on both machines), a Python ssl client making connections one at a time for 6 s, the server’s CPU (user and system, /proc/<pid>/stat or ps) divided by the handshakes its own established lines count. Three rows, alternating, 7 rounds each, so a machine whose speed changes over the minutes moves all three together; the median and the best of the 7:

full, tickets offfull, 2 tickets sentresumedresumed / full
arm64: Apple M4 Max, macOS 26.2, native; the host busy with other builds (load average 11 to 17)3.15 ms (best 3.07)3.20 ms (best 3.12)1.52 ms (best 1.50)0.48
x86-64: Intel i7-1260P, Linux, pinned to core 6 (taskset -c 6), the box shared (load average 4 to 10)9.74 ms (best 5.47)9.63 ms (best 6.82)3.46 ms (best 2.91)0.35 of the median, 0.53 of best to best
  • A resumed handshake costs about half a full one on arm64 (1.52 against 3.15 ms), and about a third to a half on x86-64. What it still pays is the key exchange (the X25519 key pair and shared secret, 1.14 ms on the M4 of docs/tls-resumption.md §1) and the key schedule, the records and the transcript: 1.5 ms, inside the 1.1 to 1.5 expected.
  • Issuing two tickets on a full handshake costs 1.6% on arm64 (3.20 against 3.15 ms) and nothing a noisy x86-64 box can see (9.63 against 9.74; the best of 7 is 6.82 against 5.47, which is the box).
  • The x86-64 figures move more than a factor of three between runs: five single runs of the same rows (an earlier form of the script, with openssl s_time -new for the full handshakes), minutes apart, gave a full handshake of 19.6, 19.9, 12.5, 5.2 and 5.8 ms and a resumed one of 8.3, 7.0, 4.7, 3.2 and 2.5 while other jobs on the box came and went (load average 4 to 10 on 16 cores; the other cores are theirs), and CI’s runner measured 5.5 ms for a full handshake of the same code in §11.4. The ratio ranged from 0.35 to 0.62 and the absolute figures are the box’s; the quiet runs, 5.2 and 5.8 ms full against 3.2 and 2.5 resumed, are those of CI. The row tls_serve gives (python3 scripts/tls_server_cost.py <tls_serve> 6 --tickets --rounds 9, whose loop costs more than the example’s, §6) is the same shape: X25519, full 14.7 ms median (best 7.5), 15.4 with tickets (best 7.6), resumed 6.3 (best 3.3), 0.43; P-256, full 21.3 (best 15.6), resumed 7.0 (best 2.8).
  • Not measured: the Docker linux-aarch64 of §6 and §11.4 (its disk was full of other jobs’ images when the run was due); the Cranelift backend; a resumption through a HelloRetryRequest (it costs one more message hashed, §6).

12.10 Memory

Per slot: nothing new. A server slot reuses the client’s resumption fields it never uses: the PSK and the resumption master secret (among the keys forget overwrites), and the area that holds a client’s ticket for the 36 bytes of randomness a ticket needs, a salt and a ticket_age_add, neither a secret, overwritten when the tickets are made. Per engine: the ticket keys and settings, 4 keys of 72 bytes and 8 bytes of header, 296 bytes, allocated by open_server only; and an identity gains its certificate fingerprint and notAfter, 40 bytes each, 640 bytes for 16. A ticket needs no storage on the server.

12.11 What was built

FileWhat
packages/tls/ticket.cho (tls_ticket)the ticket (§12.1): sealed and opened; the ring of 4 ticket keys, make_current, set_keys, the engine’s own rotation (due); the rules (§12.3) as one function, check; the settings; the verdicts and their tags
packages/tls/hello.chothe ClientHello’s pre_shared_key and psk_key_exchange_modes parsed (ch_psk_*, psk_entry), the ServerHello’s pre_shared_key, the NewSessionTicket encoded
packages/tls/server.chotry_resume (the identities tried, the rules, the binder), the key schedule from the PSK, a flight with no Certificate, issue_tickets after the client’s Finished
packages/tls/identity.choan identity records its leaf’s SHA-256 and notAfter
packages/tls/tls.choset_tickets, set_ticket_keys, rotate_ticket_key, set_time, ticket_keys, ticket_verdict, ticket_verdict_tag; the engine’s clock; resumed answers for a server slot
packages/tls/record.cho, slot.chothree tags (tls-server-binder, -ticket-config, -ticket-key) and the alert of the first
examples/tls_echo, examples/https_hello--tickets <n>, --ticket-lifetime <s>, --ticket-keys <file>; SIGHUP reads the key file again; tls.set_time each turn of the loop; `resumed=yes

server.cho is 880 lines, ticket.cho 655, tls.cho 1,025; no file passes 2,000.

12.12 Tests and results

All commands from the repository root; the Linux ones in the image of §10.2 (Ubuntu 24.04, linux-aarch64, Docker on the Apple M4 Max), the x86-64 ones on a Linux box (an Intel i7-1260P, taskset -c 6).

  • The lying client (python3 scripts/tls_liar_client.py <server driver> tests/vectors/tls/liar_client.txt): 190 connections, the 110 of §10.3 unchanged byte for byte (but the role case, which gained four calls) and 80 new, in scripts/tls_liar_tickets.py: 61 end ok (resumptions, and fallbacks each checked against the verdict the engine gave: U in the driver) and 19 end in a refusal or a configuration answer (6 tls-server-binder, 6 tls-server-client-hello-format, 2 tls-server-ticket-key, and one each of -finished, -illegal-parameter, -missing-extension, -ticket-config and tls-no-entropy), listed below by rule; 7 of them added after review (a key listed twice, and the six midway: cases of §12.14). Recorded; conformance/tls_server.rs replays every one on both backends, and the recording made on x86-64 is identical to the one made on arm64.

    rulecases
    honesta full handshake sent 2 tickets with distinct PSKs; resumed under AES-256-GCM (SHA-384, a 48-byte PSK), ChaCha20 and AES-128, with X25519 and P-256, after a HelloRetryRequest, for the second identity with ALPN, the good ticket second of three and fifth of five (only four are tried), one ticket used three times, a chain of resumptions, early data offered and skipped, a ticket cut to the certificate’s remaining life, one sealed by the lying client itself in §12.1’s format (a cross-implementation check of the format and the key schedule), the clock moved during a long connection
    a (psk_dhe_ke only)psk_ke only: full, no ticket sent; no modes at all: none sent; pre_shared_key without modes: missing_extension; an empty mode list
    b (no early data)the early-data attempt above; a NewSessionTicket never carries an extension (asserted for every ticket the client reads)
    c (lifetime, age)expired, at exactly the expiry, one millisecond before; age 60 s high, 31 s low, 29 s off; from a clock 60 s ahead; a lifetime cut by the certificate
    d (name, identity, suite)another name, no name, replace_identity with a new certificate (refused) and with the same one (kept), the SHA-384 offered a SHA-256 ticket, another ALPN protocol, a made-up fingerprint
    e (replay)one ticket three times; a captured ClientHello replayed inside the window (resumed) and 60 s later (age)
    f (binder)wrong first byte, wrong last byte only, one byte too long, wrong PSK, after a HelloRetryRequest with the retry left out of the transcript, a forged ticket with a wrong binder; and a wrong binder on a ticket that fails a rule is not an abort
    keysprevious key opens, a dropped key does not, the same keys given again change nothing, a ring of four (the fifth rotation evicts the oldest; three leave it), the end of a previous key to the millisecond, the engine’s own rotation after exactly a lifetime, supplied keys never replaced by the engine’s
    tamperingone bit of the text, the tag, the salt; truncated by 10 and to 40 bytes; the key name; 15,000 bytes; one byte appended; a key holder’s ticket of version 2, with trailing bytes, a 20-byte PSK, auth 1
    settingscount 9, lifetime 0 and 8 days, keys of 0, 31, 33 and 160 bytes refused; rotation before the engine is seeded; the ticket calls on a client engine (tls-role); the count raised mid-handshake
    malformedidentities and binders of different numbers, an identity running past its list, an empty identity (alone and first of two), a binder of 31 bytes (alone and two)
  • Mutants (python3 scripts/tls_server_mutants.py target/release/cancho): 146 of 146 killed, none argued equivalent: the 71 of §10.6 and 75 new (3 after review: the identity bound at the Finished, a repeated key, save_to on a server), one per decision of §12.3, the key ring, the parser and the settings. A first run killed 140 and left three, each a gap in the cases and not in the code: a binder of 31 bytes was caught by the list’s own minimum before the per-binder one; an empty identity alone was too short for the list’s; and the “binder too long” case had a wrong binder anyway, because the lying client’s own binder was computed over a header one byte short (a bug in the test, found by the mutant that survived it). Each got a case that only its mutant’s line can fail. scripts/tls_mutants.py (the client’s) still kills 103 of 103.

  • Interop (python3 scripts/tls_server_tickets_interop.py <tls_serve> --many <tls_many>, tls_serve with tickets on; CI’s tls-assurance job runs it, and the ticket differential below, on x86-64 since review): 16 rows, 16 ok:

    clientrows
    openssl s_client -sess_out / -sess_in, OpenSSL 3.0.13AES-128, AES-256 and ChaCha20 with X25519; AES-128 with P-256 and P-384; a HelloRetryRequest first: each resumes (Reused, TLSv1.3)
    curl 8.5.0two URLs in one run resume
    Go crypto/tls 1.22.23 connections: full, resumed, resumed
    wolfSSL 5.6.6the same
    GnuTLS gnutls-cli --resume 3.8.3resumes
    rustls 0.23 on ringfull, resumed, resumed (handshake_kind)
    mosquitto 2.0.18does not resume: libmosquitto never sets a session on its SSL object; two mosquitto_sub connections are both full, and the row asserts it, so a version that resumes fails it and says so
    packages/tls’s own client (tls_many resume)8 connections, then 8 resumed
    two server processes sharing a key filea session from A resumes at B; at a third process with another key, a full handshake with the verdict unknown
    a ticket of 2 s lifetime, 3.5 s latera full handshake: OpenSSL’s client does not offer an expired session (none); a client that did is the lying client’s expired

    The example’s own live tests (scripts/tls_echo_test.py, scripts/https_hello_test.py, the last item of this list) add Python’s ssl.

  • The differential (python3 scripts/tls_server_tickets_differential.py <tls_serve>, against openssl s_server 3.0.13 with the same identity and tickets on): the same client behaviour against each server’s own tickets, 12 cases: 9 agree, 1 differs in the alert only, 2 differ as EXPECTED says. They agree on an honest resumption, a ticket used twice, psk_ke only, pre_shared_key without psk_key_exchange_modes (both abort, missing_extension), a ticket with one bit changed, a truncated one, one of 15,000 bytes, early data offered (both resume and skip it), and a good ticket second of two behind a made-up one. A wrong binder: this server decrypt_error (51, the alert RFC 8446 §6.2 names for a binder that does not validate), OpenSSL illegal_parameter (47). The two that differ on purpose: OpenSSL resumes a ticket for another host name (it does not compare it; this server does), and one whose claimed age is 60 s off (OpenSSL does not check the age without early data; this server does, within 30 s). scripts/tls_server_differential.py (the ClientHello cases of §10.4) is unchanged: 59 agree, 7 alert, 6 as documented, skipping the ticket cases, which are several connections.

  • Fuzzing (scripts/fuzz_afl.py, AFL++ 4.09c in the image, one core a harness): fuzz_server now runs with tickets on under a fixed key (tests/programs/fuzz_server_fixture.cho), and both harnesses are seeded with 13 ClientHellos that resume (scripts/tls_server_fuzz_seeds.py, in tests/vectors/fuzz/server/ and hello/): a ticket that is right, one off in each field, a wrong binder, several identities, psk_ke only, no modes, early data, SHA-384, after a HelloRetryRequest. Each seed reaches the verdict it is named for (checked with the driver’s U line). A first campaign on the final code: fuzz_hello 2,915,676 executions (4,165 a second, 155 of 418 edges), fuzz_server 454,125 executions (303 a second, 1,457 of 12,172 edges, against 1,283 of 11,069 before tickets), 0 crashes, 0 hangs; CI’s tls-assurance job, two minutes each on x86-64: 530,333 and 27,638 executions, the same edges, 0 and 0. The server’s edges had stopped growing at 1,457 within the two minutes of CI (the seeds start the fuzzer at the binder and the key schedule); a longer second campaign was due and did not run: the Docker VM’s disk was full of other jobs’ images when it was started. conformance/tls_fuzz.rs replays the whole committed corpus on both backends. scripts/tls_fuzz.py --server (one line of the lying client’s bytes mutated, bits flipped, cuts, insertions, lengths set) now takes the 55 ticket cases that end ok beside the 26 honest connections: 40,000 mutated connections over 81 recorded handshakes, 0 traps.

  • Live tests of the examples (scripts/tls_echo_test.py, 4 new cases of 15, all ok; scripts/https_hello_test.py, 1 new of 20; and conformance/tls_echo.rs, https_hello.rs against packages/tls’s own client): a session kept by Python’s ssl resumes three times from one session; a session from one process resumes at another that holds the same key file, not at one with another; a rotation by SIGHUP (a new key on top) keeps the old session, a second rotation that drops its key refuses it, a file that is not hex is refused (reload tickets refused tls-server-ticket-key) and the keys stay; a certificate renewed and loaded by SIGHUP refuses the tickets of the old one and a reload of the same certificate keeps them; a session offered after its 2 s lifetime does not resume.

12.14 Where this differs from the design, and what building it found

  • The ticket is 681 bytes at most, not 705: the arithmetic of §12.1 was right and its first sum wrong (corrected in place).
  • A ticket from the future is not a separate test. The first draft had age < -window beside the difference of the two ages; the mutant that removed it survived, because with a client age that is never negative the difference alone is more than the window whenever the server’s age is less than minus the window. The clause was dead code and is gone.
  • event and the tickets’ output (§12.6): the interop run hung one connection in six in the test harness, not in the engine; the harness is fixed and §12.6 says so.
  • The wrong-binder alert is decrypt_error here and illegal_parameter in OpenSSL (the differential, above); §12.3 f says which the RFC names.
  • The lying client’s own binder was wrong by one byte in its first version of the padded case (§12.12). The server was right to refuse; it was refusing for the wrong reason, and only a mutant that removed the right reason showed it.
  • After review (#394): a key listed twice in set_ticket_keys was placed twice, and when the ring was full the copy find met first could be a stale one, so a lifetime later the current key’s tickets were refused: a repeated key is now tls-server-ticket-key. save_to, save and forget on a server engine touched the server’s own slot fields (the name the client sent, the tickets’ randomness): they now answer 0, and start_with tls-role. The tickets bound the identity’s fingerprint and notAfter as they were at the client’s Finished, so a replace_identity between the ClientHello and the Finished bound a certificate the client was never sent: both are now recorded when the ClientHello is answered (b_srv_fp, i_srv_not_after). The class, state read at one message and trusted at a later one, has its own cases (midway: in scripts/tls_liar_tickets.py): the identity, the ticket keys and the clock changed between the server’s answer to the ClientHello and the client’s Finished, on a full and on a resumed handshake, each asserting the outcome and, by resuming one, what the tickets bind; and the client’s ticket calls made on the server engine at that point.
  • tls.ticket_keys was not in the design’s interface; the program that supplies keys wants to log how many open.
  • mosquitto does not resume (§12.12), against the issue’s hope that it would; it is the client’s, not this server’s.

12.15 Not done, and not verified

  • A resumption count or a rate limit. A ticket resumes as often as its client offers it, until it expires (§12.4); the engine counts nothing. A program that wants a single-use ticket keeps the binders it has seen, or does not send tickets.
  • Client certificates (#384). Tickets carry auth 0 and refuse anything else (§12.3 g); what a ticket of an authenticated client holds is #384’s design.
  • P-384, Ed25519 and RSA identities (step 7): a ticket binds a leaf’s SHA-256, which is key-type independent, so nothing here changes; not exercised.
  • 0-RTT: refused, not deferred (§2.2).
  • TLS 1.2 tickets (RFC 5077): step 6, if it is ever built.
  • Browsers: Chrome and Firefox were not run, here as in §10.9; their ClientHello carries psk_key_exchange_modes and resumes with the standard extension, and the interop matrix has OpenSSL, curl, Go, wolfSSL, GnuTLS and rustls.
  • Timing of the ticket path: no dudect test. The AEAD tag and the binder are compared with every byte examined; the ticket key is never an index or a branch; the rules compare public values. The key schedule from a PSK is the client’s.
  • Independent review: none (#209), as the engine.
  • A long soak. The fuzzers ran for the times in §12.12; no multi-day run.

13. Client certificates (mutual TLS): the design

Step 4 of §8, issue #384. Written before the code. Sizes are arithmetic from the layout below and are checked by the build (§13.11); costs are arithmetic from measured primitives and marked so, and §13.12 is where they are measured. The resumption rules (§13.8) are decided here against the design of session tickets (§12, issue #379), read from its branch (tls-server-tickets, 230decd); the built tickets (PR #394) arrived before this section’s code, which is therefore built on #394’s head and says so. Where building finds a statement here false, the section that made it is corrected in place.

What it is for. Many MQTT fleets authenticate a device by the certificate it presents, and a gateway can pass a verified client identity to the service behind it (cancho-mqtt PR #22, section 7d). The engine does one thing: it authenticates, the program authorizes. After a client has been handled, the engine can say “a CA of the store you gave me vouched for this key, for client authentication, at the time the handshake ran, and the client proved it holds that key”. It never says what that identity may do.

13.1 Modes

tls.client_auth_mode(engine, mode), on a server engine, before or between connections:

modethe servera client with no certificatea client with a certificate that does not verify
0 off (the default)sends no CertificateRequest: every byte of §5.2 is as it was, and the 110 recorded connections replay unchangedaccepted, no identityn/a: none was asked for, an unsolicited Certificate is tls-unexpected-message
1 optionalsends a CertificateRequestaccepted, no identity: peer_verified answers 0refused, with the alert of the chain’s fault
2 requiredsends a CertificateRequestrefused: certificate_required (116), tls-server-client-cert-requiredrefused, as above
  • Optional is not “verify if convenient”. A certificate that is presented and wrong ends the connection in both modes (RFC 8446 §4.4.2.4 lets a server continue only without a certificate, “at its discretion”; openssl s_server -verify does the same once it is given -verify_return_error, measured in §13.16, without which it goes on after a verification error). A program in optional mode must test peer_verified before it trusts an identity: no identity is not an error is the point of the mode.
  • The mode is per engine, taken when a connection is served. serve copies it into the slot’s flags, so changing the mode does not change a handshake in progress. A mode per identity or per host name (a gateway that wants certificates on api.example and not on www.example) is not in version 1: the workaround is two engines on two listeners, and the asker that makes it worth a field in the identities (§4) has not come. Decide when one does.
  • A mode other than off needs a store. client_auth_mode(engine, 1 or 2) before any trust_clients is refused, tls-server-client-store, so a program cannot ask for certificates and trust nothing. (A store is never emptied once loaded: §13.3.)

13.2 What the server sends, and reads

CertificateRequest (RFC 8446 §4.3.2), in the server’s encrypted flight, after EncryptedExtensions and before Certificate, added to the transcript like the rest (so the server’s Finished covers it):

fieldvaluewhy
certificate_request_contextemptypost-handshake authentication is not offered (§13.9); the context is only for it
signature_algorithms (13)ecdsa_secp256r1_sha256 (0x0403), ecdsa_secp384r1_sha384 (0x0503), rsa_pss_rsae_sha256/384/512 (0x0804, 0x0805, 0x0806), ed25519 (0x0807)the schemes the server verifies for CertificateVerify: one per key type of §13.4. Not rsa_pkcs1_* (forbidden in TLS 1.3 for CertificateVerify), not ecdsa_secp521r1_sha512 (no P-521 verifier in check_signature), not rsa_pss_pss_* (packages/x509 reads rsaEncryption keys only)
signature_algorithms_cert (50)the six above and rsa_pkcs1_sha256/384/512 (0x0401, 0x0501, 0x0601)the schemes the CA signatures of the chain may use. Without it the first list governs the chain too (RFC 8446 §4.2.3), and a client whose intermediate is signed sha256WithRSAEncryption, which x509_verify accepts, would be told not to send it
certificate_authorities (47)the subject Name (DER) of each root of the store, when the list is at most 8,192 bytes, else the extension is left outthe client picks the certificate it holds that one of these issued (Go’s GetClientCertificate default does; OpenSSL, wolfSSL and mosquitto send theirs regardless). It is a hint: the verification of §13.5 is the check. Left out when large, as RFC 8446 §4.2.4 leaves it optional. It tells any peer that connects the names of the CAs; they are not secret, and the server’s own chain names its CA already.
oid_filters (48)not sentthe extension filters certificate extensions by value; no client in the interop matrix reads it, and the checks it could express (EKU, key usage) are made by the verifier

The CertificateRequest is at most 4 + 1 + 2 + 18 + 24 + (6 + 8,192) bytes, 8,247, and the flight’s buffer grows by that (respond sizes it from tls_identity.chain_cap()).

The client’s flight, read in the states wait_client_certificate and wait_client_verify that precede wait_client_finished when a request was sent:

  1. Certificate (RFC 8446 §4.4.2). Its certificate_request_context must be empty (non-empty: tls-server-illegal-parameter, the alert illegal_parameter); a CertificateEntry extension is tls-unsupported-extension (none was asked for); the message is at most 16,384 bytes, checked when its header arrives, before it is read (x509-chain-too-large, the alert bad_certificate); at most 5 certificates (tls_message.certificate’s bound of 8 is the client’s; here the leaf, up to 3 intermediates and the root) and max_intermediates is 3.
    • An empty certificate_list is the client saying it has none. optional: the connection goes on to Finished with no identity. required: tls-server-client-cert-required, alert 116.
    • Any other message in this state, Finished included, is tls-unexpected-message: a client must answer the request with a Certificate, empty if it has none (RFC 8446 §4.4.2).
  2. CertificateVerify (§4.4.3), only after a non-empty Certificate: any other message there (a Finished, or a CertificateVerify after an empty Certificate) is tls-unexpected-message. The signature is over 64 spaces, TLS 1.3, client CertificateVerify, a zero byte, and the transcript hash through the client’s Certificate (the server’s flight, with the CertificateRequest, and the Certificate just read).
    • The scheme must be one the request listed, else tls-server-client-sigalg, the alert illegal_parameter (RFC 8446 §4.2.3); a scheme that is listed but does not fit the key (ECDSA P-384 key, ecdsa_secp256r1_sha256) is tls-bad-certificate-verify, alert decrypt_error, as tls_slot.check_signature answers it for the client.
    • The signature is checked by tls_slot.check_signature, the code the client runs on the server’s: ECDSA over P-256 and P-384 (std.ecdsa), RSA-PSS (std.rsa), Ed25519 (std.ed25519), all verifiers, no secret touched (§4.2 of docs/tls-pure.md: the right-hand column).
  3. Finished as before (§10.1, constant time), with the MAC over the transcript through the client’s CertificateVerify, which is what RFC 8446 §4.4.4 says and finished_mac already computes from the running transcript: the two new messages are added to it as they are accepted. The application secrets were derived at the end of the server’s flight, from the transcript through the server’s Finished (§7.1), as they must be; nothing there changes.

13.3 The trust store

  • Its own call, its own bytes. tls.trust_clients(engine, pem) -> roots | refusal replaces the client store with the certificates of a PEM bundle. It is not tls.trust: that call is the client engine’s, for the roots of servers, with room for the system bundle (1 MiB); on a server engine it is refused today (tls-role, §5.1) and stays refused. Letting trust mean “the clients’ roots” on a server engine would put the one call that takes the system bundle next to the one that must never take it (docs/x509-verify.md §10.3: public CAs have issued certificates with both serverAuth and clientAuth, and 4 of the 128 system roots carry an EKU that would refuse every client certificate under clientAuth). trust_clients on a client engine is tls-role.
  • The engine cannot tell a public CA from a private one, and says so; two tripwires make the mistake loud. The store holds at most 32 roots and 32,768 bytes (x509_verify.store_load’s format; 128 roots of the system bundle take 138,350 bytes, §8.2 of docs/x509-verify.md). Corrected while building: the design said 65,536 bytes. The store is staged in a region before it replaces the old one (a refused bundle must leave the store as it was), and a region chunk is 64 KiB: an alloc_slice of 65,536 traps (docs/websocket-spike.md §4). 32 KiB holds 32 roots of up to 1 KiB. A bundle over either is refused whole, tls-server-client-store, so pointing the call at /etc/ssl/certs/ca-certificates.crt fails at the first call. It is a heuristic, not a guarantee (32 public roots would fit), and the documentation says it is one.
  • Strict, unlike trust. trust skips a block it cannot read, so one odd root does not lose the rest (docs/x509.md §5.3). A client store is a file an operator wrote for this purpose: a block that is not a certificate this package reads (a private key pasted into the file, a certificate with an algorithm the verifier refuses) is a mistake to report, not to skip. trust_clients refuses it, tls-server-client-store, and the store is as it was. A bundle with no certificate is refused too, so the store is never empty after the first successful call.
  • Replacement at run time, as replace_identity (§4, §11.2): trust_clients again replaces the store and bumps tls.client_generation(engine), which counts successful replacements (0: none yet). A handshake reads the store when its Certificate is handled, not when it began: a CA removed from the file stops vouching for every handshake that has not yet verified its chain, including ones in progress. (replace_identity lets a started connection keep the old identity because the certificate it was sent is already on the wire; nothing is on the wire here but the CA names, a hint.) A connection that has verified keeps its verdict, and records the generation it was verified under: tls.peer_verified(engine, slot) answers it (§13.6). A program that revokes by replacing the store walks its slots and closes those whose generation is older than client_generation. The example does (§13.10). Added after review (§13.22): a handshake whose chain verified under one generation and whose Finished arrives under another is refused, tls-server-client-store-replaced, so a client cannot hold its Finished past a replacement and be established under the store it replaced. The generation saturates at 2^32 - 1 rather than wrapping to 0, which peer_verified reads as “no identity”.
  • Revocation is not checked (docs/tls-pure.md §4.1: out of scope, and the client says the same of servers): a client whose certificate was revoked is accepted until it expires or the CA leaves the store. The program’s answer is the one it must have anyway: a deny list on the identity it is given (the fingerprint, §13.6), which applies to every connection, resumed ones included (§13.8).
  • Where it lives. In the engine’s cfg slice, tls_identity’s, after the 16 identities: the mode (1 byte), the generation (4), the store’s length (3), and the store (32,768). cfg is already what every feed is given, so no signature changes. +32,776 bytes an engine (a server engine is 274 KiB of identities already), allocated by open_server only; a client engine and every slot are unchanged.

13.4 The key types, and what is checked on the chain

Accepted client keys, each with its CertificateVerify scheme: ECDSA P-256 (ecdsa_secp256r1_sha256), ECDSA P-384 (ecdsa_secp384r1_sha384), RSA (rsa_pss_rsae_sha256/384/512; rsa.work_len() bounds the modulus, x509-key-size for one it refuses), Ed25519 (ed25519). Refused, with x509-unsupported-algorithm (alert 43): a P-521 key, an RSASSA-PSS-OID key, any other.

The chain is x509_verify.verify_chain(store, certs, ranges, now, 3, purpose_client_auth(), leaf) (docs/x509-verify.md §10): the path to a root of the store, every signature, every validity, cA and pathLen, keyUsage digitalSignature and EKU clientAuth (the leaf, every intermediate, and the root when it has one; anyExtendedKeyUsage alone is refused), and name constraints over every SAN. No name is checked: there is none to expect. leaf is the view of the verified certificate, from which the identity of §13.6 is read; on a refusal it is zeros (§10.3), so a program that ignored the answer would read nothing. The refusals are the verifier’s tags (x509-expired, x509-not-yet-valid, x509-unknown-issuer, x509-key-usage, x509-name-constraint, x509-path-too-long, x509-bad-signature, x509-not-ca, x509-chain-too-large, x509-decode) with their alerts (tls_slot.alert_for: unknown_ca 48, certificate_expired 45, unsupported_certificate 43, bad_certificate 42), unchanged.

A leaf that is itself in the store (a pinned device certificate, self-signed) is a question for the verifier, not for this design; §13.11’s tests measure what verify_chain answers and this section records it.

13.5 The clock

The chain is checked at the time the program gave serve (now_unix_ms, its clock_unix_ms), kept in the slot as seconds. A handshake that has waited in the program’s queue is served when it starts (§11.2), so the time is the handshake’s, to within the program’s handshake deadline; the engine has no clock of its own (§5.3). Validity is exact (notBefore <= now <= notAfter), as the verifier does everywhere: no skew allowance. A program whose devices have wrong clocks and present certificates “not yet valid” sees x509-not-yet-valid and the alert certificate_expired (RFC 8446 §6.2 has no other for it), and fixes the device or the CA’s notBefore. When tickets land, the clock is the later of this and tls.set_time (§12.7).

13.6 The verified identity, given to the program

An established connection (and one closed after it, for a program that logs on close; never a failed one) can be asked, in the program’s own slot:

tls.peer_verified(engine, slot) -> generation | 0   // 0: no client certificate (off, or optional and none sent)
tls.peer_certificate(engine, slot, out) -> n        // the leaf's DER; n <= 16,384; out too small: tls-server-peer-buffer
tls.peer_subject(engine, slot, out) -> n            // the subject Name's DER (SEQUENCE of RDNs), 0 if the leaf has none
tls.peer_serial(engine, slot, out) -> n             // the serial number's content bytes
tls.peer_fingerprint(engine, slot, out) -> 32       // SHA-256 of the leaf's DER, computed here, not by the client
tls.peer_not_after(engine, slot) -> seconds         // the leaf's notAfter
tls.peer_san(engine, slot, at, entry) -> next | 0   // subjectAltName one GeneralName at a time, x509_verify.san_next
                                                    //   entry[0] tag, entry[1], entry[2] the content's range in peer_certificate's DER
tls.client_generation(engine) -> n                  // §13.3
  • Zero bytes of slot. The leaf is kept in the slot’s b_leaf (16,384 bytes, which a server slot never used: it holds the server’s leaf for a client) and its length in i_leaf_len; the view is parsed again for each call (x509.parse, once a call). So no slot grows, and the chain buffer is the handshake reassembly buffer the slot already has (64 KiB, hs_cap), bounded by §13.2’s 16,384 and five certificates. The only allocation is the verifier’s own region, freed when the message is handled: the message copy (at most 16,388 bytes), the ranges, a 40-word view a certificate, the signature work area. Corrected by docs/tls-memory.md §11: after #395 b_leaf is in the work area, and keeping the leaf there kept the area for the connection’s life: a verified idle tls_echo connection was 126.0 KiB resident (arm64; 127.7 on x86-64) at 10,000 connections where one without a client certificate is 17.0, and --areas ended it record-timeout. A leaf of up to 2,048 bytes is now copied, when the client’s Finished has verified, to a room of its own for the slot (2,048 bytes of address space a server slot, written only for a verified connection), and the area goes back; a larger leaf keeps the area as before.
  • peer_verified is the gate. Every other call answers 0 bytes when it is 0 or the connection is not established (or closed after establishment), so an optional connection with no certificate, a failed one and a slot that was reused read nothing: start zeroes the length, and the leaf’s bytes are never read past it.
  • The subject and the SANs are the CA’s words. The engine adds nothing: no common name, no “identity string”, no decision. A function that picks a common name out of the Name is docs/x509-verify.md §10.1’s “until a program asks”; examples/tls_echo has one (a Name printer for its log line, §13.10), and if the broker and the gateway both copy it, it moves into packages/x509.
  • The authority of the SAN is the store’s. A name constraint on the CA limits the SANs it can vouch for (§10.3), but a CA with no constraint vouches for any subject: a program that authorizes by subject or SAN must trust each CA of its store for every subject it could carry. The fingerprint is the identity that does not depend on that.
  • Authentication is not authorization (§13 opening): the program decides what peer_fingerprint, a SAN or a subject may do, on every connection, resumed ones included.

13.7 The refusal tags

New, besides the verifier’s x509-* (§13.4) and the existing tls-* that the flight can also raise:

tagalertwhen
tls-server-client-cert-requiredcertificate_required (116)required, and the client’s Certificate has no certificate
tls-server-client-sigalgillegal_parameter (47)the client’s CertificateVerify names a scheme the CertificateRequest did not list
tls-server-client-storenone: trust_clients’s or client_auth_mode’s answera bundle with no certificate, a block that is not one, over 32 roots or 32,768 bytes; or a mode asked for with no store
tls-server-client-auth-confignone: client_auth_mode’s answera mode other than 0, 1, 2
tls-server-peer-buffernone: peer_certificate’s answerout shorter than the certificate
tls-server-client-store-replacedcertificate_unknown (46)the store was replaced (trust_clients) after the client’s Certificate verified and before its Finished (§13.22)
tls-rolenoneclient_auth_mode, trust_clients or a peer_ call on a client engine

Reused unchanged: tls-server-illegal-parameter (a non-empty request context), tls-unsupported-extension (a CertificateEntry extension), tls-decode-error (a Certificate or CertificateVerify that does not parse), tls-unexpected-message (the wrong message, or a Certificate nobody asked for), tls-bad-certificate-verify, tls-server-finished, and x509-chain-too-large for the message, certificate count or leaf over their bounds. The alert mapping of tls_slot gains the two alerts above; every other refusal maps as it does.

13.8 Resumption (§12): the decision

A resumed session skips verification: the client proves it holds the PSK, which a connection that was verified made (docs/tls-resumption.md §3’s hazard, seen from the other side). On the client side a ticket is a stored verdict about a server, and eight rules say when the verdict is stale. For a server the verdict is about a client, and it matters more: the identity is what the program authorizes with, and a ticket that carried a different identity, or a stale one, is an authorization bug. The rules, with the client’s number each mirrors:

rulethe client’s rule
R1No ticket carries a client identity unless the program asked for it. A connection whose client was verified is issued tickets only if tls.set_client_tickets(engine, on) was called (a #379 call; off by default). With it off, the server sends no NewSessionTicket on that connection, and a client that returns makes a full handshake and presents its certificate again. This is the default #379’s rule g sets (§12.3), and it is what main has: tickets do not exist yetrule 2 (the trust store), the hazardThe cost of the safe default is the whole point of §6 for authenticated peers: they do the signature and the chain, 4 to 8 ms on x86-64. An MQTT device that connects once a day does not mind; a gateway whose clients reconnect in bursts does, and turns it on
R2The ticket holds the identity, not a pointer to one. With set_client_tickets on, the sealed ticket (§12.1) carries auth = 1, the client’s leaf DER, verified_at, and the client store’s digest (R3). A resumed connection reads its identity from the ticket’s leaf with the same code that reads a verified one (b_leaf, the same peer_ calls), so the program is given byte for byte the identity of the connection that was verified, or the resumption is refused: there is no third thing, and a ticket cannot name an identity the server did not verify, because it opens only under the ticket keythe host name is kept beside the ticket (§11)A leaf over 1,024 bytes is not carried: the ticket would be at most about 1.8 KiB (§12.1’s 681-byte largest, plus the leaf, a time and a digest) of the 2,048 the packages/tls client keeps (docs/tls-resumption.md §4), and no NewSessionTicket is sent for that connection (the verdict is client-too-large, not an error). Measured leaf sizes belong in §13.12 (a P-256 leaf with one SAN: about 400 bytes)
R3Refused after the client store changed. The ticket carries SHA-256 of the store’s loaded bytes (the digest, 32 bytes, not the generation). A resumption is a full handshake when the digest of the store now differsrule 2A counter would be per process: a fleet sharing a ticket key (§12.2) that loads the same file would refuse each other’s tickets after any one restart. A digest changes exactly when the file’s roots do, and a reload that found the same file refuses nothing. tls.client_generation (§13.3) is for the program’s own use
R4Refused when the client’s certificate has expired, and a ticket never outlives it: its expiry is capped at the leaf’s notAfter, as §12.3 c caps it at the server certificate’srule 3
R5Bounded from the verification, not from the ticket. The ticket’s life is also capped at verified_at + client_max_age (default 1 hour, tls.set_client_ticket_max_age), where verified_at is the time of the full handshake that verified the client; a resumed connection’s new tickets inherit it. A chain of resumptions never outlives the verification it started fromrule 4 and its “as built” correctionThere is no revocation (§13.3); this is the window
R6Required means required. In required mode a ticket with auth = 0 (issued when the mode was off or optional and the client sent none) is not accepted: full handshake, which asks for the certificate. In optional either is accepted, and a resumed auth = 0 connection has no identity, as the original had none. In off a ticket with auth = 1 is not accepted either (auth): a program that turned client authentication off asked for connections with no identity, and peer_verified does not answer non-zero for onethe client’s refuse-by-rule shapeThe auth verdict is the one #379’s rule g already reserves
R7No early data, as §12.3 b: a resumption never accepts 0-RTT, so nothing the client authenticates is replayablerule 6
R8A replay is harmless for identity, as §12.4: the ClientHello’s binder proves the PSK, and the handshake keys need the client’s ephemeral key, so a recorded resumption yields no session and no identityrule 5The identity is given to the program only once Finished has verified
R9A different identity on a ticket is not a thing: a resumed connection does not run verify_chain and does not read a certificate from the client, so the client cannot change who it is by what it sends after the ClientHellorule 8
  • What is built, and what is not (as built, on #394’s head). Built here: R1 (no ticket to a client that presented a certificate: issue_tickets sends none when tls_clientauth.certified), R6’s required half (with client certificates required, a ticket is not accepted: try_resume answers the verdict auth, the existing tls-ticket-auth of §12.7, and the handshake is a full one that asks for the certificate), R7, R8 and R9 (nothing new is needed: §12.3 b and §12.4 hold, and a resumed handshake runs no verify_chain), and one rule §13.8 did not state: a resumed handshake sends no CertificateRequest (RFC 8446 §4.3.2: a server that has chosen a PSK must not), so optional on a resumption means no identity, as the ticket’s connection had none (serve set the flag; the resumption clears it). Not built: R2 to R5, the opt-in that lets a ticket carry the client’s identity (tls.set_client_tickets): there is no such call, so there is no ticket with auth = 1 and off mode’s half of R6 has nothing to refuse. The three cases of the recording that test the built rules (§13.16) fail if the hook is removed, as three mutants show.
  • The interface the opt-in will need (when a program asks for it): plaintext gains client_len (2) and the DER after it; auth takes 1 only when the call is on; ticket_verdict gains client-store, client-expired and client-too-large. A ticket with auth != 0 is refused today (auth), which is already §12.3 g.
  • What the program sees on a resumed connection is the same calls (§13.6), and they answer “no client certificate”: a resumed connection has none, until the opt-in is built.

13.9 What is not offered

  • Post-handshake authentication (RFC 8446 §4.6.2). The post_handshake_auth extension (49) in a ClientHello is ignored (a server need not answer it), and the server never sends a CertificateRequest after the handshake. A Certificate, CertificateVerify or Finished message from the client on an established connection is tls-unexpected-message (on_message answers it for every type it does not expect). So a program that wants a client certificate for one URL of many closes and reconnects, or runs a second listener (§13.1).
  • Renegotiation: TLS 1.3 has none; a ClientHello on an established connection is tls-unexpected-message, as today.
  • TLS 1.2 client certificates: there is no TLS 1.2 server (§2.2).
  • A certificate chosen by SNI: the request is the same for every identity (§13.1).
  • OCSP, CRLs: §13.3.

13.10 The examples

examples/tls_echo and examples/https_hello gain:

--client-ca <name>        a file of the client CAs' PEM certificates, beneath --dir (tls_echo, https_hello)
                          or in the fixed directory (tls_echo_fixed); mode `optional`
--require-client-cert     mode `required` (with --client-ca)

and each connection line gains client=<subject> (an RFC 4514-style rendering of the verified subject’s RDNs, ASCII only, anything else escaped, - when there is none) and fp=<first 16 hex digits of the fingerprint>:

conn 7 peer=127.0.0.1:51109 established suite=TLS_AES_128_GCM_SHA256 group=x25519 sni=echo.lex-sys.test alpn=echo hrr=no client=CN=device-17 fp=3fa1c0de9b21aa07 waited=0 handshake=7
  • The authority report does not change. The file is read beneath the Dir the program already holds (file_read, dir_read), by the same function that reads an identity’s files, and tls_echo_fixed reads it from the directory its one narrow(fs, "/dev/urandom", "/etc/cancho/tls_echo") already names. The report’s label set is what §11.1 pins. conformance/tls_echo.rs’s pinned list is unchanged; a test asserts it.
  • SIGHUP reloads the store with the identities: trust_clients again, a refused file logged (reload client-ca refused tls-server-client-store) with the old store still serving; after a successful change the loop closes every established connection whose peer_verified is older than client_generation (closed revoked), because a program that replaces the store to revoke means it.
  • The log line is the program’s, not the engine’s: the engine gives bytes; the example formats them.

13.11 How it will be tested

  • Interop (corrected as built: its own script, scripts/tls_server_clientauth_interop.py, which uses tls_server_interop.py’s authority, server and image): a CA, client certificates in every key type and chain length, required and optional servers: openssl s_client -cert/-key, curl --cert/--key, Go tls.Config.Certificates (scripts/interop/go_client.go gains a client certificate), wolfSSL (wolfssl_client.c gains wolfSSL_CTX_use_certificate_chain_file), mosquitto’s --cert/--key, in modes off, optional, required, with chains of one and two certificates, P-256, P-384, RSA-PSS (RSA-2048) and Ed25519 keys. packages/tls‘s own client cannot present a certificate yet (#386); its row is added when it can. Each row: the client completes, and the server’s line carries the identity the row’s certificate has, equal to what OpenSSL reports for the same certificate (openssl x509 -fingerprint -sha256; the SANs from -ext subjectAltName, in order; the subject’s DER from pyca/cryptography, which parses the same bytes; and, in the examples’ tests, client= against -nameopt RFC2253).
  • The lying client (scripts/tls_liar_client_auth.py, the shape of tls_liar_client.py, which it imports; the driver gains G, H, P): one case per rule: no certificate when required, an empty Certificate when optional (accepted, no identity), a wrong CertificateVerify (a flipped bit; a signature over another transcript; the server’s context string), a wrong scheme (not listed), a listed scheme that does not fit the key, an untrusted CA, an expired leaf, a not-yet-valid one, an EKU of serverAuth only, a key usage without digitalSignature, a name constraint violated, a chain over each bound (6 certificates, a message over 16 KiB, a leaf over 16 KiB), a self-signed leaf, a certificate for a different key than the one that signs, a CertificateVerify without a Certificate, a Finished in place of the Certificate, a non-empty request context, a CertificateEntry extension, a duplicate Certificate, a Certificate after Finished, handshake bytes replayed from another connection, the store replaced between the request and the Certificate (refused) and after establishment (kept, generation older), and the honest ones for each key type.
  • A differential against openssl s_server (-Verify 5 for required, -verify 5 -verify_return_error for optional; corrected as built: its own script, scripts/tls_server_clientauth_differential.py): the client’s flight is built from the server’s, so the harness plays a live client against both servers (the lying client’s logic over a socket) and compares the outcome (established or the alert) for each malformed flight; an EXPECTED table for the cases the RFC allows either.
  • Fuzz: fuzz_server and its corpus gain client flights (a full honest handshake with a certificate: the server’s random is fixed by the harness’s seed, so the client’s bytes replay); scripts/tls_fuzz.py --server mutates the client-auth cases; fuzz_hello is unchanged (the ClientHello is). The corpus is a regression test. Corrected as built: fuzz_server cannot reach the client’s flight, which is encrypted under keys the fuzzer’s bytes would have to derive, so a mutated ciphertext is a bad_record_mac and the Certificate parser is never reached. The new harness fuzz_clientauth calls the handlers (tls_clientauth.on_certificate, on_certificate_verify) with the fuzzer’s bytes as the message, over a slot in the state serve leaves it in; fuzz_server serves with client authentication on for inputs of length 2 or 3 mod 4, which reaches the CertificateRequest and the new states; tls_fuzz.py --server mutates the lines of the client’s bytes of the honest client-certificate connections too, and a mutated ciphertext there is the same bad_record_mac, so its value is the framing, not the certificate.
  • Mutants (scripts/tls_server_mutants.py, which now replays both recordings): one per decision above (the purpose passed to the verifier, the context string, the transcript the signature covers, the optional/required test, the sigalg list test, the strict store, the 16 KiB bound, the certificate count, the identity gate, max_intermediates), all killed or argued equivalent.
  • The existing suites unchanged: the 110 recorded connections replay byte for byte (mode off adds no byte), the client’s suites, x509_matrix.py, publish_packages.py --check.

13.12 Cost and memory

Arithmetic from measured parts first; the measurement is §13.17. A client-authenticated full handshake does one more chain verification and one more signature verification than §6’s: ECDSA P-256 verification is 0.82 ms on the M4 and 1.5 ms on the i7 (docs/ecdsa-sign.md §7), so a client leaf signed by a root of the store, with a P-256 key: two verifications, 1.6 ms (M4) to 3.0 ms (i7); with an intermediate: three, 2.5 to 4.5 ms. The issue’s “about 1.6 to 2.5 ms” is the M4’s. The handshake rate bound of §11.2 (100 a second) is then a larger share of a core for authenticated peers; the example’s --rate default is not changed, and a program with certificates sets it from its own figure.

Memory: per slot 0 bytes (§13.6); per engine +32,776 bytes (§13.3); the flight’s transient buffer +8,247. Corrected by docs/tls-memory.md §11: 0 bytes of slot layout, but a verified connection kept its work area; since §11 a server engine has 2,048 bytes of address space a slot for a verified client’s leaf, resident only where one is kept. As built: a test asserts the three sizes (L in tests/programs/tls_server_driver.cho, the case memory of the recording): a slot’s ints are 10,245 words and its bytes 187,703 (10,241 and 187,191 before #396 gave the client its own certificates), both exactly what they are on main without this change (measured by building the same program against origin/main’s packages/tls and against this tree’s), and the engine’s cfg is 280,690

  • 640 (the tickets’ fingerprint and notAfter in each of the 16 identities, §12.10) + 32,776 = 314,106 bytes.

13.13 to 13.22: as built

PR #384’s. Numbers are measured, each with the command that gives it.

13.13 What was built

FileModuleWhat
packages/tls/clientauth.chotls_clientauthnew, 199 lines: begin (the mode and the clock at serve), the CertificateRequest, on_certificate (the empty list, the bounds, verify_chain for clientAuth, the leaf kept in b_leaf), on_certificate_verify, established, and the identity (verified, generation, certificate, fingerprint)
packages/tls/identity.chotls_identitythe engine’s configuration gains the clients’ store after the 16 identities: the mode, the generation, load_client_store (strict, all or nothing), client_authorities
packages/tls/hello.chotls_hellocertificate_request and the six schemes of a client’s CertificateVerify
packages/tls/server.cho, slot.cho, record.cho, tls.chothe flight with the request, the states wait_client_certificate and wait_client_verify, the dispatch, the client’s Certificate and CertificateVerify sharing a record with what follows, the alerts, five tags, and tls.client_auth, trust_clients, client_generation, peer_verified, peer_certificate, peer_subject, peer_serial, peer_fingerprint, peer_not_after, peer_san

No file is over 2,000 lines (the largest, examples/tls_echo/front.cho, is 1,311). scripts/publish_packages.py republished the stores. A new module, not a growth of server.cho, so that session tickets (§12), which edit server.cho, hello.cho, identity.cho, slot.cho and tls.cho, meet this change in as few places as the shared flight allows.

13.14 Where building found the design wrong

  • The store is 32 KiB, not 64 (§13.3, corrected in place): it is staged in a region, whose chunk is 64 KiB.
  • “Strict” needed a count (§13.3): x509_verify.store_load skips a block pem_next does not return as a certificate, and does not count a PRIVATE KEY block, or one cut short, as skipped. The store is refused unless the number of -----BEGIN markers equals the number of roots stored.
  • openssl s_time cannot measure a chain (§13.17): it sends the leaf alone, so a chain of two is refused with x509-unknown-issuer while s_time counts the handshake as complete. The cost script uses Python’s ssl and an echo.
  • Go honors certificate_authorities (§13.2): a stranger’s certificate no listed CA issued is not sent, so the server sees a client with none (certificate_required when required, an anonymous connection when optional). It is the hint working.
  • A pinned device certificate is not a CA (§13.4): a self-signed leaf with no basicConstraints in the store is x509-not-ca; the same certificate with cA set is its own anchor and is accepted. OpenSSL, openssl s_server -CAfile, accepts both: §13.20’s first question.

13.15 Interop

python3 scripts/tls_server_clientauth_interop.py <tls_serve>, in the image of §10.2 (Ubuntu 24.04, linux-aarch64, in Docker on the M4 Max): tls_serve with --client-ca <file> required, with optional, and with none, and the clients presenting a certificate of each of five kinds: P-256 under the CA, P-256 under an intermediate (a chain of two), P-384, RSA-2048 (RSA-PSS) and Ed25519. Each leaf has a country, an organization and a common name, and four kinds of subjectAltName. 75 rows, 75 ok:

ClientVersionRowsWhat
openssl s_client -cert -key [-cert_chain]OpenSSL 3.0.13155 kinds x required and optional; no certificate refused (tls-server-client-cert-required, certificate_required) when required and accepted with no identity when optional; a stranger’s refused (x509-unknown-issuer, unknown_ca) in both; a certificate offered to a server that asks for none: accepted, no identity
curl --cert --key8.5.0, on OpenSSL 3.0.1315the same
Go tls.Config.Certificates1.22.215the same, but a stranger’s certificate is not sent (above)
wolfSSL wolfSSL_CTX_use_certificate_chain_file5.6.615the same
mosquitto --cert --key2.0.1815the same, each row mosquitto_sub and mosquitto_pub

A row passes when the client completes and its data comes back, and the identity tls_serve prints for the connection (what the program is given through tls.peer_*) is what OpenSSL reports for the same certificate: the fingerprint is openssl x509 -fingerprint -sha256’s, the SANs are openssl x509 -ext subjectAltName‘s in order, and the subject’s DER is the certificate’s. The 75 rows ran before this work was put on #394’s head (the engine’s client-certificate code is the same). On the merged tree, on x86-64 (gram: Ubuntu, OpenSSL 3.5.5, curl; no Go, wolfSSL or mosquitto there) the OpenSSL and curl rows: 30 of 30; CI’s tls-assurance job runs all five clients on it. The examples’ tests (below) compare the log line’s client= with openssl x509 -nameopt RFC2253, for a subject with a comma, quotes, a +, a leading # and a trailing space, a ;, <, >, a backslash and a non-ASCII letter: they agree. Not run: packages/tls‘s own client (it cannot present a certificate: #386), Firefox, Chrome, Windows’ schannel.

13.16 The lying client, the differential, fuzzing, mutants

  • The lying client (python3 scripts/tls_liar_client_auth.py <driver> <out.txt>): 81 connections, replayed byte for byte on both backends by conformance/tls_client_auth.rs (the honest ones again with the client’s bytes fed one byte a line, and the identity compared). 32 end ok: the honest connections (5 key types, the three RSA-PSS schemes, chains of one, two and three certificates, a leaf with four kinds of SAN and a serial with its top bit set, a leaf with none, the flight in one record, after a HelloRetryRequest, with post_handshake_auth offered and ignored, with ChaCha20 and no change_cipher_spec, optional with and without a certificate, off, a store of two CAs, a store whose names are exactly 8,192 bytes and one of 8,193, a name constraint satisfied, the store replaced after the connection, a device that is its own CA, a replacement refused while the client held its Finished) the memory sizes and the three cases of §13.8 (a client with a certificate is sent no ticket; an anonymous client’s ticket resumes with no CertificateRequest and no identity; a ticket from an optional connection is refused when certificates are required, the client presenting its own); the other 49 each end with their tag (six of them on a configuration call) and, for a connection, the alert §13.7 names. One case per rule of §13.2 to §13.4, and one for each item of the task’s list: no certificate when required; an empty Certificate when optional; a wrong CertificateVerify (a bit flipped, another transcript, the server’s context string, another key’s); a scheme the request did not list and one that does not fit the key; an untrusted CA; expired; not yet valid; EKU serverAuth only; a key usage without digitalSignature; a name constraint violated; a chain over each bound (six certificates, four intermediates, a message over 16 KiB, a header saying 16,385 bytes); a self-signed leaf; a certificate for a key other than the one that signs; a CertificateVerify without a Certificate; Finished for the Certificate; a request context; a CertificateEntry extension; a duplicate Certificate; a Certificate after the handshake; the flight replayed from another connection; the store replaced between the request and the Certificate, and between the CertificateVerify and the Finished (by the same CA and by another, §13.22); the engine’s clock moved past the leaf’s notAfter after serve. Every tag of §13.7 is reached, and the recording regenerates byte for byte on arm64 (pyca/cryptography 41) and x86-64 (46): the CAs are Ed25519, the client’s ECDSA nonce is hashed from key and message, its RSA-PSS salt is fixed.
  • The differential (python3 scripts/tls_server_clientauth_differential.py <tls_serve>): the client’s flight depends on the server’s, so the lying client plays 45 cases live against tls_serve and openssl s_server -Verify 5 (-verify 5 -verify_return_error for optional), outcomes compared: 34 agree, 8 differ in the alert only, 3 differ as EXPECTED says, 0 otherwise, the same on OpenSSL 3.0.13 (arm64) and 3.5.5 (x86-64). The alerts that differ: a request context (47 here, 50 there), a certificate that is not DER (42, 50), a message over 16 KiB (42, 50), a P-521 key (43, 10), a name constraint (42, 46), a scheme that fits the key’s family but not its curve (51, 47) and an Ed25519 key under an ECDSA scheme (51, 47), a leaf not yet valid (45, 42). The three that differ in the outcome are this server’s policy: more than five certificates, more than three intermediates (OpenSSL at depth 5 accepts them), and a non-CA self-signed leaf pinned in the store (§13.14).
  • Fuzz. fuzz_clientauth (§13.11), AFL++ 4.09c, one core each, 0.42 h each: 773,208 executions of clientauth (1,125 of 4,655 edges) and 330,362 of server (1,368 of 11,532, with client authentication on for a third of its inputs): no crash and no hang. The queue was minimised to 249 inputs, committed beside the 23 seeds (a good Certificate and CertificateVerify for each key type, and the lying client’s refusals); conformance/tls_fuzz.rs runs them on both backends. tls_fuzz.py --server (§13.11), on the tree with the tickets: 20,000 mutated connections over 48 recorded handshakes, 0 traps (x86-64), and again after the rebase on #394’s head of 2026-10-08 (commit ecc5772c): 20,000 over 107, 0 traps. The AFL++ counts above are of the tree before that rebase and were not repeated at the review’s head; CI’s tls-assurance runs each harness two minutes at every head (§13.22).
  • Mutants. python3 scripts/tls_server_mutants.py <cancho> [--shard k/n] replays the recordings of §10.3, §12 and this section: 211 mutants, 208 killed, 3 equivalent (argued in the script), 0 survived on main after #394, #395 and #396 (five shards: three on the M4, two on gram; one mutant, the certificate given whether or not verified, survived the first run on #395’s areas and is killed by a check added to tls_pool.cho clientcert, run again; the output is committed, tests/vectors/tls/client_auth/mutants.txt; since docs/tls-memory.md §11 the file is that change’s run: 227 mutants, 223 killed, 4 equivalent). When the recordings do not tell a mutant apart, the script also builds and runs tls_pool.cho clientcert against it. 146 are main’s and 65 are new: the purpose, the context string, each message’s place in the transcript, the optional/required test, the bounds, the strict store, the identity gate, the states, the alerts, each tls-role refusal, the clock, and the three hooks of §13.8. The three equivalent: a leaf over the room (the message bound already holds it), a block store_load skipped (the count of -----BEGIN markers already refuses it), and the clock begin stores (through the engine the slot’s clock is never behind it).

13.17 Cost

python3 scripts/tls_server_clientauth_cost.py <tls_serve> 10 5: the method of §6 and §11.4 (the server’s CPU time from /proc/<pid>/stat, or ps, divided by the handshakes completed) with another load: Python’s ssl, because openssl s_time sends the leaf of a client certificate and never the intermediate behind it (§13.14) and counts a handshake complete before the server has judged the client. Each handshake is a TLS 1.3 connection with an X25519 share, a byte out and back (so the server has accepted the client), and a close; 10 s a cell, 5 rounds with the rows interleaved. The suite is AES-128-GCM (the server’s choice).

Apple M4 Max, macOS 26, arm64 natively (the Docker VM’s disk was full, §13.21), OpenSSL 3.6.4’s Python client, no other work of ours on the machine (others were: the load average was 16 to 23), milliseconds of server CPU a handshake, median of 5 rounds (minimum to maximum):

msmin to maxmore than none
no client authentication3.233.11 to 3.99
optional, the client sends none3.173.06 to 3.35-0.06 (the CertificateRequest and an empty Certificate: not measurable)
required, P-256 key under the CA (a chain of one)4.944.84 to 5.62+1.71
required, P-256 key under an intermediate (a chain of two)5.765.67 to 6.66+2.53
required, P-384 key6.406.33 to 7.36+3.16
required, RSA-2048 key (RSA-PSS)4.264.09 to 4.91+1.03
required, Ed25519 key7.476.95 to 8.36+4.23

The design’s arithmetic (§13.12) holds on the M4: two P-256 verifications (the CA’s signature on the leaf, the client’s CertificateVerify) at the 0.82 ms of docs/ecdsa-sign.md §7 are 1.64 ms against 1.71 measured; a third for an intermediate, 2.46 against 2.53. The other key types are the CertificateVerify’s verification beyond the CA’s one P-256: about 2.4 ms for P-384, 0.2 ms for RSA-2048 (public exponent 65537), and 3.4 ms for Ed25519, the most expensive key a client can present here; these three are the remainders after the CA’s verification, and were not timed alone.

x86-64 (gram: linux-x86_64, an Intel P-core, taskset -c 6, perf stat): milliseconds there are not a measurement: core 6 and its hyper-thread sibling were in use by other work throughout (the script measures the others’ share of the cpu and of its sibling and refused all 35 cells: 43 to 81% of the cpu and 100% of the sibling), and the server’s CPU time per handshake came out at 15 to 42 ms against §11.4’s 5.5. What does not depend on who else is on the machine is the user-space instructions the server retires a handshake (perf stat -e cpu_core/instructions/u -p <server>; five rounds each within 0.2 of the median):

millions of instructionsmore than none
no client authentication75.63
optional, the client sends none75.68+0.05
required, P-256 under the CA126.27+50.64
required, P-256 under an intermediate151.35+75.72
required, P-384170.22+94.59
required, RSA-2048106.54+30.91
required, Ed25519152.76+77.13

Each P-256 verification is 25.2 million instructions (+50.64 for two, +75.72 for three), so on x86-64 a client certificate with a P-256 key costs two thirds of the 75.6 million of a handshake with none, and one more third for each intermediate. The M4’s ratio is near it (4.94 against 3.23 is 1.53 times; 126.27 against 75.63 is 1.67). CI’s tls-assurance job runs the script on an x86-64 runner (ubuntu-latest, not pinned) and its log has milliseconds: on the shared runner (3 rounds, milliseconds of server CPU a handshake, run 37767095238): no client authentication 5.96, optional with none sent 5.99, required with P-256 9.43 (+3.47), P-256 under an intermediate 11.08 (+5.12), P-384 12.07 (+6.10), RSA-2048 8.27 (+2.31), Ed25519 11.37 (+5.41). The runner is slower than the M4 and shared, so the ratio (9.43 against 5.96 is 1.58 times) is the figure to carry, and it matches the instruction counts.

13.18 The examples

examples/tls_echo, examples/https_hello and examples/tls_echo_fixed take --client-ca <file> and --require-client-cert (§13.10), and the connection’s line gains client=<subject> fp=<16 hex digits> for a verified client. The authority report is unchanged: the three tests that pin it (conformance/tls_echo.rs, https_hello.rs, tls_echo_fixed.rs) pass as they were. The live tests, python3 scripts/tls_echo_test.py <tls_echo> clientauth and python3 scripts/https_hello_test.py <https_hello> clientauth (CI’s tls-assurance job runs every case of both): a client with a certificate echoes (or gets /hello/mutual), and the line carries the subject and fingerprint OpenSSL reports; no certificate when required and a stranger’s end tls-server-client-cert-required and x509-unknown-issuer; SIGHUP with another CA replaces the store, the old client’s established connection ends closed revoked, its next connection is refused and the new CA’s is served; a file the engine refuses (reload client-ca refused tls-server-client-store) leaves the store as it was; with no --require-client-cert a client with none is served and logged with none. All cases ok on x86-64 (gram): tls_echo_test.py 12 of 12.

13.19 What the examples do not do

They log the identity and nothing else: no authorization, since an echo has none to make. A deny list on the fingerprint, a map from a SAN to a topic, a role from a common name: all the program’s, from tls.peer_*.

13.20 Open questions, for a person

  1. Pinned device certificates. A fleet that gives each device a self-signed certificate and lists them in the broker’s file (mosquitto’s cafile does, and so does openssl s_server -CAfile) is refused here: a leaf with no cA in the store is x509-not-ca (§13.14). Accepting it is a change in x509_verify (a store entry that equals the leaf is the anchor), not in packages/tls. Proposed: yes, as its own change with its own matrix, asked for by cancho-mqtt if the broker’s design wants it.
  2. Tickets on authenticated connections (§13.8): off by default (R1), on by an opt-in once #379 is in. Proposed as designed.
  3. A mode per identity (§13.1). Decide when a program asks.
  4. The 32-root tripwire (§13.3) is a heuristic against the system bundle. Proposed: keep, and say so.

13.21 Not done, and not verified

  • The opt-in that lets a ticket carry the client’s identity (§13.8, R2 to R5): not built; no program has asked, and the default (no ticket to an authenticated client) is the safe one. R1, R6 and the rest are built and tested.
  • Cost on arm64 Docker: the shared Docker VM’s disk was full (98 GB, other work), so the arm64 figures are the Apple M4 Max natively (macOS 26), not Linux in Docker.
  • A second core, a flood of authenticated handshakes: the rate bound is the example’s, as in §11.
  • Not independently reviewed (#209), as the engine; reviewed once by an agent on PR #398 (§13.22).

13.22 After review: a replacement while a client holds its Finished

A review of PR #398 found that revocation by replacing the store missed handshakes in flight: the generation was recorded when the client’s Certificate verified, the identity went live at Finished, and nothing compared them in between; the examples swept only established connections on SIGHUP. A client that held its Finished past the operator’s SIGHUP was then established, and served, under the CA the operator had withdrawn. Fixed:

  • The engine. tls_clientauth.established, called when the client’s Finished has verified and before any ticket or application key, refuses when the slot’s generation is not the store’s: tls-server-client-store-replaced, alert certificate_unknown (46), the connection failed. A new tag rather than x509-unknown-issuer: the chain was not found wanting, the store it was judged under is gone, and a replacement by the very same file refuses too (the engine does not compare stores; the client connects again). A replacement that was refused does not move the generation, and the handshake completes. Three cases of the recording (the same CA, another CA, a refused replacement), two mutants (the comparison removed, the alert changed), and the tag in the coverage test.
  • The generation saturates at 2^32 - 1 instead of wrapping to 0 (identity.cho, load_client_store), which peer_verified would read as “no identity” and the examples’ stale would miss. The examples’ stale is now g > 0 && g != client_generation. Past 2^32 - 1 replacements (one a second for 136 years) a replacement no longer marks older connections stale; this is said, not handled.
  • The examples check stale when a connection becomes established too, and close it as revoked without serving it (tls_echo’s pump, https_hello’s): the engine already refuses that case, so this is the program’s own check, and unreachable through the engine as built. tls_echo_fixed shares tls_echo’s serve and loop (examples/tls_echo/echo.cho), so it has the same store, reload and revocation; it differs only in where the directory comes from.
  • tls.trust_clients’s comment said 64 KiB; the cap is 32 KiB (§13.3), corrected.

On main, after #394 and #396 merged (this branch’s own commits rebased onto main at 47ceb199’s successors). Two collisions were found and fixed, neither visible as a merge conflict:

  • tls.client_auth was taken. #396 added tls.client_auth(engine, slot) on the client engine (what became of a CertificateRequest the client received, docs/tls-parity.md §6.3). This section’s mode call is now tls.client_auth_mode(engine, mode); everything else in §13 is as written, and the examples, tls_serve and the driver’s G use the new name.
  • i_not_after was #394’s. The final #394 records the serving identity’s notAfter in i_srv_not_after, an alias of the client’s i_not_after, at the ClientHello, to bound the tickets’ lifetime; this section had kept the verified client leaf’s notAfter in that same field at the client’s Certificate. A client that presented a certificate is sent no ticket (R1), so no ticket was mis-dated, but the two now have their own fields: the client leaf’s is i_peer_not_after, an alias of the client’s i_verified_at, which a server slot does not use. Still 0 words of slot added: a slot is 10,245 words and 187,703 bytes, main’s sizes (#396’s, and #392’s longer key schedules: keys_len 1,984), asserted by the recording’s memory case.
  • Refusal codes: #394 has -64 to -66, this section -67 to -71 and -73 (-72 is #395’s tls-pool, so tls-server-client-store-replaced moved to -73), #396 -120 to -132; flags: this section’s 32,768 to 262,144, #396’s 16,777,216; states 23 and 24 are this section’s. No overlap.
  • The cases of this kind (state read at one message and trusted at a later one) are named midway: in the recording, as #394’s are in liar_client.txt; scripts/tls_liar_tickets.py points to them.

After #395 (a core per slot, a pool of work areas: docs/tls-memory.md §7). Rebased again onto main with #395. Its rule: what is read after the handshake goes in the slot’s core, what only the handshake uses goes in the work area, which goes back cleared. Placed:

fieldwherewhy
i_peer_generation (alias of i_context_len, word 16), i_leaf_len (14), i_peer_not_after (alias of i_verified_at, word 23), the flags f_auth_request to f_client_auth (in i_flags)coreread by peer_verified, peer_not_after and the identity gate after the handshake
the leaf DER, b_leaf (up to 16,384 bytes)area, keptpeer_certificate, peer_subject, peer_serial, peer_fingerprint and peer_san read it after the handshake; it is up to 16 KiB, which no core has room for
the client’s Certificate as received (b_hs), the verifier’s regionarea, transientused while the Certificate is handled

Superseded by docs/tls-memory.md §11, which measured what this paragraph did not and changed it: a verified leaf of up to 2,048 bytes is kept in a room of its own a slot, and the connection gives its area back after the handshake. The paragraph is as #398 merged it. So a connection whose client was verified keeps its work area while it lives: tls_slot.keeps_area is true for a server slot with f_client_auth and not failed, the hook #395 left for this (docs/tls-memory.md §7.1). Its area goes back when it is dropped or fails, and cleared with the leaf (clear_leaf, in every release and in drop); tls.residue counts a server’s free areas’ b_leaf too. An optional connection with no certificate, a failed one, and every server without client authentication keep #395’s core-only cost. What a held area costs was not measured here: it is what a connection holds mid-handshake (docs/tls-memory.md §10.4), against an idle connection’s core. With fewer areas than slots, verified connections hold areas and a serve with none free is tls-pool (back-pressure, as for a handshake). Not built, and the obvious next step if mutual TLS fleets need #395’s density: keep a verified leaf that fits a few hundred bytes to 2 KiB in the core (a device’s P-256 certificate is ~500 bytes) and keep the area only for a larger one; or the identity fields alone (fingerprint, subject, serial, SANs) in an engine-wide table. tests/programs/tls_pool.cho clientcert (conformance/tls_pool.rs, both backends): every client verified with its area kept, tls-pool when verified connections hold every area, drops give the areas back with residue 0 after every step, and the store replaced before the client’s Finished fails the handshake with its area cleared.

TLS performance: where the time goes, and the design for closing the gap with OpenSSL

Status: measured, with a design. First deliverable of #378. Nothing here changes the TLS stack. What it adds is a measured profile of a full server handshake and a full client handshake on x86-64 and arm64, a ranked list of levers with the gain of each computed from the measured parts, the targets and gates stated before the numbers (§1), a regression guard that is built and runs in CI (§6), and the questions a person has to answer (§7). Every figure says whether it was measured or is arithmetic from measured parts. Not independently reviewed (#209).

As built (§10): after #392 to #398 the same profile was measured again on the same machines: a full server handshake is now 2.86 times OpenSSL’s cycles on x86-64 (2.12 times its CPU on arm64), the client’s 2.77 (2.26), and the allocator is the largest single cost of every handshake. §10 compares each lever with its projection, gives the targets’ state and ranks the next levers; claims below that the new measurements show false carry a note in place.

A1 as built (§10.11): the allocator lever, built (region-chunk-cache.md): the full server handshake 1.86 times OpenSSL’s cycles on x86-64 (1.50 times its CPU on arm64), a resumed one 1.04 (1.12), which meets T5 as §7 Q9 restates it for the server; the client’s resumed handshake is 1.21 (1.33), just outside it.

Related: tls-server.md (§6, §10 and §11 have the figures this extends), tls-resumption.md §1, ecdh.md, ecdsa.md §5, ecdsa-sign.md, x25519.md §6, crypto-builtins.md, tls-hooks.md and cancho-hooks’ docs/pure-tls.md (the client’s per-delivery cost: 2.9 ms on the M4 VM, which §3.4’s 3.4 ms of handshake CPU, with a 16 KiB request, is the same order as).

The tracking issue’s table of the gap (tls-server.md §6, §10, §11) came from a shared CI runner and an Apple VM. This document measures it on one x86-64 machine and one arm64 machine, each with OpenSSL beside it, and goes inside the handshake.

What the profile found, in the order it matters:

  1. Three operations are 77 to 91% of every full handshake (§3.3, §3.4): the two X25519 operations (28 to 39%), the ECDSA signature (26 to 29%, server only) and the ECDSA verifications (the server’s own check before it sends, 25 to 29%; on the client, the chain’s signature and CertificateVerify, 22 to 27% each). Hashing, HKDF, the record layer, parsing, the engine’s copies and zeroing, and the event loop together are about 5%. The transcript is not hashed twice in any way that shows (SHA-2 is 0.4 to 0.7% in all). Each primitive costs the same inside the handshake as alone, to within 8%.
  2. An unexpected 6% to 10% is the allocator, and on a kept connection it is half the CPU. Every region is a malloc and a free of 64 KiB; a handshake opens 222 of them, a request on an established connection about 6, and glibc gives the memory back to the kernel (brk) and takes it again each time: 108 brk calls a handshake. On a kept connection malloc, free and brk are 52% of the server’s CPU on x86-64 and 45% on arm64 (§3.5). Telling glibc not to trim made a request 1.9 to 2.5 times cheaper on x86-64 (two alternating pairs) and took https_hello to the cost of an OpenSSL server answering the same request, measured; on arm64 one pair gave 1.78 times, in a VM whose load changed between the two runs, so that figure is weaker. This is the first lever, a few lines, and not in any sub-issue. As built (§10.4, §10.5): a kept request no longer reaches the allocator (no brk in a 5-second trace, 1% of the samples) and costs 1.11 to 1.15 times OpenSSL’s without A1; a handshake still does (112 brk calls), and the same 0.9M cycles are now 32% of it. A1 is not built. As built (§10.11, region-chunk-cache.md): A1 is built; a whole server run makes 9 brk calls instead of 112 a handshake.
  3. The 3 to 5 times target is reachable without a new compiler builtin. Fixed-base tables for the signature and for the server’s own check, X25519 on 10 limbs of 25.5 bits (which the language’s 64-bit int allows), the allocator, and NIST-prime reduction together take the server’s handshake from 15 times OpenSSL’s cycles to about 4.9 times (§4.3, arithmetic from measured parts): inside the 5 times, by 1%, so the target is reachable but has no margin, and two of the assumptions (2.5 times on X25519’s field arithmetic, 1.7 times on P-256’s) decide whether it is met. Wide multiply (#381) is the one lever the language cannot do today; at an assumed 4 times on the field multiplications it gives about 3.4 times, and 3 times needs about 5. Corrected (§10.6): the target was reached by another route, and further. Wide multiply was built (#393) and moved X25519; #397 moved P-256 on a 28-bit field without it. The server’s handshake is 2.86 times OpenSSL’s cycles with neither A1 nor B2 nor C1: the operations became 6.8 (the check) to 17 (the ladders) times cheaper where this assumed 1.7 to 4 times on their field multiplications; “3 times needs about 5” was false: 3 times was reached with wide multiply on X25519 only.
  4. The guard has to count instructions, not time. On the x86-64 machine the clock moved between 1.4 and about 4 GHz within minutes, because other work shared the package’s power budget, and the same primitive’s time moved by two. Instruction counts under valgrind do not move, are the same as the hardware’s, and catch a primitive made twice slower (§6).

1. Targets and gates, stated before the numbers

They come first so that the tables are read against them. T1 to T4 are #378’s words made into figures; the per-primitive bounds of §1.2 were worked out afterwards, by adding up to T1 from OpenSSL’s measured cycles, so they were written knowing which operations dominate. Nothing in them was changed to suit a later result.

1.1 What “within 3 to 5 times of OpenSSL on new connections” means

#378 says: within 3–5× of OpenSSL on new connections, parity on kept ones, bulk AES-GCM within 2–3×. As figures, measured on one machine, in one session, with the same cipher (AES-128-GCM), group (X25519) and certificate (an ECDSA P-256 leaf signed by a P-256 CA), and in cycles where the machine can count them (§2 says why):

targetthe figurestretch
T1. a full handshake on the server, CPU of the process, verify-before-send includedat most 5 times OpenSSL’s s_server under s_time -new3 times
T2. a full handshake on the client, chain and host name verifiedat most 5 times a verifying OpenSSL client (benches/server/tconn.c)3 times
T3. a request on an established connection, server CPUat most 1.2 times OpenSSL’s server answering the same request (benches/server/oserv.c)parity
T4. bulk AEAD, seal and openAES-128-GCM at most 3 times OpenSSL at 1 KiB and 16 KiB, 1.5 times at 64 bytes; ChaCha20-Poly1305 documented as it is until a vector-type decision (§4.4)2 times
T5. resumed handshake (#379)no more than the full handshake’s two X25519 operations plus 10 percentage points of a full handshake (41.8% of today’s full handshake at today’s shares, §4.3). Not 1.1 times the X25519 operations: that reading (35.0%) is not met by tickets with A1 alone. Restated 2026-10-13 (§7 Q9, decided as proposed): at most 1.2 times OpenSSL’s resumed handshake, server and client; stretch: parity, the same form as T1 to T3. The bound above fell every time the full handshake got cheaper. As built (§10.3): 2.34 and 2.37 times on x86-64, 2.17 and 2.12 on arm64; with glibc not trimming (A1’s experiment) 1.05 and 1.14 times. With A1 built (§10.11): server 1.04 and client 1.21 on x86-64, 1.12 and 1.33 on arm64: met for the server, not for the client.

T1 does not give up the verify-before-send check (tls-server.md §3.3): OpenSSL does not make one, so T1 compares a server that does more with one that does less. That is the honest comparison and is why the target is 5, not 3.

1.2 The figures per primitive that make T1 and T2 add up

A handshake is its parts (§3.3 shows it is), so T1 is met by the parts below, with OpenSSL’s cycles from §3.1. These are the bounds a change to a primitive is measured against, not goals to stop at:

primitiveOpenSSL (cycles)boundin cycleswhat reaches it (projected with §4.1’s assumptions)
X25519, one scalar multiplication114kat most 6 times0.68MC2 (0.65M, 5.7 times); C1 alone projects 0.99M, 8.7 times
ECDSA P-256 sign77kat most 8 times0.61MB1 + C2 (0.60M, 7.7 times); B1 + B5 project 0.99M, 12.9 times
ECDSA P-256 verify, the server’s own key (the check)243kat most 6 times1.46MB2 + B5 (1.28M, 5.3 times)
ECDSA P-256 verify, a key never seen before243kat most 15 times3.6MB4 + B5 (2.19M, 9 times)
P-256 ECDH, one scalar multiplication189kat most 10 times1.9MC2 (1.82M, 9.6 times); B5 alone projects 3.0M, 16 times
brk calls a handshake / a requestat most 2 / 0 (from 108 / 4)A1 (as built, §10.11: 9 in a whole run of 5,148 handshakes / 0)
everything else in a handshake (HKDF, hashes, records, parsing, loop)about 0.8M for OpenSSL’s whole non-asymmetric partat most 1Mtoday’s 0.8M

Two X25519 operations at 6 times, a signature at 8, a check at 6 and today’s 0.8M of the rest come to 4.2M cycles, which is 3.9 times OpenSSL’s 1.09M and inside T1’s 5.45M. These bounds are tighter than T1 needs, on purpose: they leave room for what the projections get wrong. The levers without a builtin (§4.3) meet the check’s bound but not X25519’s or the signature’s, and reach T1 by 1% only; the bounds for X25519, the signature and P-256 ECDH need wide multiply. Corrected (§10.2): as built every bound is met, and only X25519’s needed wide multiply: X25519 1.40 times OpenSSL (bound 6), the signature 4.58 (bound 8), a verification 2.98 (bounds 6 and 15), P-256 ECDH 5.51 (bound 10), all on x86-64 in cycles; the signature and ECDH on #397’s 28-bit field, with no builtin. The rest of the handshake is not 0.8M under its 1M bound but about 0.6M (§10.4), and brk is 112 calls a handshake against a bound of 2.

1.3 Gates every change keeps

From #378, unchanged: the constant-time properties (ecdh.md §2, ecdsa-sign.md) with the dudect tests and the value-barrier audit re-run on every changed secret-handling function; the TLS suites (liar, differential, tickets, interop, fuzz, mutants) unchanged; numbers measured on a named machine and written into the document that made the claim. Added here: the guard of §6 passes, and a change that makes a primitive cheaper re-records its baseline in the same PR, so the improvement cannot be given back unnoticed.


2. How it was measured

2.1 The machines

x86-64arm64
machinegram: Intel Core i7-1260P (12th gen, hybrid: 4 P-cores with 2 threads, 8 E-cores), 16 logical CPUsApple M4 Max, native arm64 under Docker Desktop 29.2.1 (a VM of 6 vCPUs), image lexsys-hooks-env
systemUbuntu 26.04.1, Linux 7.0.0-38-genericUbuntu 24.04.5, Linux 6.8.0-100-generic
clockintel_pstate, governor powersave, 0.4 to 4.7 GHz; measured 1.4 to 2.1 GHz in the runs that counted cycles (cycles over task-clock); the first runs of the day were about twice as fast (OpenSSL signed 59,000 times a second then, 24,000 to 38,000 an hour later)not visible from the VM
THPmadvisemadvise
loadload average 4 to 10 during the runs: other people’s builds and benchmarks on the other cores (turbostat showed cores 0 to 6 all at 100% busy and 1.5 GHz at one moment), and a database container of a soakload average 1 to 7 from other containers
OpenSSL3.5.5 (system)3.0.13 (system)
pinned toCPU 0 for the primitives (its sibling, CPU 1, idle); server on CPU 0 and client on CPU 1, its SMT sibling, for handshakes. Tested both ways: cancho’s server 15.5 and 16.0M cycles with the client on the sibling, 15.8 and 17.8M with it on the same CPU (no systematic effect); OpenSSL’s lean server (oserv) 0.96M with the client on the sibling and 0.89 to 0.90M on the same CPU, so the sibling costs OpenSSL about 7% and the ratios of §3.3 are about 7% kinder to cancho than a quiet sibling would make themCPU 0 and 1 of the VM
countersperf stat for user and kernel cycles and instructions; perf record --call-graph dwarfnone (the VM has no PMU): time, and perf record -e cpu-clock
compilercancho at edfca60 (origin/main), LLVM backend (the default), clang from the systemthe same

The two machines also differ in the OpenSSL release: 3.5.5 on x86-64, 3.0.13 on arm64. The AEAD rows use openssl speed -aead, which seals the way a TLS record does (set the IV, update, take the tag); the 3.0 and 3.5 releases’ plain -evp rows do not do the same work, and the first version of these tables used them.

This machine is not quiet and could not be made so (cores 2 to 7 were in use by other agents and 8 to 15 by a soak, and changing the governor is a system setting on a shared box). Two consequences shape everything below.

  • Time is not the unit on x86-64; cycles and instructions are. The same X25519 took 0.6 to 1.3 ms in three passes within a few minutes, and a handshake 4.5 ms in a quiet minute and 10 ms when the clock fell to 1.6 GHz. The instruction count of an operation does not move (12,193,474 for an X25519, run after run), and the least cycle count over three passes is the least-disturbed one; a cycle count whose IPC would exceed 6, impossible for this core, is a glitch of the counter and is dropped. Where a time is given for x86-64 it is the fastest seen and is labelled. Ratios to OpenSSL are in cycles.
  • Shares are taken from profiles and are stable where times are not: the three server captures (6 s each, at different clocks) agree on the X25519 share to within 2.1 points (33.1%, 31.1%, 31.7%).

The arm64 times are of a VM on a busy Mac, minimum of three passes of three runs; its figures vary by about 15% between runs and by more when another container builds.

2.2 What was run

whathow
each primitive alonetests/programs/tls_prims_bench.cho, driven by scripts/tls_perf.py table: a loop that doubles its calls until it has run 200 ms, three repeats, least time; then twice under perf stat with a fixed number of calls, and cycles and instructions a call from the difference (the setup cancels)
OpenSSL, the same primitivesopenssl speed (ecdsap256, ecdhx25519, ecdhp256, -evp sha256/sha384, -hmac sha256, -aead -evp aes-128-gcm/chacha20-poly1305 at 64, 1024 and 16384 bytes) under perf stat, cycles a call = time a call × cycles over task-clock
a full server handshakeexamples/tls_echo (--connections 256 --handshakes 256 --rate 100000) under openssl s_time -new for 6 to 10 s; server CPU from /proc/<pid>/stat and, with perf stat -p, cycles and instructions; per handshake
OpenSSL’s serveropenssl s_server -tls1_3 -num_tickets 0 -groups X25519, AES-128-GCM, the same certificate, the same s_time -new; and benches/server/oserv.c, a leaner OpenSSL server (accept, SSL_accept, epoll, nothing else), under the same s_time -new
a full client handshaketests/programs/tls_many.cho (the engine’s own client, certificates verified against the CA, the name checked) against openssl s_server -HTTP, 200 connections one run and 1 the other, the difference over 199
OpenSSL’s clientbenches/server/tconn.c against oserv (above): SSL_VERIFY_PEER, the host name set, aborts on the first handshake that does not verify (s_time does not fail on a verification error, so it cannot say the check ran). It sends no request: tls_many’s 16 KiB request is about 0.1M of cancho’s 19.4M cycles and is not subtracted
where the time goesperf record -F 1999 --call-graph dwarf,32768 -p <server> for 6 s while s_time -new runs (the compiled code keeps no frame pointers; DWARF unwinding gave a complete stack in all but 0.04 to 0.7% of the samples), sorted into the categories of §3.3 by scripts/tls_perf.py profile
a request on a kept connectionscripts/https_hello_test.py --cost 5 with --kload --tload --plain --oserv (server on one core, two load threads on another, 32 connections)
allocator experimentthe same runs with GLIBC_TUNABLES=glibc.malloc.trim_threshold=33554432:glibc.malloc.top_pad=1048576, alternating, and LD_PRELOAD counting malloc, strace -c counting brk

On arm64 the same commands, without perf stat; the profile is perf record -e cpu-clock from a privileged container.


3. The measurements

Every table below is from one of the two machines of §2.1: x86-64 is the i7-1260P, Ubuntu 26.04, Linux 7.0.0-38, powersave, THP madvise, load average 4 to 10, shared; arm64 is the M4 Max VM, Ubuntu 24.04, Linux 6.8.0-100, THP madvise, load average 1 to 7. A table says where it is from only if it differs.

3.1 Each primitive alone

tls_prims_bench, x86-64, user-space cycles (the least of three passes) and instructions (exact), against OpenSSL’s cycles from openssl speed on the same core. “Ratio” is cancho’s cycles over OpenSSL’s.

operationcancho: instructionscancho: cyclesOpenSSL: cyclesratio
X25519 key pair12.19M2.34M114k20.6
X25519 shared secret12.19M2.52M114k22.2
P-256 ECDH, one scalar multiplication23.45M4.53M189k24.0
ECDSA P-256 sign25.25M (25.33M under callgrind)4.66M77k60.8
ECDSA P-256 verify25.25M4.58M243k18.9
sign, then verify before sending50.50M (50.58M under callgrind)9.27M
HKDF-Expand-Label (32 bytes out)34.5k8.6k
HKDF-Extract33.4k5.6k
AES-128-GCM key preparation36.6k6.8k

arm64 (Apple M4 Max in the VM), minimum time of three passes; the machine of #378’s table:

operationcanchoOpenSSL 3.0.13ratio
X25519 key pair, shared secret598 us19.0 us31.4
P-256 ECDH, one scalar multiplication756 us25.7 us29.4
ECDSA P-256 sign828 us11.7 us70.9
ECDSA P-256 verify816 us34.3 us23.8
sign, then verify before sending1,625 us

What is in these:

  • Signing is the worst ratio, 61 to 71 times, because OpenSSL signs with a precomputed table (tens of microseconds) and verifies with a generic double-scalar multiplication (about three times a signature). Verification is already within 19 to 24 times; the gap between sign and verify is the table.
  • The instruction count is 37 to 44 times OpenSSL’s (OpenSSL’s X25519 is 333k instructions and its P-256 ECDH 536k, measured with perf stat), and the cycle ratio is 20 to 24 because the compiled code runs at an IPC of 5.2 (X25519, ECDH) to 5.5 (ECDSA) where OpenSSL’s assembly runs at 2.4 (a chain of dependent multiplications). The cancho code is not stalling: it executes too many instructions. Better scheduling would gain nothing; fewer instructions (§4) are the whole of the lever.
  • valgrind --tool=callgrind says what the instructions are for. A P-256 scalar multiplication is about 4,800 Montgomery multiplications (bigmod.mont_mul, which the profile says is 63% of the time, with 16% in the constant-time reduction ct_reduce, 6% in the table selection and 11% in the additions and subtractions); a signature 5,280, a verification 6,015. An X25519 is 3,057 field multiplications at about 4,000 instructions each: a 16 by 16 limb schoolbook product (256 products), each with a load, a multiply, an add and a store through a slice with its bounds checks.
  • Calibration against the older harnesses, same minute: scripts/curve25519_bench.py 1.185 ms for X25519, this bench 1.125 ms; scripts/ecdsa_sign_bench.py 2.047 ms signing and 3.986 checked, this bench 2.06 and 4.05.

Hashes, HMAC and the AEADs, by size. x86-64 in cycles, arm64 in nanoseconds (OpenSSL uses the CPU’s SHA-2 and AES instructions on both; the cancho code uses hardware for AES and GHASH only, crypto-builtins.md):

operationbytescancho: instructionscancho: cyclesOpenSSL: cyclesratioarm64: cancho nsarm64: OpenSSL nsarm64 ratio
SHA-2566415.2k2.6k0.6k4.6566876.5
SHA-2561,024122k21k2.5k8.44,48641110.9
SHA-25616,3841,832k324k32k10.067,8715,59712.1
SHA-384648.5k2.1k1.1k2.03211252.6
SHA-38416,384824k156k85k1.832,4709,9023.3
HMAC-SHA-256 (OpenSSL reuses the keyed context)6440k10k0.7k14.51,45712411.8

SHA-384 is within 2 times on x86-64 because OpenSSL has no SHA-512 instruction on this CPU either (its code is AVX2), and 3 times behind on arm64, where it has; SHA-256 is 10 times off because OpenSSL uses SHA-NI. OpenSSL’s HMAC row measures update and final on a keyed context; a cancho HMAC does the two key-pad compressions every time, so that row is not like for like and is not used below.

3.2 Bulk: the AEADs by record size

openssl speed -aead seals a record the way TLS does (IV, update, tag) and so does tls_prims_bench.

bytescancho: cyclescycles a byteOpenSSL: cyclesratioarm64: cancho nsarm64: OpenSSL nsarm64 ratio
AES-128-GCM seal640.8k12.50.9k0.9115941.2
AES-128-GCM seal1,0246.7k6.61.6k4.19912164.6
AES-128-GCM seal16,384102k6.211k9.115,0142,1337.0
AES-128-GCM open640.8k12.80.9k0.9123941.3
AES-128-GCM open16,384102k6.211k9.115,0752,1337.1
ChaCha20-Poly1305 seal643.1k48.70.4k7.06422392.7
ChaCha20-Poly1305 seal1,02422k21.51.9k11.55,3866758.0
ChaCha20-Poly1305 seal16,384312k19.128k11.281,2987,86710.3
ChaCha20-Poly1305 open16,384300k18.328k10.881,2987,86710.3

On arm64 the AES-GCM bandwidth at 16 KiB is 1.09 GB/s against OpenSSL’s 7.7 GB/s (7 times, as #378 says); on x86-64 it is 6.2 cycles a byte against 0.7.

  • At the size of an HTTP request (64 bytes) AES-GCM is already at OpenSSL’s cost (0.9 times on x86-64, 1.2 on arm64): OpenSSL’s per-record setup costs as much as the hardware path. The ratio grows with the record: the code runs one AES block and one GHASH reduction at a time with the AES latency in the way (IPC 3.7), where OpenSSL interleaves eight.
  • ChaCha20-Poly1305 is the cipher a machine without AES instructions uses. At 19 cycles a byte, 11 times OpenSSL’s AVX2 code, it is scalar by necessity (no vector types, §4.4).

3.3 A full server handshake

examples/tls_echo, P-256 certificate (one certificate, signed by the CA), X25519, AES-128-GCM, TLS 1.3, one handshake at a time. Totals, per handshake:

x86-64arm64
cancho server, CPU4.51 ms in the quiet minute (2,001 handshakes in 10 s); 9.96 to 10.46 ms when the clock was 1.6 GHz3.08 to 3.14 ms (three runs)
cancho server, cycles / instructions16.5M (least of three: 16.5, 17.1, 18.8) / 77.8Mnot countable
OpenSSL s_server, CPU0.59 to 0.65 ms (throttled)0.21 to 0.26 ms
OpenSSL, cycles / instructions1.09M (least of three) / 1.86M
ratio15.1 in cycles (41.8 in instructions); 16 in time within the throttled session12 to 15

How much of OpenSSL’s 1.09M is public-key work. Its two X25519 operations and its signature are about 0.31M (§3.1); the other 0.78M is s_server, the TLS state machine, the kernel’s accept, read, write and close, and OpenSSL’s own allocation. A leaner OpenSSL server, oserv (no BIO chain or s_server options, just SSL_accept and epoll), measured in a second session (2026-10-12, load average 2.4 to 3.4): 0.89 to 0.96M cycles and 1.77M instructions a handshake, against s_server’s 1.04 to 1.05M and 1.85M in the same minutes and tls_echo’s 15.5 to 17.8M and 77.8M. So the lean figure is 10 to 15% smaller and the ratio against it about 17 times, not 15; T1 is stated against s_server, which is what #378 measured, and the 5.45M bound is about 4.7M against oserv, which §4.3’s projection (5.4M) does not meet.

The 4.51 ms and the 3.1 ms agree with #378’s 5.5 ms (a shared CI runner) and 3.0 to 3.9 ms (the M4 VM) as the order, not as a repeat of them.

Where it goes. perf record --call-graph dwarf, 28,014 samples in three x86-64 captures and 6,618 in one on arm64. A sample is in the first category whose frames its stack passes through, memory first. “us” applies the share to the totals above (x86-64: 4.51 ms, which is arithmetic from the profile’s shares and a time measured in another minute).

categoryx86-64: sharex86-64: M cyclesx86-64: usarm64: sharearm64: us
X25519: the key pair and the shared secret (two ladders)31.8%5.251,43439.2%1,216
ECDSA sign (ecdsa_sign.sign)29.0%4.781,30626.4%818
ECDSA verify: the server’s check before it sends28.5%4.711,28725.0%775
memory: malloc, free, brk5.7%0.942565.0%154
engine and event loop (parsing, copies, bookkeeping)1.6%0.26710.7%22
system calls (sockets, epoll)1.2%0.20561.8%55
HKDF and HMAC (key schedule, Finished)1.0%0.16441.0%32
SHA-256/384 (transcript, digests)0.7%0.11300.4%11
record AEAD and traffic-key setup0.3%0.04120.4%12
page faults, not attributed0.3%0.05120.2%6
  • The three asymmetric operations are 89% (x86-64) and 91% (arm64). Each costs inside the handshake what it costs alone (§3.1): on arm64 1.216 ms against 2 × 0.598 for the two ladders, 818 against 828 for the signature, 775 against 816 for the check; on x86-64 5.25M against 4.86M, 4.78M against 4.66M, 4.71M against 4.58M. There is no hidden cost around them to hunt. The sign and the check are the same size: the check is as expensive as the signature (tls-server.md §6’s correction).
  • The engine’s own copies, zeroing, parsing and the loop are 1.6% (x86-64) and 0.7% (arm64), and the leaf memcpy/memset calls are 0.24% of all samples. The transcript hash plus every digest is 0.7%: SHA-256 is 20 cycles a byte but a handshake hashes under 6 KiB. The record layer is 0.3%. None of these is a lever now (§4.5).
  • tls_serve is not the number to quote: tls-server.md §11.4 measured it 0.9 ms dearer than tls_echo with the same engine and left it uninvestigated; this document profiles tls_echo and did not investigate tls_serve.
  • Memory is the one surprise and has its own section, §3.5: 222 regions open in a handshake, each a malloc and free of 64 KiB, 108 brk calls (strace -c: 24,410 for 226 handshakes, 76% of the time spent in system calls).

3.4 A full client handshake

tls_many, the engine’s client, the CA and the name checked, against openssl s_server -HTTP (a small answer); the difference of a 200-connection run and a 1-connection run, over 199. Each connection also seals a 16 KiB request, which is 0.1M cycles of the figures below.

x86-64arm64
cancho client, CPU10.7 to 14.6 ms (throttled; no quiet minute was caught)3.38 to 3.49 ms
cancho client, cycles / instructions19.4M (least of three: 19.4, 22.3, 23.1) / 80.3Mnot countable
OpenSSL client (tconn, verified)0.93 to 1.02 ms0.296 to 0.306 ms
OpenSSL client, cycles / instructions1.70M / 3.39M
ratio11.4 in cycles (23.7 in instructions)11.6

#378’s 3.5 to 7 times for the client is hooks’ per-delivery CPU against a service that uses OpenSSL, and covers everything a delivery does (cancho-hooks’ docs/pure-tls.md); the ratio of handshake CPU alone, with the same cipher and group, a verified chain and the same name check, is 11 here.

categoryx86-64: sharex86-64: M cyclesarm64: sharearm64: us at 3.48 ms
X25519 (key pair, shared secret)27.9%5.433.1%1,151
ECDSA verify: the chain (the CA’s signature on the leaf)27.2%5.322.3%776
ECDSA verify: CertificateVerify (the server’s key)27.1%5.322.2%772
memory: malloc, free, brk9.9%1.95.0%172
page faults (first touch of 200 slots in one process)1.6%0.312.1%420
system calls2.1%0.42.2%78
engine and loop1.6%0.30.5%16
record AEAD0.9%0.20.8%29
HKDF, HMAC0.9%0.20.6%21
SHA-256/3840.4%0.10.3%12
certificate parsing0.3%0.060.2%8
  • Four operations are 82% (x86-64) and 77% (arm64) of the client’s handshake, tls-resumption.md §1’s “these four operations are the handshake”, with the same costs inside as alone (arm64: 776 against 816 for a verification, 1.151 ms against 1.196 for the two ladders).
  • The page faults are the 200 slots of tls_many being touched for the first time in one process (about 120 KiB each established, #378); a long-lived client reuses its slots, so in steady state they are not in the handshake. On arm64 in the VM they cost 12%, on x86-64 1.6%. It is #383’s memory and not a CPU lever.
  • The client has no signature; its second and third costs are verifications. A chain through an intermediate adds a third (ecdsa.md §5.4), so a typical public chain costs the client more than this one.

3.5 A request on an established connection

https_hello over TLS 1.3 and keep-alive, GET /hello/42, server CPU per request, 2 load threads and 32 connections. The “plain” row is a different application (examples/api, GET /users/42), the only plain server in the repository; the OpenSSL rows are benches/server/oserv.c, a one-thread OpenSSL server that answers the same nine bytes, plain and over TLS.

x86-64 (throttled)arm64
examples/api, plain12.2 and 14.7 us2.90 us
oserv, plain11.5 and 13.1 us3.67 us
https_hello, TLS47.7 and 52.8 us8.81 us
oserv, TLS (OpenSSL)20.9 and 22.8 us6.73 us
https_hello with glibc told not to trim24.7 and 21.5 us4.96 us (one pair; the plain row moved from 2.90 to 4.90 between the two runs, so the VM was busier)
oserv, TLS, in the same runs as that row23.6 and 20.2 us5.07 us

On x86-64 https_hello costs 2.3 times OpenSSL’s server per request, and after the allocator experiment about 1.05 times (1.9 and 2.5 times cheaper in the two pairs). On arm64 the gap was 1.3 times, and the one pair there gave 1.78 times cheaper, confounded: the VM was busier for the second run (the plain row rose by 69%), so it says only that the effect is there, not how large. TLS’s own cost over plain (record seal, open, copies) is small: OpenSSL’s is 9 us of 21 on x86-64.

Where the request goes (perf record, 8,366 samples on x86-64, 8,812 on arm64):

categoryx86-64arm64
memory: malloc, free, brk, page faults52.9%45.1%
system calls (send, recv, epoll; the loopback delivery of a send is charged to the sender)29.7%35.8%
HTTP parser, application, loop11.4%13.0%
record AEAD5.8%5.9%

As built (§10.5): no brk at all in a 5-second trace of a loaded https_hello, and memory 1% of a request’s samples; the paragraph below describes edfca60. A request opens about 6 regions (LD_PRELOAD counting: 851,022 mallocs of 65,536 bytes for 140,640 requests and 32 handshakes at 222 each), and each is a malloc of 64 KiB and a free. The reason is in the generated code: cancho-codegen-llvm/src/emit.rs declares malloc and free for “one malloc per arena, one free on the way out” and ARENA_CHUNK is 64 KiB. glibc frees the chunk back when the top of the heap grows past 128 KiB and then grows it again for the next region, with a page fault for each page touched: 108 brk calls a handshake and 4 a request (strace -c: 40,594 brk for 10,163 responses). The cost was known in part (tls-parity.md: “nested regions cost 8 brk calls per seal”); it was not known to be the largest cost of a kept connection.

The experiment is GLIBC_TUNABLES=glibc.malloc.trim_threshold=33554432:glibc.malloc.top_pad=1048576 on the unchanged binaries, alternating with the default, two pairs each. It is an experiment and not the fix (§4.1 A1): it shows what the fix is worth. The handshake gain (5.7% by the profile) could not be confirmed by the same alternation, because the handshake’s cycle count moved by 20% between runs; it is arithmetic from the profile.

3.6 What was looked for and is not there

#378 asked about specific suspects. By the profile of §3.3 and §3.4:

suspectwhat the profile says
the engine’s copies and zeroingmemcpy/memset are 0.24% of server samples; the engine and loop together 1.6%
the transcript hashed twiceall hashing is 0.7% of a server handshake, 0.4% of a client’s
parsing and certificate handling0.3% of a client handshake
the event loopsystem calls 1.2 to 2.2%, epoll_wait 0.15%
allocationyes: 5.7% to 9.9% of a handshake, 45 to 53% of a request (§3.5)
the verify-before-send checkyes, 28.5% of the server’s handshake, as expensive as the signature
HKDF/HMAC recomputing pads1.0%: hmac.init does two compressions a call; small now, relatively larger once the rest is faster (D4)
bigmod.setup/r_squared per signature or verification0.9% of the handshake, inside the ECDSA rows (A2)
the field inversion at the end of each ladder3.2% of the handshake each, 19% of an X25519 (C3)

4. The levers

4.1 The list, ranked

Gain is the percentage of today’s x86-64 server handshake removed (shares of §3.3), computed from the measured share and the stated assumption. Nothing in the gain columns has been measured as a gain except A1’s per-request figure; the basis column says what each was computed from.

#leverissuebasisgain: server handshakegain: client handshakegain: kept requesteffort
A1Stop region returning its chunk to the kernel: keep a few freed 64 KiB chunks (a per-thread free list, or mallopt(M_TRIM_THRESHOLD) in the emitted main as a stopgap). Compiler change, both backendsnewmeasured: 5.7% / 9.9% of a handshake; experiment 1.9 to 2.5 times per request on x86-64 (arm64: one confounded pair, 1.78)5.7% (1.06 times)9.9%48 to 60% on x86-64 (1.9 to 2.5 times)small
B1Fixed-base comb for k·G, signing: 64 windows of 4 bits, 64 mixed additions instead of 256 doublings and 78 additions#380callgrind: a signature is 5,280 Montgomery multiplications, of which the scalar multiplication is about 4,430; a comb needs 64 × 13 = 830, plus 850 for the two inversions19.7 points (sign ÷ 3.1)medium
B2A fixed-base table per identity for the check: the server’s public key is fixed for the life of the certificate, so u1·G + u2·Q is two combs, no doublings, no table built per callnew (in #380’s area)a verification is 6,015 multiplications; two combs 1,664 plus two inversions (s⁻¹ mod n, std/ecdsa.cho scalars, and the affine x mod p), 850 as in B1: about 2,51016.6 points (check ÷ 2.4)medium
B3The same table per trust anchor on the client, built when the root is added to the storenewthe chain’s verification is 27.2% of a client handshake; a fixed key is B2’s case (÷ 2.4)15.8 pointssmall after B2
B4u1·G + u2·Q for a key never seen: G by the comb, Q by 5-bit wNAF with the cheaper non-complete formulas (public data)#3802,048 doublings at 8 multiplications, about 43 additions, 64 mixed additions, two inversions: about 4,300 against 6,0157.8 points (CertificateVerify ÷ 1.4)medium
C1X25519 on 10 limbs of 25.5 bits with the 64-bit int the language has (x25519.md §6 names it), a dedicated square, products kept in registersnew (#381’s baseline)100 products a multiplication instead of 256; today about 4,000 instructions a multiplication; assumed 2.5 times on the field multiplications, which the profile puts at 96% of a ladder (field25519.mul 78%, product_into 18.5%)18.4 points16.1 pointsmedium
B5NIST-prime reduction for P-256 in place of generic Montgomery (and 8 limbs of 32 bits)#380mont_mul and ct_reduce are 79% of the signature’s time; 64 products plus a fold in place of 162; assumed 1.7 times on that 79% only6.9 points after B1 and B2 (their 21.1 × 0.79 × (1 − 1/1.7))10.1 points after B3 and B4medium
D1Tickets: a resumed handshake skips the signature, the check and the chain#379a resumed handshake is X25519 + memory + the rest = 42.5% of a full oneresumed: 57.5% (2.35 times); only for returning clientsresumes already (tls-resumption.md)large
C2Wide multiply and add-with-carry#381assumed 4 times on the field multiplications (96% of X25519, 79% of the ECDSA operations), after A1, B1, B2with A1 + B1 + B2: 77.6% removed (4.5 times in all)large, compiler
D2Multi-block AES-GCM, aggregated GHASH#382102k cycles for 16 KiB at IPC 3.7 is the latency of one block at a time; 4 to 8 blocks hides itnonenonenone (64-byte records are at OpenSSL’s cost already); bulk 16 KiB 2.5 to 3.5 timesmedium, builtins
D3A hardware SHA-256 builtin (SHA-NI, ARMv8 SHA-2), the shape of #328newSHA-256 is 10 and 12 times OpenSSL’s; 0.4 to 0.7% of a handshake todayunder 1 pointunder 1 pointmedium, builtins
D4HMAC with the key pads kept (HKDF reuses a keyed state)newhmac.init is two of the compressions of a 64-byte HMAC; HKDF-Expand-Label is 8.6k cycles0.3 pointssmall
A2The Montgomery constants for p and n of P-256/P-384 as constants, not computed per callnewbigmod.setup + r_squared 0.9%0.9 pointssmall
C3One inversion for the two ladders (Montgomery’s trick); the server computes both back to backnewthe profile puts the inversion at 6.4% of the handshake for the two, 19% of each ladder; one is saved3.2 pointssmall
E1Memory per connection#383not measured here; the client’s first touch of a slot costs 12% on arm64, 1.6% on x86-64(first touch)per #383
F1Drop the verify-before-send checkwould remove 28.5%28.5 points (1.40 times)a decision for a person, §7 Q1

Order, by gain over effort and by what unlocks what: A1; then B1 and B2 together (the same comb code, one table-making routine); then C1; then B3 and B5; then D1 in parallel; C2 as a prototype that has to beat C1; D2 for bulk.

The changes to the order in #378’s checklist (§8): the profile moves A1 first and adds it as a new issue; #380 grows the per-identity table (B2) and moves above #379 (tickets help only a client that comes back, and the comb helps every connection); #381’s prototype is measured against C1, not against today’s code, or it takes credit for a 2.5 times that 10 limbs of 25.5 bits give with no compiler change.

4.2 What the language cannot do today

  • A 64 × 64 → 128-bit multiply and an add with carry (C2). The only products are 64-bit and signed and trap on overflow (wrapping_mul drops the high half). That is why the limbs are 16 bits (field25519) and 30 bits (bigmod): so that a product and its sums fit in 63 bits. No amount of code in the language gets 51- or 64-bit limbs. #381. As built: #393 added mul_wide, add_carry and sub_borrow (edition 7, wide-multiply.md) and X25519 runs on 51-bit limbs; this bullet describes the language at edfca60. P-256, bigmod and Poly1305 are not on them yet (§10.8 P1, M1).
  • Vectors. There are no SIMD types, so ChaCha20 (4 or 8 blocks at once) and Poly1305 cannot be vectorised; ChaCha20-Poly1305 stays 11 times OpenSSL’s AVX2 (§3.2). It is the cipher chosen when the CPU has no AES instructions, so the case is real, and needs the decision #382 mentions: a vector type or a builtin.
  • The CPU’s SHA instructions (D3). There is no builtin for them; #328 made hardware AES and GHASH.
  • Choosing how memory is returned (A1). A program cannot call mallopt without a foreign-function capability, and this engine is built to need none; the chunk policy is in the code the compiler emits, so A1 is a compiler change.
  • A constant table in the binary. A region is 64 KiB (and traps beyond it), so the 60 KiB of a comb table lives on the heap (box_slice), built at engine open, not in a region and not as a static; a &static [byte] literal of 60 KiB would work and has not been tried (§7 Q2).
  • A guarantee of no branch in the wide-multiply lowering is #381’s design question, not a limit.

What the language can do and nobody has: C1 (25.5-bit limbs), the combs (B1, B2, B3), wNAF (B4) and the reduction (B5) are all expressible with the 64-bit int, wrapping_* and slices that exist.

4.3 What the levers add up to

Applying the levers one after another to the x86-64 server handshake (shares of §3.3: X25519 31.8, sign 29.0, check 28.5, memory 5.7, the rest 5.0; 16.5M cycles; OpenSSL 1.09M). Arithmetic from the measured shares and the stated assumptions, not a measurement:

afterhandshakeM cyclesagainst OpenSSL
today100%16.515.1 times
+ A194.3%15.614.3
+ B1 (comb, signing)74.6%12.311.3
+ B2 (per-identity table, the check)58.0%9.68.8
+ C1 (X25519 on 25.5-bit limbs: 2.5 times on 96% of it)39.6%6.56.0
+ B5 (NIST-prime reduction: 1.7 times on 79% of the ECDSA operations)32.6%5.44.9
alternatively A1 + B1 + B2 + C2 (wide multiply, 4 times on the same field multiplications)22.4%3.73.4
A1 + B1 + B2 + C2 at 5 times20.0%3.33.0
  • T1 (5 times) is reached by A1, B1, B2, C1 and B5, none of which needs a compiler builtin, by 1% (5.39M against 5.45M): no margin. It rests on two assumptions the prototypes have to bear out (2.5 times on X25519’s field multiplications, 1.7 times on P-256’s); C3 (3.2 points) and A2 (0.9) are the small levers that would give it some. Against the leaner oserv it is not reached (§3.3). The 3-times stretch needs wide multiply at about 5 times on the field multiplications. Corrected (§10.6, §10.7): as built the server’s handshake is 2.86 times s_server (3.04 times oserv) without A1, B2 or C1; the stretch was reached because the operations became 6.8 (the check) to 17 (the ladders) times cheaper, where this assumed 2.5 to 5 times on their field multiplications.
  • Corrected (review of #391): this table said 28.9% and 4.4 times. B2 was priced with one inversion where a verification does two, and B5’s 1.7 times was applied to the whole ECDSA time where the basis column says 79% of it.
  • The client handshake with A1, B3, B4, C1 and B5: 100 − 9.9 − 15.8 − 7.7 − 16.1 − 10.1 = 40.4%, which is 7.8M cycles, 4.6 times OpenSSL’s 1.70M: inside T2’s 5, by 8%. (Arithmetic; the same assumptions. It was 35.7% and 4.1 times before the review’s correction.)
  • Tickets (D1) give a returning client a handshake of 42.5% of a full one (7.0M cycles, 6.4 times a full OpenSSL handshake; the client side, which resumes already, measured 2.2 times cheaper in hooks, pure-tls.md, against the 2.35 here); with A1 36.8% (6.1M), which meets T5 as §1.1 defines it (41.8%) and would not meet a 1.1-times reading (35.0%), and with C1 as well 18.4% (3.0M): the resumed handshake is the X25519 operations plus the rest, and nothing else is left to cut but the ladders. As built (§10.3, §10.7): a resumed server handshake is 2.00M cycles, 0.71 of the as-built full one and 2.34 times OpenSSL’s resumed one; the ladders are 15% of it and the allocator 52%. T5 is not met, with or without A1’s experiment. As built (§10.11): with A1 a resumed server handshake is 0.884M cycles, 1.04 times OpenSSL’s resumed one.
  • A kept request is 2 to 2.5 times cheaper with A1 alone, at OpenSSL’s cost (§3.5): T3 needs only A1. Corrected (§10.5): T3 did not need A1. As built a request opens no region that reaches the kernel and costs 1.11 to 1.15 times OpenSSL’s server, and glibc told not to trim no longer changes it.
  • The arm64 shares give the same ranking (X25519 39%, sign 26%, check 25%, memory 5%); with the larger X25519 share C1 matters more there (22.7 points removed by C1 alone, against 18.4).

4.4 Bulk

The cancho AEADs are at OpenSSL’s cost for a 64-byte record and 4 to 9 times it at 1 and 16 KiB (§3.2). T4 asks for 3 times: D2 (#382). The 102k cycles for 16 KiB are 6.2 cycles a byte at IPC 3.7: AES-NI’s aesenc has a latency of 3 to 4 cycles and the code is one block at a time, with a GHASH reduction a block. Four-block interleaving hides the latency (4 times at the AES) and aggregating the reductions removes three of four; 2.5 to 3.5 times is the arithmetic, which is #382’s own hypothesis (2 to 4 times) and still to be measured with the prototype of the four-block path it asks for. As built (#392, §10.6): 9.5 times at 16 KiB, 9.0 at 1 KiB and 4.7 at 64 bytes on x86-64; AES-128-GCM is now 0.21, 0.57 and 1.15 times OpenSSL’s cycles at those sizes, inside T4.

4.5 Levers the profile rules out

The transcript hash, the HKDF, the record layer, parsing, copies and zeroing, and the event loop are together 5% of a server handshake (§3.3, §3.6). Even a perfect version gains 5%; after A1, B1, B2, C1 and B5 they are 17% of what remains, and D3 and D4 become worth doing then, not before. They are listed so that nobody starts with them.


5. Open questions about the measurements

  • The x86-64 machine was shared. Times moved by 2; cycles by 20 to 30% in the worst passes (the least is used); instruction counts did not move. A second session on a machine of one’s own would tighten the cycle columns, not change the ranking.
  • arm64 has no cycle counts (the VM has no PMU). Its time figures are minimums of three passes on a busy Mac.
  • The client profile includes first-touch page faults (§3.4). A steady-state client with slot reuse was not run.
  • OpenSSL releases differ (3.5.5 and 3.0.13); the AEAD rows use -aead to compare like with like; the HMAC row does not and is not used.
  • Cranelift was not measured (the LLVM backend is the default and the one in the issue’s numbers); P-384, RSA and TLS 1.2 were not measured; latency (the loop held for 4.5 ms by one handshake) was not.
  • The handshake gain of A1 is arithmetic; the per-request gain is measured (§3.5).
  • Memory per connection (#383) was not measured here.

6. The regression guard

tests/programs/tls_prims_bench.cho times 33 rows (every primitive of §3.1, the three AEAD and hash sizes, and a calibration loop). scripts/tls_perf.py drives it.

cancho build --std tests/programs/tls_prims_bench.cho -o tls_prims_bench
python3 scripts/tls_perf.py table  ./tls_prims_bench                 # ns, cycles and instructions a call (cycles: Linux with perf)
python3 scripts/tls_perf.py check  ./tls_prims_bench                 # the guard
python3 scripts/tls_perf.py record ./tls_prims_bench                  # a new baseline for this machine's <system>-<architecture>
python3 scripts/tls_perf_mutant.py <cancho>                          # shows the guard fails when two primitives get twice as dear
python3 scripts/tls_perf.py profile server.script.txt --total-us 4510   # §3.3's table from a perf script (--memory: §10.4's split)
python3 scripts/tls_perf.py openssl                                  # OpenSSL's cost of the same primitives (§10.2)
python3 scripts/tls_handshake_cost.py <bin> [--sudo-perf]            # full and resumed handshakes, both sides, beside OpenSSL (§10.3)

What it compares. With valgrind (callgrind), check counts the instructions each row executes, from the difference of two runs with different numbers of calls, and fails a row more than 1.3 times its recorded count, or less than 1/1.3 of it (a primitive that got that much cheaper re-records its baseline in the same change, §1.3, so the improvement cannot drift back), a row the baseline has and the run lacks, and a row the run has and the baseline lacks. A run under valgrind that does not exit 0 and print its checksum line is an error, not a count. Instruction counts do not depend on the clock or the load (§2.1), they equal the hardware’s (12,193,474 for an X25519 under both), and the run takes 40 seconds on x86-64 and 12 on arm64. As built (§10.10): 11.5 and 8.3 seconds, the primitives being cheaper to run under valgrind. Without valgrind it falls back to the time of each row over the calibration’s, at 2.0 times, because that quotient moved 1.6 times between two runs minutes apart on the shared machine, which is why it is not the first choice; and only against an entry recorded on the same system and architecture (linux-x86_64, linux-aarch64): the aarch64 entry from the Linux VM failed 11 of 33 rows on unchanged code when timed natively on macOS (found by the review of #391), so time mode refuses a foreign entry. The calibration row is not what its source says on x86-64: LLVM folds the multiply-add chain out of the loop (its constants are not in the binary) and leaves a counted loop of about one instruction and one cycle a step, 1,007 instructions a call. It is still independent of every primitive, which is all the fallback needs.

What it catches, shown and not claimed. scripts/tls_perf_mutant.py builds a copy of the benchmark with X25519 and SHA-256 each done twice and runs the guard on it; CI runs it after the guard. It failed exactly those four rows (x25519_public, sha256 at 64, 1,024 and 16,384 bytes) at a ratio of 2.00 and passed the other 29. A change that makes a primitive twice as slow is caught; one that adds 20% is not (1.3 is wide for what CI showed: the compiler and CPU differences between the machine that recorded the baseline and GitHub’s runner moved the counts by 2% down to 11% up). What it cannot catch: a change in instruction count that leaves time alone or the reverse (a code layout that halves the IPC), the handshake as a whole (a primitive added to the path), and memory. The allocator (A1) and the engine are in no row; scripts/tls_echo_test.py --cost and scripts/https_hello_test.py --cost remain where those are measured.

CI. The tls-perf job builds the compiler and the benchmark and runs check. valgrind is not on the runner by default: the job installs it. The baseline file holds one entry per system and architecture (linux-x86_64 from the machine of §2.1, linux-aarch64 from the M4 VM; a macOS entry would be recorded on macOS); the x86-64 entry carries over to GitHub’s runner: on #391’s first CI runs (ubuntu-latest, its own clang and valgrind) all 33 rows were between 0.98 and 1.11 of the instruction counts recorded on the i7, two runs alike. That is the evidence for the factor: 1.3 leaves room for compiler differences of that size, and 1.2 would too. If a later runner image moves a row past it, the entry is re-recorded from the runner’s own log in the same PR that notices, not loosened.


7. Open questions, for a person

Q1. Does the verify-before-send check stay, and in what form? It is 28.5% of the server’s handshake, as expensive as the signature. Proposed: it stays (a fault in the signer that leaks the key is the harm, and the check is the only defence the design has, tls-server.md §3.3), and B2 makes it cheaper rather than optional: a check against a fixed key is two combs and two inversions, about 2.4 times cheaper. That does not make it small: by this document’s own numbers it is still about 21% of the handshake after B1 and B2, and about 25% after every lever of §4.3, the largest single cost left. So the question is not deferred to a later measurement; the proposal is to keep it at that price, because no cheaper check gives the same protection (recomputing the signature by a second route costs as much as the signature), and to say so in tls-server.md §3.3. A flag to switch it off is not proposed: a default no one reads is how the check goes missing. As built (§10.4): the check is 24.4% of the server’s handshake on x86-64 and 25.6% on arm64, as projected here, and twice the signature, since the signature has the comb and the check does not. B2 (§10.8) would take it to about 10% after P1.

Q2. Where does a 60 KiB table live, and what may it cost? B1’s table for G, B2’s for each identity and B3’s for each trust anchor are 61,440 bytes at 4-bit windows (38.5 KiB at 3 bits, 86 windows). A region is 64 KiB. Proposed: the heap, owned by the thing it belongs to: G’s table built once at engine open (about 15,000 field multiplications, three scalar multiplications’ worth, 5 to 15 ms), an identity’s when it is loaded or reloaded on SIGHUP, before the swap, and a trust anchor’s when it is added. Budget: 64 KiB for each of at most 16 identities (1 MiB) and a table per root, lazily. Whether a literal &static [byte] for G’s table is better (no start-up cost, 60 KiB of binary) is a measurement for the first PR to make.

Q3. How does A1 land: a chunk free list in the codegen, or mallopt in the emitted main? Proposed: the free list: a small number (8) of freed chunks kept per thread in a thread-local, taken and returned where malloc and free are emitted (cancho-codegen-llvm/src/body/memory.rs, and cancho-codegen’s equivalent), because mallopt is glibc’s and the compiler also targets macOS, and musl is a fair thing to ask for; mallopt(M_TRIM_THRESHOLD, 16 MiB) is the one-line stopgap that proves the gain in the meantime. Programs that spawn threads need the list per thread or a lock; per thread is the proposal. This is a compiler change, so it takes the full gate (both backends, the conformance and differential tests). As built (§10.8): still open, now the first lever (34% of a server handshake, 55% of a resumed one, measured by the experiment). #375 asks for the same per-thread cache for macOS’s sake; a cache of one chunk would not do here, because the key schedule nests regions (hmac.init inside hkdf.expand inside hkdf.expand_label).

Q4. Is C1 built before #381’s prototype? Proposed: yes, first. It needs no compiler change, it is the baseline the wide-multiply prototype must beat, and without it #381 will report a 2.5 times that belongs to the limb size and not to the builtin. The prototype’s question becomes “what does 51-bit limbs with a wide multiply give over 25.5-bit limbs with a 64-bit multiply”, which is the question the issue means to ask. As built: #393 built the wide multiply first and measured it against the 16-bit code (23 times on the M4) and against a C estimate of 51-bit limbs (wide-multiply.md §2); C1 was not built, and is no longer needed.

Q5. Are the targets of §1 the right ones? In particular, T1 counts cycles of the whole process (kernel included) against s_server and accepts 5 times because OpenSSL does not run the check. Proposed: as written; a wall-clock handshake rate on a named machine is quoted beside it in each PR but is not the gate, since §2.1 says the clock is not stable.

Q6. Is the guard a required check, and at what factor? Proposed: required: its first CI runs showed the x86-64 baseline carries over (0.98 to 1.11 on all rows). 1.3 on instruction counts is kept (a primitive that gets 30% dearer is a finding), and the baseline is re-recorded in the same PR by anything that moves a primitive. If a runner image or clang ever needs a second x86-64 entry, record it under its own tag rather than raise the factor.

Q7. Does the SHA-256 instruction (D3) get a builtin? Proposed: not yet. SHA-2 is 0.7% of a handshake today and becomes about 2% after the levers of §4.3 (0.67 of 32.6); decide then, with the vector-type question for ChaCha20 (§4.4), since both are “a CPU feature the language can’t name” and want one design.

Q8. What does “within 3 to 5 times” mean for P-384, RSA and TLS 1.2? Not measured here (§5). Proposed: P-256 and X25519 are the targets; P-384 is 1.7 times the P-256 handshake (tls-server.md §6) and follows B5; RSA signing is #385’s.

Q9. Is T5 restated against OpenSSL’s resumed handshake? (Added with §10.) Decided as proposed (2026-10-13): T5 is now at most 1.2 times OpenSSL’s resumed handshake, server and client, stretch parity (the T5 row in §1.1 says so). T5 bounds a resumed handshake by the full one’s two ladders plus 10 points of it; as built it is not met (2.00M cycles against 0.58M on x86-64) and the bound falls every time the full handshake gets cheaper. Proposed: at most 1.2 times OpenSSL’s resumed handshake, server and client, stretch parity: the same form as T1 to T3. As built it is 2.34 and 2.37 times; with glibc not trimming (A1’s experiment) 1.05 and 1.14 times (§10.3).


8. Changes to the tracking issue’s list

  • Add A1 (the allocator) as a new sub-issue, first.
  • #380: add B2 (a table for the server’s own key, for the check) and B3 (for each trust anchor), and reorder: the comb, then the per-key tables, then the reduction.
  • Add C1 (X25519 on 25.5-bit limbs) as a sub-issue or fold it into #381 as the baseline of its prototype.
  • #379 stays and moves below A1, B1, B2: it helps only a client that returns.
  • #382 stays, for bulk; it does not move the handshake or a small request.
  • New small items: A2, C3, D3, D4.
  • Add the guard (§6) to the gates of every sub-issue.

As built: §10.8 is the list now; #378’s checklist carries it.

9. Not done

  • No lever is built; every gain in §4 is arithmetic from the profile. As built (§10.6): B1, B4 and B5 (#397), C2 on X25519 (#393), D1 (#394), D2 (#392) and E1 (#395) have been; A1, B2, B3, C1, C3, A2, D3 and D4 have not.
  • A baseline for macOS (darwin-aarch64): the job runs on ubuntu-latest only, and the linux-aarch64 entry is guarded where someone runs it on Linux (§6).
  • Cycle counts on arm64, a quiet x86-64 machine, a steady-state client, memory per connection, P-384, RSA, TLS 1.2, Cranelift and latency (§5).
  • tls_serve’s 0.9 ms difference from tls_echo (§3.3).

10. As built: the profile again, after #392 to #398

Measured on 0e40f430 (origin/main after #398), on the same two machines as §2, with the same tools and the same OpenSSL beside each figure. Since §3 was measured (edfca60), these landed: #393 (mul_wide, add_carry, sub_borrow; X25519 on five 51-bit limbs), #397 (P-256 on a ten-limb 28-bit field using the prime’s form, a fixed-base comb for k·G, wNAF verification), #392 (multi-block AES-GCM, aggregated GHASH), #394 (server session tickets), #395 (a core per slot and leased work areas), #396 and #398 (client certificates and ALPN; mutual TLS in the server). Not built: A1, B2, B3, C1, C3, A2, D3, D4 (A1 built since, §10.11; D3 and D4, §10.14). Raw output of every table is in assets/tls-performance/, one directory a machine.

What it found, in the order it matters:

  1. A full server handshake is now 2.86 times OpenSSL’s s_server in cycles on x86-64 (2.83M against 0.99M) and 2.12 times in CPU time on arm64; the client’s is 2.77 and 2.26 times. §4.3 projected 4.9 and 4.6 times, with the allocator and two levers that were not built (§10.6). T1 and T2 are met, and their 3-times stretch too (by 5% on x86-64 against s_server; not against the leaner oserv, 3.04 times). A resumed handshake is 2.34 (x86-64) and 2.17 (arm64) times OpenSSL’s resumed one; a request on a kept connection 1.11 to 1.15 times.
  2. The allocator is now the largest cost of every handshake, and nothing else comes close on a resumed one. malloc, free and brk are 31.6% of a full server handshake on x86-64 (28.3% on arm64), 52.5% of a resumed one (48.5%), and 35.2% of a client’s (23.7%) (§10.4). It is the same 0.9M cycles §3.3 measured as 5.7%: the rest shrank around it. glibc still trims and regrows the heap: 112 brk calls a handshake (§3.3 counted 108). Telling glibc not to trim, alternating with the default, made the server’s full handshake 34% cheaper (2.83M to 1.86M cycles, 1.87 times OpenSSL), the resumed one 55% (1.06 times OpenSSL’s resumed), and the client’s 40% (1.65 times) on x86-64; 27%, 50% and 32% on arm64. A1 is now the first lever by a factor of two (§10.8), and it is the same mechanism as #375’s chunk cache. As built (§10.11): A1 took the full server handshake to 1.844M cycles (1.86 times s_server), a resumed one to 0.884M (1.04 times OpenSSL’s resumed), the client’s to 2.664M (1.64 times tconn); arm64 1.50, 1.12, 1.51 times.
  3. A kept request no longer touches the allocator (no brk in a 5-second trace on either machine, 1% of the samples), so A1 does nothing for it; it is within T3 without it. What it spends instead, beside the sockets, is #395’s work areas: copying a connection’s core into an area and back out and clearing the area is 24% (x86-64) to 27% (arm64) of a request. As built (§10.12): 3.6%, and a kept request 0.94 times OpenSSL’s cycles on x86-64 (from 1.20).
  4. Each primitive is 1.3 to 5.5 times OpenSSL now, not 19 to 71: X25519 1.40 (x86-64 cycles) and 1.28 (arm64), the ECDSA signature 4.6 and 4.3, a verification 3.0 and 3.0, a P-256 ECDH 5.5 and 5.2. AES-128-GCM is below OpenSSL’s cost at 64 bytes and 1 KiB and within 1.15 times at 16 KiB. SHA-256 (4.5 to 11 times) and ChaCha20-Poly1305 (2.6 to 11.5) did not move. Each costs inside a handshake what it costs alone, as in §3.
  5. The next levers, ranked by measured gain (§10.8): A1 (34% of a server handshake, measured by the experiment), P-256 on mul_wide (about 15 points, arithmetic; as built, §10.15: 19.9% of the A1-built handshake, 13 points of this one), the per-identity table for the check (about 7.5), a SHA-256 builtin (about 6.5, and 10 of a resumed handshake), then small ones; for a kept request, the work-area copies (up to 24%). Applied in that order the server’s full handshake comes to about 1.1 to 1.2 times OpenSSL’s (arithmetic from measured parts).

10.1 How it was measured

x86-64arm64
machinegram, Intel Core i7-1260P, as §2.1Apple M4 Max, Docker Desktop 29.2.1 (a VM of 6 vCPUs), image lexsys-hooks-env, as §2.1
systemUbuntu 26.04.1, Linux 7.0.0-38-generic, governor powersave, THP madvise; clang 21.1.8, valgrind 3.26Ubuntu 24.04.5, Linux 6.8.0-100-generic, THP madvise; clang 18.1.3, valgrind 3.22
clock2.6 to 4.25 GHz in these runs (cycles over time); quieter than §2.1’s sessionnot visible from the VM
loadload average 0.2 to 3.5 (2026-10-12, 21:19 to 22:07 CEST); a soak’s database and other containers on the box; this work on CPUs 0 to 3 onlyVM load average 1.4 to 4.5; the Mac’s own 4.6 to 11 (other containers and builds)
pinned tothe process measured on CPU 0, its peer on CPU 1 (CPU 0’s SMT sibling, as §2.1), load generators on CPUs 2 and 3the same CPU numbers in the VM
countersprimitives: cycles:u and instructions:u (kernel.perf_event_paranoid lowered to 2 for the run and restored to 4); handshakes: user and kernel cycles and instructions, through sudo perf stat -p (as §2.1’s); profiles: cyclesnone (no PMU): CPU time, and perf record -e cpu-clock as root in a privileged container
OpenSSL3.5.53.0.13
compilercancho at 0e40f430, LLVM backendthe same

The commands are §2.2’s, now in two scripts so that the next profile is one command: scripts/tls_perf.py openssl runs the openssl speed commands §2.2 lists, and scripts/tls_handshake_cost.py runs the handshake rows (openssl s_time -new against tls_echo, openssl s_server -num_tickets 0 and oserv; tls_many with 200 connections less tls_many with 1; tconn), each row alternating with the others for three or four rounds, the least kept. Three things differ from §2.2:

  • The resumed rows are new. openssl s_time -reuse does not resume a TLS 1.3 session (scripts/tls_server_cost.py says so), so benches/server/tconn.c has a resume mode: one full handshake that reads the server’s tickets, then every connection offers that session and the run fails on the first that does not resume. The server’s resumed handshake is tconn ... resume against tls_echo --tickets 2 and against openssl s_server (two tickets, its default); the client’s is tls_many ... resume (200 full, then 200 resumed) less tls_many with 200, over 200, and tconn ... resume, both against openssl s_server.
  • Both clients run against openssl s_server (§3.4 ran tconn against oserv): the same server for both sides of the ratio.
  • tls_perf.py profile now finds a P-256 verification by std.p256_vf.verify (#397; the server’s check reaches it from tls_server.sign with sign_checked and verify_raw inlined away) and X25519 by std.field25519_51 as well as x25519.scalarmult (a ladder inlined into its caller leaves only its field); --memory splits the memory category by kernel and by caller.

10.2 Each primitive

tls_prims_bench and openssl speed on the same core; x86-64 cycles are the least of two table passes of three repeats (instructions:u exact), arm64 times the least of two passes (three and five repeats). The ratio of §3.1 is beside each.

operationx86-64: instructionscyclesOpenSSL cyclesratio§3.1arm64: nsOpenSSL nsratio§3.1
X25519, shared secret694k148.8k106.4k1.4022.224,96319,4521.2831.4
X25519, key pair694k148.7k106.4k1.4020.631,00519,4521.59
P-256 ECDH, k·P5.00M971.9k176.4k5.5124.0134,76526,1025.1629.4
P-256 k·G (ecdh.public_key)1.20M231.7k32,836
ECDSA P-256 sign1.63M326.6k71.3k4.5860.850,78111,6914.3470.9
ECDSA P-256 verify3.49M686.8k230.8k2.9818.9104,00334,8112.9923.8
sign, then verify before sending5.12M1,011.3k302.1k (sign + verify)3.35154,29646,5023.32
HKDF-Expand-Label (32 bytes out)34.5k8.8k4,417
HKDF-Extract33.4k5.6k1,144
AES-128-GCM key preparation36.9k6.2k1,243

On the arm64 rows the key pair and the shared secret are the same work (525,613 instructions each under valgrind) and the first row of every pass is the slower one in both passes; the shared secret is the figure compared.

operationbytesx86-64: instructionscyclesOpenSSL cyclesratio§3.1, §3.2arm64: nsOpenSSL nsratio§3.1, §3.2
SHA-2566415.2k2,5135634.464.6530915.856.5
SHA-2561,024122k20,9852,5268.318.44,24941510.210.9
SHA-25616,3841.83M318.1k33.9k9.3810.063,4765,59911.312.1
SHA-384648.5k1,4911,0391.432.03071242.472.6
SHA-3841,02458.8k10,8115,7531.882,2017323.01
SHA-38416,384824k151.6k77.0k1.971.830,2739,8803.063.3
HMAC-SHA-256 (not like for like, §3.1)6440.2k6,7036871,411125
AES-128-GCM seal647381698020.210.928960.291.2
AES-128-GCM seal1,0243,1437451,3070.574.11712180.794.6
AES-128-GCM seal16,38443.5k10,6939,3371.159.12,3112,1351.087.0
AES-128-GCM open647461708020.210.928960.291.3
AES-128-GCM open1,0243,1517511,3070.571722180.79
AES-128-GCM open16,38443.5k10,6439,3371.149.12,3342,1351.097.1
ChaCha20-Poly1305 seal6414.6k2,4344026.057.06162372.602.7
ChaCha20-Poly1305 seal1,024119k20,1711,75611.511.55,1116737.598.0
ChaCha20-Poly1305 seal16,3841.79M292.3k27.5k10.611.276,9047,9119.7210.3
ChaCha20-Poly1305 open16,3841.79M293.4k27.5k10.710.878,1257,9119.8810.3
  • The asymmetric operations moved by 4 to 18 times and are now 1.3 to 5.5 times OpenSSL. The instruction counts moved with them (X25519 12.19M to 0.69M, a signature 25.25M to 1.63M, a verification 25.25M to 3.49M) and the code still runs at an IPC of 4.7 to 5.2: still too many instructions, not stalls (§3.1’s reading holds).
  • AES-128-GCM is below OpenSSL’s cost up to 1 KiB (OpenSSL’s speed -aead pays its EVP calls per record, which is most of a 64-byte record) and 1.08 to 1.15 times it at 16 KiB.
  • Not moved: SHA-2, HMAC, HKDF and ChaCha20-Poly1305, whose instruction counts are §3.1’s to within 1%.

10.3 Handshakes: full and resumed, server and client

x86-64: cycles and instructions (user and kernel) of the process measured, the least of three or four rounds, and the CPU a handshake (user and system, /proc or wait4) over the rounds; arm64: CPU a handshake, the least and the range. scripts/tls_handshake_cost.py <bin> --seconds 8 --rounds 3 [--sudo-perf].

x86-64: canchoOpenSSLratio (cycles)§3arm64: canchoOpenSSLratio (CPU)§3
server, full2.834M cycles, 9.67M instructions; 0.79 to 0.92 mss_server 0.990M, 1.78M; 0.28 to 0.34 ms2.86 (5.44 in instructions)15.10.404 ms (to 0.427)s_server 0.191 (to 0.195)2.1212 to 15
server, full, against the lean oservoserv 0.932M, 1.76M3.04about 17oserv 0.141 (to 0.144)2.87
server, resumed2.004M, 5.25M; 0.57 to 0.72 mss_server 0.858M, 1.73M2.340.297 ms (to 0.312)0.137 (to 0.142)2.17
client, full (chain and name verified)4.513M, 13.9M; 1.25 to 1.56 mstconn 1.628M, 3.36M2.7711.40.634 ms (to 0.699)tconn 0.280 (to 0.285)2.2611.6
client, resumed1.957M, 5.0Mtconn 0.826M, 1.65M2.370.261 ms (to 0.325; a difference of two runs)0.123 (to 0.129)2.12
  • The full server handshake is 5.8 times cheaper in cycles than §3.3’s (16.5M to 2.83M) and 8.0 times in instructions (77.8M to 9.67M); on arm64 7.6 times in CPU (3.08 to 0.404 ms). The client’s: 4.3 times in cycles (19.4M to 4.51M).
  • A resumed handshake is 0.71 of a full one on the server (x86-64; 0.74 on arm64) and 0.43 on the client, against 0.87 and 0.51 for OpenSSL. #394 measured 0.35 to 0.53 before #393 and #397 made the full handshake’s signature and ladders cheap: what a resumed handshake saves is now small, and what it keeps (the allocator, the key schedule, the tickets) is most of it (§10.4).
  • On arm64 the OpenSSL server is 1.35 times dearer than oserv (0.191 against 0.141 ms) where on x86-64 it is 1.06 times: s_server’s own work costs more in the VM. T1 is stated against s_server; both are given.
  • The x86-64 cycles moved by 0.3% between rounds (2.834 to 2.841M for the full handshake); the CPU times by 17%. The arm64 client’s resumed row is the difference of two runs’ wait4 times and moved by 25%.

The allocator experiment, repeated (A1, §3.5): the same rows with GLIBC_TUNABLES=glibc.malloc.trim_threshold=33554432:glibc.malloc.top_pad=1048576, alternating with the rows above. The OpenSSL rows, which run under the same setting, are the control: they did not move (x86-64: s_server 0.990M against 0.992 to 0.994M, tconn 1.628M against 1.620 to 1.634M).

x86-64: default → no trimchangeratio to OpenSSL, no trimarm64: default → no trimchangeratio, no trim
server, full2.834M → 1.860M cycles (8.12M instructions)−34.4%1.87 (oserv 1.99)0.404 → 0.294 ms−27.2%1.50 (oserv 2.03)
server, resumed2.004M → 0.904M−54.9%1.05 to 1.060.297 → 0.150 ms−49.5%1.06
client, full4.513M → 2.694M−40.3%1.65 to 1.660.634 → 0.431 ms−32.0%1.56
client, resumed1.957M → 0.948M−51.6%1.140.261 → 0.159 ms−39%1.30

This is the experiment, not the fix (§3.5 says why); A1 is the fix (§10.8).

10.4 Where the time goes now

perf record -F 1999 --call-graph dwarf,32768 for 6 s, sorted by tls_perf.py profile --memory. x86-64: 9,462 samples of cycles, applied to the 2.834M cycles of §10.3; arm64: 5,931 samples of cpu-clock, applied to 404 µs.

A full server handshake

categoryx86-64: shareM cyclesarm64: shareµs§3.3 (x86-64)
memory: malloc, free, brk31.6%0.9028.3%1145.7% (0.94M)
ECDSA verify: the server’s check before sending24.4%0.6925.6%10328.5% (4.71M)
ECDSA sign11.2%0.3213.9%5629.0% (4.78M)
X25519: the key pair and the shared secret10.8%0.3112.2%4931.8% (5.25M)
HKDF and HMAC (key schedule, Finished)6.2%0.186.5%261.0%
engine and event loop5.3%0.154.0%161.6%
system calls (sockets, epoll)4.2%0.125.3%211.2%
SHA-256/384 (transcript, digests)3.2%0.092.3%90.7%
page faults1.5%0.040.4%20.3%
record AEAD and traffic-key setup1.4%0.041.5%60.3%
  • Each primitive still costs inside the handshake what it costs alone: on x86-64 the check 0.69M against a verification’s 0.687M, the signature 0.32M against 0.327M, the two ladders 0.31M against 2 × 0.149M; on arm64 103 µs against 104, 56 against 51, 49 against 2 × 25.
  • Memory is the same cost as in §3.3, now six times the share. 0.90M cycles of malloc, free and brk against §3.3’s 0.94M: nothing changed in how a region gets its chunk. Of the 31.6%, 26.4 points are inside the brk system call and 20.2 under glibc’s systrim, giving the top of the heap back (arm64: 24.2 and 21.4). A 5-second perf trace of the server under s_time -new counted 424,593 brk calls for 3,804 handshakes: 112 a handshake (raw). The functions that open the regions are the key schedule’s: hmac.init 8.1 points, hkdf.expand 5.2, hkdf.expand_label 4.7, aes.sub_word 2.1 (x86-64).
  • The rest grew from 5% to 22% (HKDF and HMAC, the engine, the system calls, SHA-2, faults, records): 0.62M cycles, against §3.3’s 0.84M in the same categories. §4.5 said these become worth doing after the big levers; they now are.
  • The check is a quarter of the handshake (24.4% and 25.6%): §7 Q1 projected about 25% after every lever. It is now twice the signature (0.69M against 0.32M), because the signature has the comb and the check does not (B2).

A resumed server handshake (x86-64 11,364 samples, arm64 9,174): memory 52.5% (arm64 48.5%; 43.3 points in brk), X25519 15.5% (16.6%), HKDF and HMAC 13.6% (15.1%), system calls 6.3% (8.0%), engine 4.0% (5.3%), record and traffic keys 2.9% (3.6%), SHA-2 2.5% (2.4%), page faults 2.3% (0.4%). Making and sealing the two tickets is 15.8% inclusive (12.8% on arm64), half of it their regions. With glibc told not to trim, the resumed handshake is 0.904M cycles: two ladders (0.30M), and 0.60M of key schedule, tickets, records and sockets.

A full client handshake (x86-64 10,184 samples, tls_many 200 at a time in a loop; arm64 6,633, of which perf lost 21% to its buffer): memory 35.2% (23.7%), the chain’s verification 15.4% (14.9%), CertificateVerify 15.1% (15.1%), page faults 9.0% (10.0%; the first touch of 200 slots, §3.4), X25519 6.6% (9.6%), system calls 5.8% (10.9%), engine 5.0% (4.2%), HKDF and HMAC 3.9% (4.2%), SHA-2 1.6% (1.8%), record 1.1% (1.2%), certificate parsing 1.0% (0.9%). The x86-64 regions are opened most by x509.well_formed (8.9 points).

10.5 A request on a kept connection

scripts/https_hello_test.py --cost 5 with --kload --tload --plain --oserv, two passes, each followed by the same with glibc told not to trim; server CPU a request in microseconds:

x86-64: pass 1pass 2arm64: pass 1pass 2
examples/api, plain6.175.792.262.22
oserv, plain6.015.302.312.30
https_hello, TLS9.209.814.494.49
oserv, TLS (OpenSSL)8.308.663.893.98
ratio1.111.131.151.13
https_hello, no trim9.1311.184.524.38
oserv, TLS, the same runs8.1011.123.923.79
  • A request costs 1.11 to 1.15 times OpenSSL’s server, on both machines; §3.5 measured 2.3 (x86-64) and 1.3 (arm64). Telling glibc not to trim changes nothing now (1.13 and 1.01 times on x86-64, 1.15 and 1.16 on arm64): a request opens no region that reaches the kernel. A 5-second perf trace of the loaded server counted no brk at all on either machine (x86-64: 146,988 sendto, 439,590 recvfrom, 9,195 epoll_wait).
  • Where it goes (x86-64 11,959 samples, arm64 11,445): system calls 53.0% (50.3%), the engine and the loop 42.6% (46.0%), the record AEAD 1.5% (2.1%), memory 1.0% (0.8%). Inside the engine, tls.lease and tls.settle, which copy a connection’s 4.5 KiB core into a work area and back out and clear the area (#395), are 23.6% of a request inclusive (arm64 26.6%): tls.core_out 9.1%, tls.lease 8.8%, tls.clear_area 5.0%. #395 measured this as 16% more CPU a request on arm64.
  • The record layer around the cipher (tests/programs/record_cost.cho less gcm_cost.cho 16 hw, best of two runs, ns a record): x86-64, seal 80 against 44 at 64 bytes (the layer 45% of the record), 252 against 188 at 1 KiB (25%), 2,838 against 2,664 at 16 KiB (6%), open within a few ns of the cipher at 16 KiB; arm64 Linux, 69 against 38 (45%), 225 against 172 (24%), 2,776 against 2,479 (11%). On Linux the layer’s region is cheap (a 64 KiB malloc that does not reach the kernel); on macOS it is not (gcm-wide.md §8.2: 592 ns against 37 at 64 bytes on the M4).

10.6 The levers against §4.3’s projection

leverprojected (§4.1, §4.3)builtmeasured as built (x86-64 server handshake unless named)against the projection
A1 the allocator5.7% of the handshake (0.94M cycles), 9.9% of the client’s, 48 to 60% of a kept requestnothe experiment now removes 34.4% (0.97M cycles), 40.3% of the client’s, 54.9% of a resumed one, and nothing of a kept requestnot realised. The cycles it removes are the projected ones (0.97M against 0.94M); the share is six times larger because everything around it shrank. The kept request no longer needs it
B1 comb for k·Gthe signature ÷ 3.1 (4.78M to 1.54M)#397, with B5’s field4.78M → 0.32M, ÷ 15.1 (alone 4.66M → 0.327M, instructions ÷ 15.5)4.9 times the projected gain, with the field in it
B2 table per identity for the checkthe check ÷ 2.4; ÷ 3.6 with B5no; #397 made verification wNAF on the new fieldthe check 4.71M → 0.69M, ÷ 6.81.9 times the projection of B2 with B5, without B2
B3, B4 client verificationsthe chain ÷ 2.4 (B3), CertificateVerify ÷ 1.4 (B4)B4 as #397’s wNAF; B3 noeach 5.3M → 0.69M, ÷ 7.7above both
C1 X25519 on 25.5-bit limbs÷ 2.36 (2.5 times on 96%)no, superseded by C2
C2 wide multiply÷ 3.6 on the field multiplications (4 times on 96%); “3 times needs about 5 times”#393, X25519 onlythe ladders 5.25M → 0.31M, ÷ 17.1 (alone 2.34M → 0.149M, instructions ÷ 17.6)4.7 times the projected gain; 51-bit limbs remove the 16-bit representation’s 256-product schoolbook, which the projection’s 4 times did not count
B5 NIST-prime reduction1.7 times on 79% of the ECDSA operations#397: a 28-bit field using the prime’s form (not 32-bit limbs)in B1’s and B2’s rows: the field multiplication 3.8 to 4.0 times faster on this machine (p256-fast.md §9)above
D1 ticketsa resumed handshake 42.5% of a full one (7.0M); 36.8% with A1#3942.00M: 0.71 of the as-built full handshake; 0.90M and 0.49 of it with A1’s experiment3.5 times cheaper in cycles than projected, but a larger share: the projection kept X25519 at 5.25M
D2 multi-block AES-GCM2.5 to 3.5 times at 16 KiB, nothing at 64 bytes#39216 KiB 102k → 10.7k cycles (9.5 times), 1 KiB 6.7k → 0.75k (9.0), 64 bytes 0.8k → 0.17k (4.7)about 3 times the projection
E1 memory per connectionnot projected#395119 to 17 KiB idle (tls-memory.md); its CPU is 24% of a kept request (§10.5)
A2, C3, D3, D40.9, 3.2, under 1, 0.3 pointsno

The handshakes against the projection:

§4.3’s projection (arithmetic)as built (measured)as built with glibc not trimming (measured)
server, full, x86-645.4M cycles, 4.9 times s_server (A1, B1, B2, C1, B5)2.83M, 2.86 times (B1, B5, C2; no A1, B2)1.86M, 1.87 times
client, full, x86-647.8M, 4.6 times tconn4.51M, 2.77 times2.69M, 1.65 times
server, resumed, x86-647.0M (6.1M with A1)2.00M, 2.34 times OpenSSL’s resumed0.90M, 1.05 times

The projection was conservative on every operation and right about the allocator’s cycles. It assumed 2.5 to 4 times on X25519’s field multiplications and 1.7 on P-256’s. X25519 became 17 times cheaper in the handshake: 51-bit limbs remove the 16-bit representation’s 256-product schoolbook and its bounds-checked loads and stores (wide-multiply.md §10.5). P-256’s field multiplication became 3.8 to 4.0 times faster on this machine (p256-fast.md §9), and with the comb that made the signature 15 times cheaper, and with wNAF the check 6.8 times. It was wrong about the order: it put A1 first for its gain over effort, and the changes that came first, and gave most, were the fields and the comb.

10.7 The targets of §1.1, as built

targetx86-64 (cycles)arm64 (CPU time)met?
T1 server, full, at most 5 times s_server (stretch 3)2.86 (oserv 3.04)2.12 (oserv 2.87)met, and the stretch (against oserv on x86-64, not the stretch)
T2 client, full, at most 5 times a verifying OpenSSL client (stretch 3)2.772.26met, and the stretch
T3 a kept request, at most 1.2 times oserv (stretch parity)1.11, 1.131.15, 1.13met; not parity
T4 AES-128-GCM at most 3 times at 1 and 16 KiB, 1.5 at 64 bytes (stretch 2)0.57, 1.15, 0.210.79, 1.08, 0.29met, and the stretch. ChaCha20-Poly1305 documented: 6.1 to 11.5 (x86-64), 2.6 to 9.9 (arm64)
T5 resumed: at most the two X25519 operations plus 10 points of a full handshakebound 0.30M + 0.28M = 0.58M; measured 2.00M (0.90M with glibc not trimming, against a bound of 0.48M)0.297 ms against a bound of about 0.09 msnot met (as built with A1, §10.11: 0.884M; restated as at most 1.2 times OpenSSL’s resumed handshake (§7 Q9), met by the server: 1.04 on x86-64, 1.12 on arm64; not by the client: 1.21, 1.33)

T5 is not met, and the levers of §10.8 do not meet it either (the arithmetic in §10.8 leaves a resumed handshake at about 0.69M cycles). It was written when the rest of a handshake was 5% of it; it is now 22% of a full one and most of a resumed one, and the bound moves down whenever the full handshake gets cheaper. Against OpenSSL’s resumed handshake the as-built one is 2.34 times (x86-64) and 2.17 (arm64), and 1.05 to 1.06 times with glibc not trimming: §7 Q9 asks whether T5 should be restated that way.

10.8 The next levers, ranked by measured gain

Gain is the share of the as-built x86-64 handshake (§10.3, §10.4) the lever removes, and of a kept request (§10.5). The issues are #378’s sub-issues; #380 and #381 were reopened for what of their scope is left (B2 and B3, P-384; P-256, bigmod and Poly1305 on the builtins). A1’s is measured (the experiment of §10.3); the others are arithmetic from measured shares and the stated assumption. The gains are of today’s handshake and overlap where two act on the same code (P1 and B2 on the check, D3 and D4 on HMAC’s compressions); the sequence below the table applies each to what the ones before it leave.

#leverissuebasisserver fullserver resumedclient fullkept request
A1keep freed region chunks per thread (a small stack of them: the key schedule nests regions three deep, hmac.init in hkdf.expand in hkdf.expand_label), so free never trims and malloc never grows the heap. Compiler change, both backends#375measured: glibc told not to trim, alternating (§10.3); built (§10.11): 34.6% (arm64 23.8), 55.8% (45.6), 40.5% (38.0)34.4% (arm64 27.2)54.9% (49.5)40.3% (32.0)0 (no brk now)
P1P-256’s field on mul_wide: four 64-bit limbs in place of ten of 28 bits (wide-multiply.md §11)#381the multiplication kernels (p256_kernels.mul_p, sqr_p, sqr_n) are 24.7% of the server’s handshake and 22.2% of the client’s by the profile (self time; all kernels 33.0%); assumed 2.5 times on them, the low end of wide-multiply.md §11’s 3 to 3.5 for bigmod; built (§10.15): five 56-bit limbs inside the kernels, 1.4 to 1.6 times on them; the full handshake 19.9% cheaper on the server (0.37M cycles; this row projected 0.42M), 20.3% on the client14.8 points013.30
B2a fixed-base table for the server’s own key (the check as two combs)#380the check is 24.4%; two combs are about 1.44 times cheaper than a wNAF verification: 2 × public_key less one inversion mod p plus one mod n, against verify_raw, from p256-fast.md §9’s M4 parts (34.6, 5.8, 10.0 against 105.8 µs); built (§10.13) as a split-wNAF table, not two combs: the check 2.03 times cheaper, the full server handshake 24% fewer instructions, the client’s 18% (B3)7.5 points0(B3, per trust anchor: 4.7 on the chain’s 15.4%)0
D3a SHA-256 builtin (SHA-NI, ARMv8 SHA-2)#445crypto.compress is 7.3% of the server’s handshake, 11.6% of a resumed one, 3.8% of the client’s (self time); assumed OpenSSL’s speed a block (9.4 times on x86-64 at 16 KiB, 11.3 on arm64); built with D4 (§10.14): SHA-256 at 0.78 to 1.00 of OpenSSL’s cycles, the two together 11.5% of a full server handshake and 27.6% of a resumed one, 90% and 88% of the cycles projected here6.5 points10.33.40
D4HMAC with the key pads kept (HKDF reuses a keyed state); built (§10.14)#446hmac.init’s two compressions, outside its regions: at most 9.9 − 8.1 = 1.8% of the server’s handshake, 16.5 − 12.8 = 3.7% of a resumed one, 1.8% of the client’sat most 1.83.71.80
L1#395’s work areas without the copy a call: keep a busy connection’s lease across calls, or copy only what the call changed#447tls.lease + tls.settle are 23.6% of a kept request inclusive (arm64 26.6%); the measured upper bound000up to 23.6% (26.6)
X1X25519 to OpenSSL’s cost; one inversion for the two ladders (C3)10.8% of the handshake at 1.40 times OpenSSL: at most 3.1 points; the two inversions are 1.4% (C3: 0.7)at most 3.14.41.90
R1an AEAD that seals in place, so the record layer stops copying#448the layer around the cipher (§10.5): 36 of 80 ns at 64 bytes, 174 of 2,838 at 16 KiB (x86-64); tls_record.* is 1.3% of a kept request inclusiveunder 1under 1under 1under 1.3%; 45% of a 64-byte record’s seal, 6 to 11% of a 16 KiB one on Linux; more on macOS
M1std.bigmod on mul_wide (P-384, RSA)#381not in this handshake; 3 to 3.5 times on mont_mul by wide-multiply.md §11’s arithmetic; built (§10.15): RSA-2048 verification 6.8 times fewer cycles, P-384 3.9 to 5.10000

Applied in order to the x86-64 server’s full handshake (arithmetic, from the shares above):

afterhandshakeM cyclesagainst s_server
as built100%2.832.86 times
+ A1 (measured; as built, §10.11: 65.4%, 1.844M, 1.86 times)65.6%1.861.87
+ P1 (2.5 times on the kernels: 24.7 × 0.6; as built, §10.15: 52.4%, 1.483M, 1.48 times)50.8%1.441.45
+ B2 (1.44 times on what is left of the check, about 14.2%; as built, 2.03 times on the check, §10.13)46.5%1.321.33
+ D4 (1.8) then D3 (on the 5.5 points of compression left: × 0.89)39.8%1.131.14
  • A1 first, by twice the next lever, on every kind of handshake, and for the resumed one it is most of what there is: with A1 measured and D3 and D4 by arithmetic, a resumed server handshake comes to about 0.90 − 0.07 − 0.14 = 0.69M cycles, about 0.8 times OpenSSL’s resumed one (arithmetic).
  • P1 before B2: it speeds the signature, the check, ECDH and the client’s verifications at once, where B2 speeds the check. Together they leave the check at about 10% of the handshake.
  • For a kept connection, L1 is the only lever; T3 is met without it, and halving the copies would take a request from 1.11 to about 1.0 times OpenSSL’s (arithmetic): T3’s stretch. As built (§10.12): 0.94 times, the stretch met.
  • R1 matters to bulk transfer and to macOS, not to a handshake or a small request on Linux.

10.9 Not done, and what this does not show

  • No lever was built here. A1’s gain is the experiment’s, not a fix’s; the rest is arithmetic. A1 was built afterwards (§10.11), and its gain measured as a fix’s.
  • arm64 has no cycle counts (the VM has no PMU) and its client profile lost 21% of its samples to perf’s buffer.
  • The client’s figures include the first touch of 200 slots (§3.4); a steady-state client was not measured.
  • The arm64 resumed client is the difference of two wait4 times over 200 connections and moved by 25% between runs.
  • Not measured: P-384, RSA, TLS 1.2, Cranelift, latency, macOS natively (the guard’s darwin-aarch64 entry, §9).
  • The first arm64 run’s resumed rows (assets/tls-performance/arm64/hs_arm64.txt and hs_arm64_tunables.txt, server-resumed and the tconn client-resumed rows) used a tconn built before its resume mode, which ignored the argument: they are full handshakes and are not used; hs_arm64_resumed.txt replaces them.

10.10 The guard, completed

The linux-aarch64 entry of benches/tls_baseline.json had kept the counts from before #393 and #397 (the Docker VM’s disk was full when those merged; #392 re-recorded its six AES-GCM rows). scripts/tls_perf.py check on arm64 Linux at 0e40f430 failed exactly those seven rows as cheaper than 1/1.3 (raw): X25519 11.09M to 0.53M instructions, the signature 26.26M to 1.06M, sign-and-check 52.88M to 3.36M, a verification 26.63M to 2.30M, k·G 24.35M to 0.76M, k·P 24.36M to 3.23M. tls_perf.py record re-recorded the entry on arm64 Linux; the other 26 rows came back within 1.01 of what was recorded. Then, at this change’s head, check passes 33 of 33 rows on both entries (x86-64 on gram, arm64 in the VM) and scripts/tls_perf_mutant.py fails exactly its four rows at 2.00 on both. check now takes 11.5 s on x86-64 and 8.3 s on arm64 (§6 said 40 and 12: the primitives are cheaper to run under valgrind).

10.11 A1 as built

region-chunk-cache.md built A1 (#375): each thread keeps the chunks its regions give back, at most 16, last in first out, cleared when a region ends. Measured against its base (2cb860b3), alternating, on the machines of §10.1 (x86-64: load average 1.2 to 3.6; arm64: the VM at 1.6 to 3.4); scripts/tls_handshake_cost.py --seconds 8 --rounds 2, the least of four rounds; raw output in assets/region-chunk-cache/.

x86-64 cycles: before → afterratio to OpenSSLarm64 CPU: before → afterratio
server, full2.821M → 1.844M (−34.6%)2.87 → 1.86 s_server (oserv 1.98)0.382 → 0.291 ms (−23.8%)1.93 → 1.50 (oserv 2.09)
server, resumed2.001M → 0.884M (−55.8%)2.33 → 1.040.270 → 0.147 ms (−45.6%)1.99 → 1.12
client, full4.474M → 2.664M (−40.5%)2.74 → 1.64 tconn0.682 → 0.423 ms (−38.0%)2.44 → 1.51
client, resumed1.901M → 0.994M (−47.7%)2.32 → 1.210.284 → 0.159 ms (−44.0%)2.35 → 1.33
  • brk: 112 a full server handshake and 136 a resumed one before; 9 in a whole 5-second run after, on both machines (strace -c, region-chunk-cache.md §8.1). §1.2’s bound of 2 a handshake is met.
  • The fix matches the experiment of §10.3 (1.860M, 0.904M, 2.694M with glibc told not to trim): it is a little cheaper, since it skips malloc and free as well as the kernel. On arm64 the server’s gain is smaller than the experiment’s (23.8 against 27.2%, 45.6 against 49.5%) and the client’s larger (38.0 against 32.0%); the VM moves about 10% between rounds.
  • The targets: T1 1.86 (x86-64) and 1.50 (arm64), inside its 3-times stretch; T2 1.64 and 1.51; T5, as §7 Q9 restates it (at most 1.2 times OpenSSL’s resumed handshake, server and client), is met for the server: 1.04 and 1.12, and not for the client: 1.21 and 1.33 (from 2.32 and 2.35). As first written (the two ladders plus 10 points of a full handshake: 0.30M + 0.18M = 0.48M), it is not: 0.884M.
  • Clearing each region’s bytes when it ends costs +0.1% of a full handshake’s instructions and +0.3% of a resumed one’s, inside the cycles’ spread (region-chunk-cache.md §8.3).
  • The next lever is now P1 (§10.8’s order). With A1 measured, a server’s full handshake is 1.844M cycles and the arithmetic of §10.8’s sequence starts from it.

10.12 L1 and R1 as built

record-in-place.md built L1 (#447) and R1 (#448). Measured against 3d7e8e9d (A1 merged) on gram (the server on CPU 6, its load on CPU 7), alternating; raw output in assets/record-in-place/.

  • L1 was the loop, not the bytes. The 24% of §10.5 was the 296 words of the core copied one at a time through the engine’s boxes (the store may alias the boxes, so every word reloaded both and checked both bounds). Block copies took it to 10.3% of a request; an established connection that rests in its area until another needs it (at most 32 by default, tls.set_resting) took it to 3.6%.
  • A kept https_hello request: 40.0k cycles (72.2k instructions) to 31.4k (45.8k); OpenSSL’s oserv 33.3k (43.4k): 1.20 to 0.94 times. T3’s stretch, parity, is met.
  • R1: a record is sealed where it is sent from and opened straight into the received data, with no region and no byte loop. Through both engines with no sockets (tests/programs/engine_cost.cho): a 64-byte record 6.6k cycles to 1.15k, 1 KiB 8.4k to 2.6k, 16 KiB 39.1k to 26.7k, of which the cipher is about 21k. §10.8 put R1 under 1% of a request: on its own it was about 1% (33.6k to 33.9k cycles with L1’s block copies, 33.1k to 33.6k with R1 added), as projected; for a record it is the 5.7 times and 1.5 times above.
  • Memory: an idle connection is what it was (17.0 KiB at 10,000); resting keeps at most 33 areas more written per engine, 2.1 MiB after a 16 KiB record on each connection (tls-memory.md §12); the high-water mark fell 16 KiB a connection.
  • Not changed: /big (16.0 to 15.7 cycles a byte), which is packages/http-server’s and the example’s byte loops, 85% of it.

10.13 B2 and B3 as built

p256-fast.md §11 built both (#380): a table for a P-256 key that verifies many signatures, kept once per engine and per key, never per slot. Not two combs, as §10.8 assumed: the 257 width-7 wNAF digits of each scalar are cut into four runs of 65, and a table holds the odd multiples 1 to 63 of Q, 2^65 Q, 2^130 Q and 2^195 Q (128 affine points, 10,305 bytes), so the two scalars share one chain of 64 doublings where p256-fast.md §7’s verification has 256. Two combs would have added 61 times a scalar and taken 83 KB a key; this adds 32 times and takes an eighth of that. The server keeps one per identity (built by add_identity and replace_identity, tls-server.md §3.5); the client one per P-256 trust anchor, at most 16 (built by trust; the system bundle has 3, 30,939 bytes; x509-verify.md §11).

Measured on gram (core 2 and its sibling, load average 3.5 to 5.1, other agents’ benchmarks on cores 0, 1, 4 and 5), against origin/main at 3d7e8e9d (A1 included), alternating; raw figures and conditions in p256-fast.md §11.4 and §11.5:

beforeafterchangeratio to OpenSSL, before → after
a verification (tls_prims_bench, cycles)688.1k326.4k with the key’s table−53% (instructions 3.49M → 1.54M)2.72 → 1.29
the check after a signature (sign_checked less sign)687k339k−51%
sign and check (sign_checked)1,014.5k666.5k−34%2.99 → 1.97 (OpenSSL’s sign + verify)
server, full handshake1.923M cycles, 8.092M instructions1.467M, 6.127M−23.7%, −24.3%s_server 1.85 → 1.46, oserv 1.98 → 1.56
client, full handshake (under a P-256 CA)2.743M, 10.717M2.303M, 8.767M−16.0%, −18.2%tconn 1.62 → 1.41

arm64 (the M4’s Docker VM, CPU time, the least of four rounds, VM load average 7.2 to 8.2): the server’s full handshake 0.292 → 0.232 ms (−20.5%; 1.52 → 1.23 times s_server), the client’s 0.422 → 0.364 ms (−13.7%; 1.52 → 1.31 times tconn).

  • Against §10.8: B2 was 7.5 points of the pre-A1 handshake, the check 1.44 times cheaper; as built the check is 2.03 times cheaper and saves 24% of the instructions of the A1-era handshake (1.965M of 8.092M; the check’s own saving is 1.946M). B3 was 4.7 points; as built 18% of the client’s instructions, one verification of its chain (the leaf’s CertificateVerify has no table).
  • T1 (at most 5 times s_server, stretch 3) is now 1.46 on x86-64, and T2 1.41.
  • What B3 does not reach: the measured client verifies under a private P-256 CA. Of the 31 signatures of x509-verify.md §8.2’s 14 real chains, 22 are RSA, 7 P-384 and 2 P-256, and none ends at a P-256 root; for public sites the next cost is P-384 verification, 9 to 16 times OpenSSL’s cost (p256-fast.md §12, which decides it is worth building, on #381’s field).
  • The guard has two new rows on both entries, ecdsa_p256_verify_tabled and ecdsa_p256_sign_checked_tabled, recorded on each entry’s own machine; the other 33 were not re-recorded and pass, 35 of 35 on both.
  • The next levers, by §10.8’s order: P1 (P-256’s field on mul_wide, #381), now on a check half the size it was; D3 and D4 (#445, #446).

10.14 D3 and D4 as built

sha256-builtin.md built D3 (#445: hw_sha256, sha256_blocks and sha256_block_words, SHA-256’s compression on SHA-NI and ARMv8 SHA-2, std.crypto on them) and D4 (#446, hkdf.md §9: a key’s two padded blocks hashed once, HKDF and the TLS key schedule’s traffic keys and handshake secrets on prepared keys, and hmac.final with no region). Measured against 3d7e8e9d (A1 built), alternating, on the machines of §10.1, x86-64 pinned to CPUs 4 and 5; raw output in assets/sha256-builtin/. sha256-builtin.md §8.5 has every row; the ones that move a handshake:

x86-64: before → afterratio to OpenSSLarm64: before → afterratio
SHA-256, 64 / 1,024 / 16,384 bytes (cycles; arm64 ns)2,565 / 20,829 / 332,622 → 440 / 2,406 / 33,8574.56 / 8.21 / 9.78 → 0.78 / 0.95 / 1.00557 / 4,425 / 66,284 → 141 / 455 / 6,5765.78 / 9.21 / 12.0 → 1.46 / 0.95 / 1.19
HKDF-Expand-Label (cycles; arm64 ns)5,641 → 1,314; 677 prepared1,205 → 232; 155 prepared
server, full (cycles; arm64 CPU)1.848M → 1.636M (−11.5%)1.86 → 1.65 s_server0.297 → 0.262 ms (−11.8%)1.59 → 1.40
server, resumed0.889M → 0.644M (−27.6%)1.04 → 0.760.152 → 0.111 ms (−27.0%)1.10 → 0.80
client, full2.660M → 2.540M (−4.5%)1.64 → 1.57 tconn0.423 → 0.389 ms (−8.0%)1.49 → 1.37
client, resumed0.954M → 0.827M (−13.3%)1.15 → 0.990.172 → 0.151 ms (−12.2%)1.40 → 1.23
  • §10.8’s arithmetic held in cycles: 0.212M of the 0.235M projected for a full server handshake and 0.245M of the 0.280M for a resumed one. The shares are larger than projected because A1 came first.
  • A resumed server handshake is now cheaper than OpenSSL’s (0.76 of its cycles, 0.80 of its time on arm64), and T5 as §7 Q9 restated it (at most 1.2 times OpenSSL’s resumed handshake) is now met by the client too: 0.99 on x86-64, 1.23 on arm64 (so not on arm64 by 0.03).
  • The guard cannot see the x86-64 builtin: Valgrind does not have SHA-NI and hides it, so the linux-x86_64 entry counts the software path (sha256-builtin.md §2, §8.3); the linux-aarch64 entry counts the instructions, and was re-recorded. A new row, hkdf_expand_label_prepared_32, is in both.
  • The next lever of §10.8 is P1 (the field under the check and the signature, untouched here); L1 and R1 are §10.12’s, B2 and B3 §10.13’s. SHA-384 (a TLS_AES_256_GCM_SHA384 handshake’s hash, and the second transcript every handshake keeps until the suite is known) stays in software at 1.4 to 3 times OpenSSL.

10.15 P1 and M1 as built, and Poly1305 (#381)

p256-fast.md §13 and wide-multiply.md §12 built P1, M1 and Poly1305 on the wide-multiply builtins. P1 is not what §10.8’s row describes: four 64-bit limbs were measured and not chosen (they change the representation #380’s files read and lose §4.4 of p256-fast.md’s lazy reduction); five 56-bit limbs inside the kernels were, and the additions and subtractions became wrapping_*. Measured against main at 3d7e8e9d (A1 built, before §10.12’s L1 and R1 merged), alternating, on the machines of §10.1; raw output in assets/wide-multiply/381/.

Handshakes (scripts/tls_handshake_cost.py <bin> --seconds 8 --rounds 2 --cpu 0 --peer-cpu 1 server-full client-full, the two builds alternating twice, the least of four rounds; x86-64 gram with --sudo-perf, user and kernel cycles, load average 3.1 to 3.6 from other agents’ jobs; arm64 the Docker VM of §10.1, CPU time, load 3.0 to 6.7):

x86-64 cycles: main → branchchangeratio to OpenSSLinstructionsarm64 CPU: main → branchchangeratio
server, full1.852M → 1.483M−19.9%1.85 → 1.48 s_server (oserv 1.96 → 1.57)8.09M → 6.11M0.293 → 0.251 ms−14.3%1.57 → 1.34 (oserv 2.12 → 1.79)
client, full2.673M → 2.130M−20.3%1.63 → 1.31 tconn10.5 to 10.9M → 7.9M0.423 → 0.358 ms−15.4%1.53 → 1.28

The resumed rows were not run: a resumed handshake does no P-256 operation and no ChaCha20-Poly1305 (the cipher is AES-128-GCM). §10.8 projected P1 at 14.8 points of the pre-A1 server handshake, 0.42M cycles; it took 0.37M (13 points of it, 19.9% of the A1-built one), with the kernels 1.4 to 1.6 times faster rather than the 2.5 assumed: the additions, the subtractions and the inversions, which the projection left out, paid the rest. The client’s projected 13.3 points (0.60M) came to 0.54M. T1 is now 1.48 times s_server on x86-64 and 1.34 on arm64; T2 1.31 and 1.28.

Primitives (x86-64 cycles on gram, the least of six runs; p256-fast.md §13.4, wide-multiply.md §12): P-256 ECDH 980k → 568k cycles (5.54 → 3.21 times OpenSSL), the signature 327k → 225k (4.61 → 3.17), a verification 682k → 417k (2.92 → 1.78); RSA-2048 verification 1.05M → 0.15M (17.8 → 2.61), P-384 ECDH 11.9M → 3.1M (12.1 → 3.10); Poly1305 over 16 KiB 89.6k → 39.0k. ChaCha20-Poly1305 is otherwise ChaCha20’s (its rows below moved 7 to 9% in instructions).

The guard (§6). After the rebase onto #466 (36 rows), scripts/tls_perf.py check failed exactly the seven P-256 rows as cheaper than 1/1.3 on both machines (x86-64 0.58 to 0.65, arm64 0.69 to 0.76), and nothing else; ChaCha20-Poly1305 moved 0.91 to 0.95, inside. Both entries were re-recorded on their own machines (record on gram, and in the arm64 VM), keeping their descriptions; check then passes 36 of 36 on each and scripts/tls_perf_mutant.py fails exactly its four rows on each (assets/wide-multiply/381/tls_perf_*.txt). The handshakes above were measured before #463, #465 and #466 merged.

TLS memory per connection

Issue #383, part of #378 (TLS performance: closing the gap with OpenSSL). Status: built, measured (PR #395). The first commit is the design: this document, the measuring script scripts/tls_memory.py and OpenSSL’s server benches/server/ossl_hold.c; §10 is what the second built and measured. Extends docs/tls-pure.md §7.4 and §10 question 6, docs/tls-core.md §9.1 and docs/tls-server.md §6 and §11.

1. The claim, and what measuring it found

An established connection of examples/tls_echo was said to cost about 120 KiB, against 26 to 48 KiB for OpenSSL and 0.34 KiB for a plain connection. A broker with 100,000 clients is the case this hurts: 12 GB of connections before it holds a message. This document measures the claim (§2, §4), lists what a slot holds (§3), says what the earlier documents claimed and what the code does (§5), weighs six ways out (§6) and chooses one (§7).

  1. The figure is right, and it is not the buffers. An established server connection is 119 KiB resident in tls_echo (arm64 and x86-64 agree to 2.4 KiB) in a slot that is 263 KiB in the engine’s own accounting. Memory is resident only where it was written: a box_slice with a zero fill is calloc (docs/zeroed-slices.md), and a page nobody writes is not in the RSS. So the 64 KiB handshake buffer costs one or two pages, not sixteen.
  2. Where the 119 KiB is. About 80 KiB is the slot’s integer slice: std.ecdh’s work area (9,593 words, 75 KiB), which the server’s signature and every key exchange use, and the two transcript states (2.8 KiB). Every handshake writes it, so it is resident for good. docs/tls-core.md §9.1 counted 158 words for that slice; it is 10,241 since the work area was moved into it (a region holds at most 64 KiB, docs/tls-parity.md §3.3.1). About 25 KiB is one to three pages written in each of the byte buffers. The example’s own three buffers a slot add about 14 KiB. (The engines alone, a client slot and a server slot in one process with no example, are 211 KiB for the pair, §4: 105 a slot.)
  3. Nothing is ever released. docs/tls-pure.md §7.4 says the handshake reassembly buffer is “released into the slot’s pool after Finished”. There is no pool and no release: the buffer is compacted and never freed or cleared.
  4. A record in flight costs 90 KiB more, for good. The first 16 KiB record on a connection writes up to 16 KiB of each of the input, opened-record, received-data and output buffers, and of the example’s three. They are not given back when the record is gone.
  5. start writes the whole integer slice (80 KiB) when a connection begins, before a byte has arrived: connected is 85 KiB.

OpenSSL on the same machines, same certificate, a server that only accepts, handshakes and echoes (§2). An established idle connection at 10,000 connections: 13.6 KiB with SSL_MODE_RELEASE_BUFFERS and 24.2 KiB without on 3.0.13 (arm64); 27.8 and 49.4 KiB on 3.5.5 (x86-64); tls_echo is 119.4 and 121.8. In the middle of a handshake it is 42 to 54 KiB (arm64) and 56 to 77 (x86-64). The “26 to 48” of tls-nonblocking.md §8.3 was a client through memory BIOs, and agrees with 3.0.13’s range.

The choice (§7). Keep what an established connection needs in a small core per slot, and lease the rest, the handshake’s scratch and the record buffers, from a pool of work areas for as long as a handshake runs or bytes are in flight. The engine’s API does not change and neither does what it accepts and refuses. A prototype measured 17 KiB for an established idle connection of tls_echo (from 119) and 54 KiB after a 16 KiB record on each (from 211), on arm64, with the example’s own buffers included.

2. How it was measured

scripts/tls_memory.py starts a server per count N and opens N connections from Python’s ssl (memory BIOs, so a handshake can stop before the client’s Finished), and reads the server’s VmRSS from /proc/<pid>/status after each phase. The figure for a phase is (RSS in the phase - RSS at the server’s start) / N. The first server is for the phases in which the handshakes are not finished; the second is for established connections, handshaken one at a time, as a program that bounds its handshakes in progress makes them:

phasewhat the N connections are doingserver
connectedTCP connected, nothing sent: the slot is started and waits for a ClientHellofirst
handshakeClientHello sent, the server’s whole flight read, the client’s Finished held back: all N in progress at oncefirst
establishedFinished sent, one byte echoed, then idlesecond
echoed16,384 bytes sent and read back on each connection in turn, then idle again: what traffic leaves residentsecond
partial12,000 bytes of a 16,384-byte record sent on every connection at once, the rest held backsecond
all-echoedthe rest sent and read back on all N, idle again: the high-water marksecond

N is 50, 100, 200 and 10,000. The server is examples/tls_echo built from origin/main (7bf3628); OpenSSL’s is benches/server/ossl_hold.c: one thread, one epoll, accept, handshake, echo, no tickets, TCP_NODELAY, with and without SSL_MODE_RELEASE_BUFFERS. The certificate is the committed test identity (tests/vectors/tls/echo, ECDSA P-256) for both. Kernel socket memory is not in the RSS and is the same for both. One change to the example, for the 10,000: tls_echo refused --connections over 4,096, because a queue entry packs id * 4096 + slot into one word; front.cho has slot_base() = 1,048,576 and the options take that much.

Machines. arm64: this Mac’s Docker, native Linux 6.8 (not emulated), image lexsys-hooks-env, OpenSSL 3.0.13, 4 KiB pages. x86-64: ssh gram, Linux 7.0, OpenSSL 3.5.5, 4 KiB pages, the same sources, on cores 6 and 7 with the host’s other work on its other cores. Nothing here is a timing, and gram was not quiet.

3. What a slot holds

A slot is two slices of the engine’s boxes (tls_slot): 10,241 words of integers and 187,191 bytes, 269,119 bytes (262.8 KiB), the same for a client and a server. The sizes are printed by a program that imports tls_slot (ints_len, bytes_len, the offsets).

whatwherebytesneededcould be released
incoming record, header includedb_in16,645whenever a record is partly readbetween records
handshake reassemblyb_hs65,536a message in pieces: a server’s ClientHello (limit 16 KiB), a client’s Certificate (limit 64 KiB); after the handshake, a NewSessionTicket or KeyUpdate in pieceswhen empty
output queueb_out49,218up to three full records for takewhen take has emptied it
opened recordb_plain16,640while a record is opened and copied outbetween calls
received datab_recv16,640from feed to recvwhen recv has emptied it
the server’s leaf (a client’s)b_leaf16,384Certificate to CertificateVerifyafter CertificateVerify
keys, IVs, secrets, hardware key schedulesb_keys1,728the whole connectionat its end
ticket offered, ticket received, its PSK, hostb_offer ..4,400a client’s resumption; a server’s ALPN and SNI are kept herea server’s: never needed
std.ecdh’s work areai_ecdh_work9,593 wordsone key exchange or signaturebetween them: it is scratch
the transcript, SHA-256 and SHA-384 statesi_transcript356 wordsuntil Finishedafter the handshake
both directions’ prepared AEAD keysi_read_aead258 wordsthe whole connectionat its end
the rest of the integers: state, sequence numbers, fills, flags, ticket numbers34 wordsthe whole connectionat its end

What an established connection must keep, by that table: the keys (1,728 B), the AEAD contexts and the rest of the integers (2,336 B) and, for a server, its ALPN and SNI (512 B), for a client the ticket state (4,400 B): 4,576 B (4.5 KiB) for a server and 8,464 B (8.3 KiB) for a client. The other 258 KiB are for a handshake, or for bytes in flight.

4. Measured

KiB of RSS per connection (§2). The server slot, examples/tls_echo as it was:

arm64 (Docker on this Mac, Linux 6.8): examples/tls_echo, as it was

connectionsconnectedhandshakeestablishedechoedpartialall-echoed
5085.0107.0119.0210.7210.7210.7
10085.1107.2119.2210.8210.8210.8
20085.0107.2119.2210.8210.8210.8
10,00085.2107.4119.4210.9210.9210.9

x86-64 (gram, Linux 7.0): examples/tls_echo, as it was

connectionsconnectedhandshakeestablishedechoedpartialall-echoed
5085.0109.4121.4211.9211.9211.9
10085.1109.6121.6212.0212.0212.0
20085.0109.5121.6212.0212.0212.0
10,00085.2109.8121.8212.1212.1212.1

OpenSSL’s server, default and with SSL_MODE_RELEASE_BUFFERS:

arm64, OpenSSL 3.0.13, default

connectionsconnectedhandshakeestablishedechoedpartialall-echoed
509.854.228.951.151.151.1
1009.252.426.548.648.648.6
2008.951.425.347.447.447.4
10,0008.650.424.246.246.246.2

arm64, OpenSSL 3.0.13, SSL_MODE_RELEASE_BUFFERS

connectionsconnectedhandshakeestablishedechoedpartialall-echoed
509.846.218.819.134.020.9
1009.244.116.216.331.717.2
2008.943.014.915.030.515.4
10,0008.641.913.613.729.413.7

x86-64, OpenSSL 3.5.5, default

connectionsconnectedhandshakeestablishedechoedpartialall-echoed
5021.177.163.773.973.973.9
10018.069.856.166.866.866.8
20017.466.753.264.064.064.0
10,00016.263.249.460.460.460.4

x86-64, OpenSSL 3.5.5, SSL_MODE_RELEASE_BUFFERS

connectionsconnectedhandshakeestablishedechoedpartialall-echoed
5021.167.640.140.455.742.8
10018.062.835.435.651.036.7
20017.158.631.331.447.032.0
10,00016.255.927.827.843.527.8

echoed, partial and all-echoed are one figure for tls_echo because its buffers are per slot and written to their ends by one record; for OpenSSL partial is a connection holding a record (30 to 34 KiB with the buffers released, nothing more without).

The client slot (tests/programs/tls_many.cho, conc connections to an echo server, each having sent and been sent back a 16 KiB request and now waiting for the close; the client’s RSS over its RSS with one connection, arm64): 185.1 KiB a connection at 50 connections, 183.7 at 100 and 184.5 at 200. It cannot be held idle, so this is the echoed figure, and it includes the program’s own 16 KiB a connection.

The engines alone, without a socket or an example (tests/programs/tls_pool.cho of the second commit, built against the engine as it was with open_with_areas and open_server_with_areas replaced by open_with_tickets and open_server, and scripts/tls_memory.py engine): a client slot and a server slot together, KiB of RSS over the start, for the pair.

arm64:

connectionsareasestablishedechoed
5050210.8325.8
100100211.3326.2
200200211.2326.1
10,00010,000211.4326.3

x86-64:

connectionsareasestablishedechoed
5050210.8325.8
100100211.3326.2
200200211.2326.1
10,00010,000211.4326.3

What they say. connected (85 KiB) is start writing every word of the integer slice; with if ints[k] != 0 before the store it is 9 KiB, and the later phases do not move, because the handshake writes those pages anyway (a probe, not a change: connected 9.0, handshake 107.0, established 119.0 on arm64). handshake to established is 12 KiB: the Finished, the first echo. A record more than doubles the figure because the engine’s four buffers and the example’s three are written to their ends by it. A client slot and a server slot are the same layout, so a pair is twice a slot: 105 KiB each, and the 14 KiB more of tls_echo’s server slot is the example’s.

5. What the earlier documents said

claimwherewhat happens
the reassembly buffer “is released into the slot’s pool after Finished”tls-pure.md §7.4there is no pool; compact moves the unread bytes to the front and nothing is freed or cleared. Its resident part is one or two pages, written by the ClientHello, and stays
a slot is “about 179 KiB, or 11.2 MiB for 64”; “181,927 bytes and 158 words”tls-pure.md §7.4, tls-core.md §9.1the slot is 187,191 bytes and 10,241 words, 262.8 KiB: the 75 KiB of std.ecdh’s work arrived with HelloRetryRequest groups and the server’s signing, and the figure was not revisited. 64 slots are 16.4 MiB, 100,000 are 25 GiB of address space
“a server slot is the client slot plus nothing significant”tls-server.md §6true of the layout; a server never uses the client’s 4.4 KiB of ticket state or its 16 KiB leaf buffer, and has them
“Peak RSS of tls_many is 31.1 MiB with 1 connection and with 64”tls-core.md §10.2on this tree the client is 2.1 MiB with one connection, 11.2 with 50 and 38.9 with 200

These are corrected in place by the change that builds this (§10), not by this commit.

6. The ways out

A. Stop start writing zeros (read, and store only a nonzero). Free, and it takes connected from 85 to 9 KiB. It moves no other phase: the signature writes the work area anyway. Done with the build, because it falls out of E; alone it is not the answer.

B. A smaller ceiling for the buffers. The buffers’ sizes are not the resident figure (§1): a page nobody writes costs nothing, and a connection that only sees small records writes one page of each already. A smaller ceiling saves only for connections that send large records, and it changes what the engine accepts: RFC 8446 §5.2 obliges a receiver to take ciphertext of 2^14 + 256 bytes, peers send full records routinely, and max_fragment_length and record_size_limit are not offered (docs/tls-parity.md). Excluded by the gate.

C. Release the handshake buffers into a shared pool after Finished, and take one back for a KeyUpdate or a NewSessionTicket. This is what tls-pure.md §7.4 says is the design. Alone it is the wrong lever: the reassembly buffer is two pages and the work area is 75 KiB. As part of E it is what E does, for every buffer.

D. Share the scratch. One work area for the engine, since a call does not outlive itself: std.ecdh‘s work and the transcripts. The five places that use the work area (new_ecdh_share, the client’s and the server’s ecdh.shared, the server’s sign_checked, the TLS 1.2 client’s ecdh.shared) take a slice of the slot’s integers and would take an argument, through feed and what it calls, in files being edited by four other pieces of work. Estimated, not built: established 119 to about 44 KiB, the buffers’ pages still in every slot.

E. A core and leased work areas (chosen, §7). The slot’s layout is reordered so that what an established connection keeps comes first and the rest after it. The engine keeps a core for every slot and a pool of full-size work areas; a connection holds an area while it is in its handshake or has bytes in a buffer, and not otherwise. The functions of client.cho and server.cho are unchanged: they are handed the area, which is a slot, with the core copied in. Costs: two copies a call (a core is 4.5 or 8.3 KiB; the bytes go by copy_into, a memmove), the reorder of the layout (§7.1), a pool that can run out when it is smaller than the slots (§7.5), and the engine functions that read a connection reading the core. Gains: established 119 to 17 KiB, and a record in flight costs an area for the length of the call, not a slot for good. Prototype, arm64, N = 100: connected 90.7, handshake 111.6, established 17.4, echoed 54.0, partial 79.4, all-echoed 139.4. (handshake and connected are all N at once, so N areas; partial is N areas in use; echoed is traffic one connection at a time, which touches the few areas that are in use.)

F. Two kinds of slot (tls-pure.md §10 question 6: “share a pool across slots”). A connection starts in a handshake slot and moves to an established one at Finished. It is E without the lease for traffic: an established connection would need its record buffers all its life, 66 KiB, and a broker’s 100,000 connections are exactly those. E is F with the buffers leased too.

7. The design

7.1 The layout, precisely (for a rebase)

packages/tls/slot.cho. No size changes: ints_len and bytes_len are the same numbers. Two blocks change order.

Integers. The transcripts and the work area move after everything else:

  • i_offer_len() is 20 (it was i_ecdh_work() + ecdh.work_len()), so the resumption fields, the AEAD contexts and the server’s five words follow the header directly;
  • i_transcript() is ints_core_len() (it was 20); i_transcript384 and i_ecdh_work follow it as before;
  • ints_core_len() is the old ints_len() body, i_alpn_len() + 5: a field added at the end of the integers is added there, and is in the core; ints_len() is i_ecdh_work() + ecdh.work_len().

Bytes. The core moves to the front:

  • b_keys() is 0 (it was b_leaf() + leaf_cap()), the keys and ticket state follow it unchanged;
  • bytes_core_len() is b_ticket_host() + 256 (the old bytes_len()), a client’s core; bytes_core_len_server() is b_offer() + 512: a server’s room is its ALPN and its SNI, and b_sni() is b_offer() + 256 (it was b_ticket_host(), the same 256 bytes, now next to the ALPN);
  • b_in() is bytes_core_len() (it was 0), and b_hs, b_out, b_plain, b_recv and b_leaf follow as before; bytes_len() is b_leaf() + leaf_cap().

Besides: keeps_area(ints), false, is where a state that must stay in the buffers after the handshake says so; client.cho’s drop zeroes b_offer()..bytes_core_len() (it was ..bytes_len(), which is now the buffers); a refusal tls-pool (-72) in record.cho.

Placed on rebase over #394 (server tickets) and #396 (client certificates and ALPN). The rule: state kept after the handshake goes in the core (a word after i_session_len, before ints_core_len(); bytes before bytes_core_len() for a client or bytes_core_len_server() for a server); state a handshake uses and leaves goes in the area, which a release after a handshake clears.

fieldfromwherewhy
i_alpn_offer_len, i_alpn_at#396core (words 292, 293)tls.alpn on a client reads the server’s choice out of the offer after the handshake
i_identity_gen#396core (294)read when a CertificateRequest is answered, which can come late in the handshake; a word, so not worth a rule of its own
i_trust_gen#396core (295)stamped on a ticket when it is saved, after the handshake
i_srv_verdict, i_srv_now_ms, i_srv_not_after, i_srv_drawn#394core (aliases of the client’s ticket words 20 to 28)ticket_verdict is read after the handshake; the others ride along, being aliases
k_sign_hedge (32 B, keys_len 1,760)#396core, in the keysthe keys are one range; it is cleared with them
k_read_hw, k_write_hw (368 B each, were 256; k_sign_hedge moves to 1,952, keys_len 1,984)#392core, in the keyseach direction’s AES round keys and GHASH powers, read on every record after the handshake; cleared with the keys
b_alpn_offer (256 B)#396client core (it ends at b_alpn_offer() + 256, bytes_core_len())the same tls.alpn read
b_ticket_random (at b_ticket(), 288 B), b_srv_fp (at b_ticket() + 512, 32 B)#394server areadrawn at serve and used by issue_tickets when the client’s Finished is answered, in the lease the handshake holds; the area is cleared when it goes back (the client core’s room, bytes_core_len(), which contains them, is cleared after a handshake whatever the role)
the identity store (tls_cident), the ticket keys (tls_ticket)#396, #394engine-wide boxes (ids, tk), not slotscleared by close as on main

Sizes after: the integer core is 296 words (2,368 B); a server’s byte core 2,272 B (the keys and the two names), so 4,640 B (4.5 KiB) a server; a client’s 6,416 B, so 8,784 B (8.6 KiB) a client. (#392, rebased on this: the two prepared AES-GCM keys grow by 112 B each, so the keys are 1,984 B and every core 224 B larger: a server’s byte core 2,496 B, 4,864 B (4.75 KiB) a server; a client’s 6,640 B, 9,008 B (8.8 KiB) a client. The figures elsewhere in this document are from before it.) tests/programs/tls_pool.cho runs with server tickets and resumption (tickets) and with a client identity (mutual, open_mutual_with_areas), and tls.residue is 0 after every step there too. Client certificates on the server’s side (#384, not merged when this was rebased) keep the client’s leaf in b_leaf for peer_certificate: keeps_area is where such a connection keeps its area, at what it costs today. As #384 merged (docs/tls-server.md §13.22): its words (i_peer_generation, i_peer_not_after, i_leaf_len, the flags) are core words; a connection with a verified client keeps its area (keeps_area), which goes back cleared with the leaf; tls_pool.cho clientcert asserts tls.residue 0 after every step. Measured and changed by §11: such a connection was 126.0 KiB idle (17.0 without a client certificate); the leaf now has a room of its own a slot and the area goes back.

7.2 The engine

tls.cho. The engine’s ints and bytes become the areas (areas strides of ints_len and bytes_len) and two boxes are added, cints and cbytes: every slot’s core. meta gains each slot’s lease, a stack of free areas, a flag for the handshake’s scratch and how far the received data reached since the lease. The stack is last in, first out, so the areas that are touched are the few in use at once.

  • Every call that moves bytes (start, start_with, serve, feed, send, eof, finish, and recv and take when there is something to move) first leases: a slot with no area takes the top of the stack and has its core copied in. While it holds the area, the area’s copy is the current core. After the call, settle gives the area back if the connection is idle (established, closed or failed; no partial record, no partial handshake message, nothing to take, nothing to recv; keeps_area false), its core copied out first.
  • The calls that only read (event, failure, alert_received, resumed, suite, group, retried, server_name, alpn, handshakes_in_progress, save_to’s reads) read the current core, the area’s or the slot’s, and take no area. This is why the core is the front of the slot: they are handed a shorter slice with the same offsets. Corrected after review: the first version copied the core out after every call so that reads could always use cints; measured, that copy was most of the engine’s added CPU a request (§10.2). Changed by #447 (§12): an established connection that goes idle may rest in its area instead of giving it back, and the copies are block copies.
  • take on a connection with no area answers 0 (it has nothing queued: that is what idle means). drop clears the area if there is one, and always the core.
  • An area goes back cleared (clear_area, on every release, in drop and in close): its copy of the core (the traffic keys, IVs, the handshake and application secrets, k_res_master, the hardware key schedules, the AEAD contexts), the last record opened and the received data, and, after a handshake, the transcripts and the work area, which hold intermediate values of the key exchange and the signature (since #448 the last record opened only on a TLS 1.2 connection: a TLS 1.3 record is opened into the received data, §12). No free area holds a key or plaintext, as on main drop and the end of a connection left none in a slot. tls.residue(engine), for tests, counts what is left in free areas, free slots’ cores and failed connections’ keys, and tests/programs/tls_pool.cho asserts it is 0 after every step and drop. Corrected in review: the first version cleared the scratch and the opened record only, and only after a handshake; the core’s copy, the received data and every clean release kept theirs, which was a regression. docs/tls-pure.md §7.3: best effort.

7.3 API

Unchanged: open, open_with_tickets, open_server, and every call. Added:

tls.open_with_areas(heap, slots, tickets, areas) -> Engine    // a client engine whose slots share `areas` work areas
tls.open_server_with_areas(heap, slots, areas) -> Engine
tls.areas(engine) -> int                                      // how many
tls.areas_free(engine) -> int                                 // how many are not leased now
tls.holds_area(engine, slot) -> bool                          // in a handshake, or bytes in a buffer
tls.residue(engine) -> int                                    // for tests: what the clearing left (0)

areas is at least 1 and at most slots, and the three existing opens use slots, so with them no call is refused for want of an area and the engine accepts and refuses what it did. With fewer: start, start_with and serve answer tls-pool and start nothing (the slot stays free; the program leaves the connection queued, as it does for a handshake bound). A connection that is established and needs an area for a call gets back-pressure, not a failure: feed and send take nothing (0) and finish answers would_block(), as when the engine’s buffers are full, so the program offers the bytes again; recv and take need no area (an idle connection has nothing waiting: recv answers from the core, would_block(), 0 after close_notify, or the failure); eof fails the connection tls-peer-closed on its core, keys cleared, as peer_eof does (that refusal sends no alert). Corrected in review: the first version failed a connection fed with no area free (tls-pool), and answered tls-pool to recv, which tls_echo took as an end.

7.4 What does not change

client.cho, server.cho, client12.cho, hello.cho, message.cho and record.cho (but for one refusal and one range in drop) are not touched: they see an area, which is a slot. The record layer, the key schedule, the transcript, the signatures and every refusal are the same code on the same bytes. tests/programs/tls_driver.cho, the fuzz harnesses and the lying peers build their own slots from tls_client.ints_len() and bytes_len() and are not affected by the engine’s.

7.5 Sizing the pool, and what it costs a peer to take one

With areas = slots (the default) nothing can be starved and the address space is what it was: 263 KiB a slot, 25 GiB for 100,000 (a calloc the kernel may refuse on a small machine, the other reason to size the pool). The memory is what is touched: the areas in use at the same time. A smaller pool is sized for the connections that can be in a handshake or hold a record at one instant. A program that bounds its handshakes in progress has that bound for the first; for the second, a peer can hold an area by sending half a record and stopping, which in the slot design held that slot’s memory and in the pool design holds one area of the pool. A pool smaller than the connections is a resource a peer can exhaust, with 100,000 connections each stalled mid-record; and --idle does not bound it, since one byte per idle period keeps a connection active. examples/tls_echo and https_hello get --areas (default --connections; at least 2), keep handshakes to half the areas rounded down, so traffic has at least one, and end an established connection that holds an area for longer than --handshake-timeout (record-timeout: half a record and nothing more, or bytes it does not read; measured from the first sweep that sees it, so between one and two timeouts). A connection that finds no area waits (back-pressure) rather than failing. Corrected in review: the first version relied on --idle and allowed --areas 1.

7.6 Expected, and the risk

An established idle connection: its core, 4.5 KiB (server) or 8.3 KiB (client), plus the example’s pages. A record in flight: an area, 263 KiB of address space and about 70 KiB touched, for the length of the call. The risk is a field read from a slice that is the core and that sits in the buffers: a trap, because the slice is shorter. The reorder puts every field a read-only call needs in the first 20 words or before ints_core_len(), and the tests of §8 call each read after every kind of traffic with the areas taken and given back in every order.

8. Gates of the second commit

cargo fmt, cargo clippy, cargo test --workspace; the TLS suites unchanged (the lying server and client, the differential against openssl s_server and s_client, tickets, the interop matrix, the fuzz harnesses, the mutants); new cases for an engine with fewer areas than slots (tls-pool, a parked connection fed, sent to, received from and ended when none is free, data waiting while another connection is served, every order of lease and release, a connection that ends without an area); mutants for the lease, the copies and the idle test; scripts/tls_echo_test.py areas and scripts/https_hello_test.py areas; the measurements of §4 repeated and written here; the corrections of §5 made in place; a comment on #378.

9. Open

  1. The example’s own buffers. pend, inq and app are 48 KiB a slot in tls_echo and https_hello, each written to its end by the first full record. They are the example’s, not the engine’s; leasing them the way the engine does would take an echoed connection from 54 KiB to about 20. It is the same pattern in code that four other pieces of work are editing.
  2. The core of a server is 4.5 KiB because its ticket room is not in it. If #379 needs room after the handshake the core grows by that.
  3. TLS 1.2 (client12.cho) holds the same state in the same places and uses the same engine; it is covered by the traces and by tls_many against OpenSSL’s TLS 1.2 servers, not by a case of its own in the pool test.

10. As built

The second commit of #395 (the first is this document), with the review’s fixes. Changed: packages/tls/slot.cho (the reorder of §7.1, ints_core_len, bytes_core_len, bytes_core_len_server, keeps_area: 39 lines), tls.cho (the areas, their clearing and residue: 916 lines to 1,367 on the first base; 1,245 to 1,721 after the rebase of §10.4), client.cho (one range in drop), record.cho (tls-pool, -72); examples/tls_echo/front.cho, echo.cho, tls_echo.cho, examples/https_hello/hello.cho and loop.cho (--areas, at least 2; a handshake bound of half the areas; record-timeout; --connections up to 1,048,576); the stores. Added: tests/programs/tls_pool.cho and conformance/tls_pool.rs; engine mutants in scripts/tls_mutants.py, one in tls_server_mutants.py; the areas and stall cases of scripts/tls_echo_test.py and areas of scripts/https_hello_test.py; tls_many.cho takes up to 100,000 connections (its events and handles were region slices, and a region holds 64 KiB); the raw output of every run behind these tables, benches/tls_memory/ (its README says which file is which).

10.1 Measured after

These were measured on the first base, main before #394 and #396; §10.4 repeats them after the rebase onto both.

examples/tls_echo, KiB of RSS a connection (§2’s method and machines; before is §4):

arm64, after

connectionsconnectedhandshakeestablishedechoedpartialall-echoed
5086.2107.215.452.277.3136.6
10086.2107.317.854.279.6139.6
20086.2107.217.153.478.9139.2
10,00086.4107.516.752.780.7141.2

x86-64, after

connectionsconnectedhandshakeestablishedechoedpartialall-echoed
5086.1109.415.452.277.2136.6
10086.2109.617.854.279.6139.6
20086.2109.517.153.478.9139.1
10,00086.4109.916.752.780.7141.2
at 10,000 connectionsbefore (arm64 / x86-64)after (arm64 / x86-64)OpenSSL, released / default (arm64)OpenSSL, released / default (x86-64)
connected, nothing sent85.2 / 85.286.4 / 86.48.6 / 8.616.2 / 16.2
all N mid-handshake at once107.4 / 109.8107.5 / 109.941.9 / 50.455.9 / 63.2
established, idle119.4 / 121.816.7 / 16.713.6 / 24.227.8 / 49.4
after one 16 KiB record each210.9 / 212.152.7 / 52.713.7 / 46.227.8 / 60.4
every connection holding half a record210.9 / 212.180.7 / 80.729.4 / 46.243.5 / 60.4
all-echoed, the high-water mark210.9 / 212.1141.2 / 141.213.7 / 46.227.8 / 60.4

What did not improve, or got worse: connected is 1.2 KiB more (each slot’s core, and the area leased at serve), and a handshake in progress is what it was (it needs a whole area); both are bounded by --handshakes, not by the connections. The high-water mark, every connection holding a record at once, is 141 KiB, below the 211 of before but three to ten times OpenSSL’s 14 to 60: each of those connections holds an area, and an area is a whole slot’s buffers, not a buffer sized to the record. echoed (52.7) is the example’s own three 16 KiB buffers a slot, written by one record, plus about 5 KiB of the engine’s (§9, open item 1); OpenSSL’s released buffers make its figure the idle one.

The engines alone, a client slot and a server slot together (before, §4: 211 established, 326 after a record each):

arm64

connectionsareasestablishedechoed
505013.814.9
10010015.315.9
20020014.014.3
10,00010,00012.812.9

x86-64

connectionsareasestablishedechoed
505015.116.2
10010015.516.1
20020014.014.3
10,00010,00012.812.9

On 64 areas, 10,000 connections are the same 12.8 KiB a pair (*_engine_new64.json), and 100,000 connections on 64 areas are 12.8 KiB a pair (arm64, arm_engine_after100k.json): about 1.3 GB for 100,000 client slots and 100,000 server slots, of which a server’s is 4.5 KiB.

examples/https_hello (a keep-alive GET answered, then /big/16384; arm64 / x86-64):

connectionsestablished, beforeafterafter a 16 KiB response, beforeafter
50131.0 / 133.827.4 / 27.8186.8 / 189.568.2 / 68.6
100131.2 / 133.729.7 / 29.9186.7 / 189.270.1 / 70.2
200131.2 / 133.629.1 / 29.2186.6 / 189.069.3 / 69.4

https_hello stops serving at 1,024 connections, before and after this change (packages/http-server’s max_connections()), so it was measured to 200; the 10,000 of §2 are tls_echo‘s and the engines’.

10.2 What it costs

Memory: a core of 4.5 KiB (server) or 8.3 KiB (client) a slot for every slot, and an area (263 KiB of address space) for every slot unless areas is smaller: by default the address space is what it was. CPU: a connection that has no area and needs one copies its core in (4.6 or 8.5 KiB and 292 words), and copies it out and clears the area (the core’s copy, the received data as far as it reached and the opened record as far as that and at least 3 KiB) when it gives the area back: twice a request for a keep-alive HTTPS server. Measured on arm64 (https_hello over tload, 32 keep-alive connections, GET /hello/42, server CPU from /proc, Docker VM; three or more alternating runs each, the same session): 6.7 to 7.0 microseconds of server CPU a request before, 7.9 to 8.1 after, about 1.1 microseconds or 16% more. The first version, which copied the core out after every call, cost 9.4 to 10.9; the clearing the review asked for measured within noise of no clearing (8.8 to 9.0 against 8.9 to 9.2 on the first version). A handshake: 3.0 ms before and after in two of the alternating runs and 4.8 ms in one before-run that something else disturbed, so no change in a handshake’s cost is claimed, only that none showed. No timing is taken from gram.

10.3 Evidence

cargo fmt, clippy and cargo test --workspace on CI’s linux-x86_64 and darwin-aarch64; locally 624 of 625 on darwin-aarch64 (the failure spawn_and_join_run_concurrently_not_sequentially, a timing test, on a loaded machine; it passes on CI). tls-assurance on CI: the interop matrix, both differentials, AFL over every harness, tls_echo_test.py (now 13 cases: areas and stall new) and https_hello_test.py (20: areas new). tests/programs/tls_pool.cho through every order of lease and release, on both backends and both operating systems, with tls.residue 0 after every step, release and drop. Mutants: scripts/tls_mutants.py 128 of 128 killed (25 of them on the work areas: the copies, the lease, the idle test, the clearing, the reads of a core an area holds), tls_server_mutants.py 72 of 72; no mutant is argued equivalent. Not covered: save_to while an area is held (it needs a server that sends a ticket: #379’s).

10.4 After the rebase onto #394 and #396

main with server tickets (#394) and client certificates and ALPN (#396), and this change on top (§7.1 places their fields). Same method; tickets off (tls_echo’s default); raw output benches/tls_memory/*/after-rebased.log and *_rebased.json.

arm64, tls_echo

connectionsconnectedhandshakeestablishedechoedpartialall-echoed
5086.4108.616.753.579.5138.9
10086.3108.118.454.882.8142.8
20086.3107.917.553.881.8142.1
10,00086.5107.816.852.881.0141.6

x86-64, tls_echo

connectionsconnectedhandshakeestablishedechoedpartialall-echoed
5086.3109.915.452.278.2137.6
10086.3109.917.754.182.1142.0
20086.3109.917.153.481.5141.8
10,00086.5110.216.852.881.0141.6

The engines alone, a client slot and a server slot together, arm64 / x86-64:

connectionsareasestablishedechoed
505014.215.3
10010015.616.2
20020014.414.6
10,00010,00013.213.2
connectionsareasestablishedechoed
505015.516.6
10010015.616.2
20020014.514.8
10,00010,00013.213.2

At 10,000 connections an established idle connection is 16.8 KiB (16.7 before the rebase), after a record 52.8, every connection holding half a record 81.0, the high-water mark 141.6; connected 86.5 and mid-handshake 107.8 / 110.2; a client and a server slot together 13.2 KiB (12.8 before the rebase: #396’s 320 bytes of client core and 4 words). https_hello idle 29.0 to 30.4 KiB (arm64) and 27.2 to 29.8 (x86-64) at 50 to 200.

11. Client certificates: the verified leaf outside the work area

Status: measured, decided, built (this section’s PR). The first commit is §11.1 to §11.5: the measurement of what PR #398 (mutual TLS, docs/tls-server.md §13) costs an idle connection, and the design; §11.6 is what the second built and measured. Raw output: benches/tls_memory/{arm64,x86-64}/mtls-*.log and *_mtls_*.json (benches/tls_memory/README.md).

11.1 The question

#398 kept the verified client’s leaf certificate where the handshake had put it, b_leaf, which since #395 is in the work area (§7.1), and so made a connection whose client presented a verified certificate keep its area for its whole life (tls_slot.keeps_area). Its author did not measure that, and named two ways out: (a) a small leaf in the core, (b) only the identity fields the program reads in an engine-wide table. The rule set for this section: if a verified idle connection costs more than about twice an unverified one, build one of them; otherwise write the cost down and close the question.

11.2 How it was measured

§2’s script and phases, with one change: scripts/tls_memory.py --client <dir> makes every client present <dir>/chain.pem with its key, and starts the server requiring a certificate from <dir>/ca.pem (tls_echo --client-ca ... --require-client-cert; for OpenSSL, benches/server/ossl_hold.c ... verify <ca.pem>, which sets SSL_VERIFY_PEER | SSL_VERIFY_FAIL_IF_NO_PEER_CERT, what openssl s_server -Verify sets: s_server itself serves one connection at a time and cannot hold N). The certificates are benches/tls_memory/clientauth/ (make.sh made them, with OpenSSL): p256/, a device’s certificate, ECDSA P-256 under a P-256 CA, a subject of three attributes, a URI and a DNS subjectAltName, keyUsage, extendedKeyUsage clientAuth and both key identifiers, 572 bytes of DER; rsa/, an RSA-2048 leaf (970 bytes) under an RSA-2048 intermediate the client sends, under an RSA-2048 root. N is 50, 100, 200 and 10,000; the machines are §2’s (arm64: Docker on the Mac, Linux 6.8, OpenSSL 3.0.13; x86-64: gram, Linux 7.0, OpenSSL 3.5.5, cores 6 and 7 only, other work on its other cores). RSS only; nothing here is a timing. --probe <s> handshakes connections one at a time until one is not established within s seconds, for --areas.

11.3 Measured: what a verified connection cost

KiB of server RSS a connection (§2), examples/tls_echo from origin/main at 0e40f430 (#398 merged).

arm64

Nwithout client certificates: establishedechoedP-256 client: establishedechoedRSA-2048 chain: establishedechoed
5018.255.0127.8214.9132.2216.8
10019.455.8127.0213.8131.2215.5
20018.154.3126.3213.1130.4214.8
10,00017.053.0126.0212.7130.0214.3

x86-64

Nwithout client certificates: establishedechoedP-256 client: establishedechoedRSA-2048 chain: establishedechoed
5015.752.5127.1213.4129.5214.2
10018.154.5127.4213.3129.8214.1
20017.754.0127.3213.3129.7214.1
10,00017.053.0127.7213.6130.0214.3

connected and handshake are the same with and without (86.5 to 86.7 and 108.0 to 110.6 KiB: a handshake holds an area either way), and partial and all-echoed of a verified connection are its echoed (its area is held whatever it does).

OpenSSL’s server on the same machines, idle established connections at 10,000 (the other counts in the logs):

arm64, defaultarm64, released buffersx86-64, defaultx86-64, released buffers
no client certificate24.213.749.427.8
P-256 client, verified31.519.861.033.9
RSA-2048 chain, verified36.166.9

A verified idle connection was 126.0 KiB (arm64) and 127.7 KiB (x86-64) at 10,000 connections, against 17.0 without client certificates: 7.4 and 7.5 times, 109 and 111 KiB more. The RSA-2048 chain is 130.0 on both. The threshold was twice. OpenSSL keeps the peer’s certificates parsed in its session for as long as the connection lives and pays 6 to 12 KiB for the P-256 one (19.8 against 13.7 and 33.9 against 27.8 with its buffers released, 31.5 against 24.2 and 61.0 against 49.4 without), 12 to 18 for the RSA leaf and its intermediate (36.1 and 66.9 against 24.2 and 49.4); tls_echo paid 109 because what kept the leaf was the area, and an area is the handshake’s whole scratch (§3): the ECDH work area alone is 75 KiB, written by every handshake. After a record each, a verified connection is 213 KiB, four times an unverified one’s 53, because the record buffers it wrote stay with the area.

--areas. With --areas 64 and 200 clients, each presenting the P-256 certificate (--probe 5): 64 of 200 established, the 65th not within 5 s, because 64 verified connections hold the 64 areas (127.8 KiB each on arm64, 127.5 on x86-64); without client certificates 200 of 200 (18.0 and 17.9 KiB each). And --areas ended every verified connection: tls_echo ends an established connection that holds an area for longer than --handshake-timeout (record-timeout, §7.5), and a verified one always held one. A client that presented a P-256 certificate to tls_echo --areas 4 --handshake-timeout 1000 --client-ca ... --require-client-cert, echoed a byte and waited 6 s was closed record-timeout after 1.3 s (scripts/tls_echo_test.py ... clientauth-areas fails on 0e40f430).

11.4 What the program reads, and the decision

The calls (docs/tls-server.md §13.6), and what each needs after the handshake:

callneedsread by
peer_verifiedthe generation, the identity flags, the state: core words alreadytls_echo on every SIGHUP (stale), https_hello
peer_not_afteri_peer_not_after, a core word alreadytls_serve, the driver
peer_fingerprintSHA-256 of the leaf’s DER, computed at the calltls_echo and https_hello (the log line), tls_serve
peer_subject, peer_serialranges of the leaf’s DER, parsed at the calltls_echo (the log line), tls_serve
peer_certificatethe leaf’s DER, wholetls_serve, the driver
peer_santhe leaf’s DER: an entry is a range in peer_certificate’s DERtls_serve, the driver

(b) as proposed, the identity fields alone, cannot keep the API. peer_certificate gives the DER and peer_san answers offsets into it; a table of fields would have to keep the DER anyway, or change two calls. So what is kept is the leaf’s DER, and everything the calls compute from it is computed as before, from the same bytes. Where it is kept is (b)’s, not (a)’s. The core is copied into an area and out again on every lease (§7.2), twice a request on a keep-alive connection: a leaf of up to 2 KiB in the core would put 2 KiB more in each of those copies for every server connection, verified or not, and 2 KiB more resident in every server slot that has ever served one (the copy writes the core whole). A room of its own a slot, beside the cores, is written once when the client’s Finished has verified, read by the peer_ calls only, and touched only for a connection that was verified.

Decided: build it. A server engine gets peers, tls_slot.peer_cap() = 2,048 bytes a slot; when a connection is established as a verified client and its leaf fits, the leaf is copied there and the area can go back; a leaf over 2,048 bytes keeps the area as before. 2,048 holds the measured P-256 device leaf (572) and RSA-2048 leaf (970) with room for more names; a bigger room is address space every server slot has (2 GiB at 1,048,576 slots, tls_echo’s ceiling; the cores are 4.75 KiB a slot), and resident only where written.

11.5 The design, and its hazards

  • The copy. In tls.cho’s settle, after every call on a server connection that holds an area, tls_clientauth.keep(area, room): if the connection is established as verified (tls_clientauth.verified: f_client_auth and not failed) and the leaf is not yet kept and is at most 2,048 bytes, the i_leaf_len bytes at b_leaf are copied to the slot’s room and the core flag f_leaf_kept is set. keeps_area becomes f_client_auth && !failed && !f_leaf_kept, so the same settle gives the area back (cleared, b_leaf included, as before).
  • Exactly what was verified. b_leaf is written once, by on_certificate, with the bytes verify_chain verified, and CertificateVerify’s signature is checked against them; nothing writes it afterwards. The copy is made in the call whose Finished set f_client_auth (established), from the same area, before the area can go back. The length is the core word i_leaf_len; the room holds nothing past it.
  • No aliasing. The room is indexed by slot, a slot has one connection at a time (serve refuses a busy slot), and the room is emptied whenever the connection that owns it stops (below). Nothing in it is shared between slots and nothing is looked up by content.
  • Reads. The peer_ calls read the slot’s current core (the area’s copy while it holds one, §7.2): when it says f_leaf_kept the leaf is the room’s i_leaf_len bytes, gated as before by verified; otherwise, for a larger leaf, the area’s b_leaf, as before. peer_verified and peer_not_after read core words, so they answer from the core of a connection without an area (before, peer_verified answered 0 for a connection without one, which a verified connection never was). A verified connection that takes an area again for a record reads the room: the area’s b_leaf was cleared when it went back.
  • Emptied (tls_clientauth.forget: the i_leaf_len bytes zeroed and the flag cleared): when the connection fails (in the settle of the call that failed it, or in fail_parked for a socket’s end with no area free), when the slot is dropped (drop, before the core is cleared, whether it holds an area or not), and so by close, which drops every slot. A closed connection (close_notify) keeps its identity until it is dropped, as §13.6 says it does.
  • tls.residue counts, for a server engine, every byte of a slot’s room that is not the leaf of a busy, non-failed connection with f_leaf_kept: a free slot’s room must be zeros, a failed connection’s too, and a kept leaf’s room must be zeros past i_leaf_len (so a slot’s next, shorter leaf cannot sit on the tail of the last one).
  • The store replaced at Finished (tls-server-client-store-replaced, §13.22): established refuses before f_client_auth is set, so nothing is copied; the connection fails as before. Resumption (§13.8): a resumed connection presents no certificate and has no identity, so nothing is copied.
  • The API is unchanged: the same calls, the same answers, the same refusals; the recordings of the lying client and the client certificates replay byte for byte (§11.6).

What it costs: 2,048 bytes of address space a server slot (n * 2,048, allocated by open_server* only; a client engine’s is empty), written only for a verified connection; for each, i_leaf_len bytes, which on 4 KiB pages two neighbouring slots share, so about 2 KiB resident a verified connection when every slot holds one (arithmetic; §11.6 measures it). One copy of the leaf a connection (572 bytes for the P-256 device). No copy on a lease.

11.6 As built, and measured after

Changed. packages/tls/slot.cho: peer_cap() (2,048), the flag f_leaf_kept (524,288, free until now), and keeps_area false once the leaf is kept. clientauth.cho: keep, kept, forget, and view_of and fingerprint_of (the old leaf_view and fingerprint call them). tls.cho: the box peers (server engines only), the copy in settle, forget in fail_parked and drop, residue over the rooms, core_now and peer_der, and the peer_ calls reading through them (1,914 lines to 1,944). No slot layout moved: a slot is the same 10,245 words and 187,703 bytes, a server core the same 4,864 bytes. examples/ are unchanged: they ask the same calls. The stores are republished (scripts/publish_packages.py).

Measured after, the same runs (mtls-after.log), KiB of server RSS a connection:

Narm64: noneP-256 beforeP-256 afterRSA beforeRSA afterx86-64: noneP-256 beforeP-256 afterRSA beforeRSA after
5018.3127.818.4132.218.615.7127.115.5129.515.8
10019.4127.021.3131.221.518.0127.419.9129.821.0
20018.1126.320.1130.420.117.4127.319.4129.719.4
10,00017.0126.019.0130.019.017.0127.719.0130.019.0

(established and idle; “none” is the run without client certificates after the change, the same as before it: 17.0 at 10,000 on both.) A verified idle connection is 19.0 KiB at 10,000 connections on both machines, from 126.0 and 127.7: 2.0 KiB more than one without a client certificate, the room’s page shared by two slots, as §11.5 reckoned; the RSA chain’s 970-byte leaf costs the same. OpenSSL, the same certificate, the same machines: 31.5 and 61.0 by default, 19.8 and 33.9 with its buffers released. After a 16 KiB record each: 55.0 on both (from 212.7 and 213.6; 53.0 without a client certificate). The other phases are unchanged (connected 86.7, a handshake 108.1 and 110.5: an area either way).

--areas. With --areas 64 and 200 clients presenting the P-256 certificate, 200 of 200 established (20.0 KiB each on arm64, 19.4 on x86-64; before, 64 of 200). The phases with --areas 64 (*_p256_areas64.json, established ones only: the handshakes are bounded at 32), at 10,000 verified connections: established 19.0, echoed 55.0, every connection holding half a record 55.2, all echoed 55.6 on both machines (with an area for every slot they are 83.5 and 144.9): what a record in flight costs is now bounded by the pool, verified or not. tls_echo --areas no longer ends verified connections: scripts/tls_echo_test.py ... clientauth-areas, four verified connections on two areas idle past --handshake-timeout and still echoing, passes here and fails on 0e40f430.

Tests. tests/programs/tls_pool.cho clientcert (conformance/tls_pool.rs, both backends; (slots, areas) of (4, 4), (5, 2) and (1, 1)): every verified connection gives its area back after its handshake and every peer_ call answers the leaf byte for byte (the DER decoded from the client’s own PEM, its SHA-256, the subject, the serial, notAfter, the subjectAltNames counted inside the DER, the generation); traffic on random slots in random pieces, its first record fed half so the connection holds an area again while the identity is read, and released after; a verified connection that fails (no identity before or after its alert is taken, its room emptied); a slot’s next connection with a shorter leaf after a longer one dropped while it held an area (nothing of it left: residue counts a room’s bytes past the leaf); leaves larger than the room (pool_big_chain.pem, 4,318 bytes, 80 SANs) keep their areas and are read from them, and with fewer areas than slots hold them all, a serve then tls-pool; a socket’s end on a verified connection with no area free (the core alone fails, fail_parked) and with one; the store replaced before the client’s Finished. tls.residue is 0 after every step. The new vectors: tests/vectors/tls/client_auth/pool_ca2.pem, pool_chain2.pem (418 bytes, two SANs) and pool_big_chain.pem, for pool_key.pem‘s key. The recordings (liar_client.txt, liar_client_auth.txt) replay unchanged byte for byte (conformance/tls_server.rs, tls_client_auth.rs): nothing a peer sees changed. The examples’ live tests: tls_echo_test.py 19 of 19 (clientauth-areas new), https_hello_test.py 22 of 22 (arm64, Docker).

Mutants. scripts/tls_server_mutants.py: 16 new on the moved code (the copy skipped, short, before the client is verified, into the next slot’s room; the area kept anyway, given back for a large leaf; the room not emptied on a failure, a parked failure, a drop with and without an area, or emptied without zeroing; the area’s b_leaf read for a kept leaf, the room read for a large one; the slot’s core read while an area holds the current one; the engine never keeping a leaf), one of #398’s rewritten for the new keeps_area; the counts are in tests/vectors/tls/client_auth/mutants.txt. One is argued equivalent: the room read whether or not the connection is verified (a leaf is kept only for a verified connection and given up in the call that fails it, so kept’s own test is a second line). A first version argued “the copy before the client is verified” equivalent too, and the run killed it: after a handshake failed at its Finished, the next take would have copied the leaf again into the failed connection’s room, which residue saw; the argument was wrong, the guard is not redundant. scripts/tls_mutants.py (the engine’s): 181 of 183 killed, 2 equivalent, as before.

11.7 Not done, and not verified

  • A leaf over 2,048 bytes still keeps its area, and tls_echo --areas ends such a connection record-timeout (the clientauth-areas case asserts it): the example cannot tell an area held for a leaf from one held by half a record. The fleets this was for present device certificates of about 400 to 600 bytes; a program with larger client leaves runs without --areas, or the room grows (peer_cap, address space every server slot has).
  • https_hello’s memory with client certificates was not measured; it shares tls_echo’s engine calls and front end.
  • The room’s address space, 2,048 bytes a server slot, is allocated whether or not client authentication is on (2 GiB at tls_echo’s ceiling of 1,048,576 connections, resident only where written).
  • Timings: none taken. The copy is one pass over the leaf’s bytes a connection, in the call that verified its Finished; a lease copies nothing more than before.

12. Resting, and the copies in bulk (#447, #448)

Status: built, measured (record-in-place.md, which has the design, the invariants and the raw output; this section is what changed in the layout and the engine, for a rebase, and what it costs in memory).

What it found. §10.2’s cost (about 16% a request on arm64; 24% on x86-64 by tls-performance.md §10.5) was the copy of the core’s 296 words one at a time through the engine’s boxes, not the bytes. They are block copies now (tls_slot.copy_words, zero_words), and an established connection that goes idle rests in its area: it keeps it, its core in it, until an area is wanted and none is free, or more connections rest than tls.set_resting allows (32 by default), the longest-resting first. A handshake takes a resting connection’s area before a free one, so handshakes keep writing the same one or two areas. A kept https_hello request is 21.5% fewer cycles on x86-64 (0.94 times OpenSSL’s oserv).

What changed in the engine, precisely.

  • meta is 11 + 8 * slots + areas words (it was 7 + 5 * slots + areas): after the reach block, at 7 + 5 * slots + areas, how many may rest, how many do, the first and the last to rest (slot plus one), then for each slot the next and the one before it in that order (plus one), then 1 for each slot that rests.
  • holds_area is false for a resting connection; areas_free counts resting areas; tls.areas_resting(engine) is new, and so is tls.set_resting(engine, k) (0 to the number of areas; 0 is §7’s engine exactly).
  • take and recv on a resting connection answer from its core in the area with no settle.
  • tls.residue also counts what a resting area holds past its connection’s core (§7.2’s promise: nothing).
  • The release’s slice-level work moved from tls.cho to slot.cho (idle, clear_area, clear_plain, clear_leaf, plain_left, nonzero, nonzero_words), unchanged but for b_plain, which is cleared only on a TLS 1.2 connection.

What did not change: the slot’s layout (every offset, ints_len, bytes_len, ints_core_len, the cores), the API and everything it answers, and what a free area holds.

Memory (scripts/tls_memory.py, tls_echo, x86-64, KiB a connection, before / after):

Nestablishedechoedpartialall-echoed
5017.5 / 15.554.6 / 97.480.7 / 106.9140.0 / 121.9
10019.1 / 17.955.6 / 76.983.8 / 96.6143.7 / 126.3
20017.9 / 17.654.2 / 65.182.5 / 89.2142.7 / 126.2
10,00017.0 / 17.053.0 / 53.381.5 / 81.6142.0 / 125.8

The bound: at most 33 areas’ written pages more per engine (the 32 that may rest and one), measured as 2.1 MiB after a 16 KiB record on every connection whatever N is; an idle connection that never carried a record is what it was. The high-water mark fell 16 KiB a connection: a TLS 1.3 record no longer writes b_plain. https_hello after a 16 KiB answer each: 1.1 to 1.2 MiB more for the engine at 50 to 200 connections.

13. The key tables (#380): an engine’s, not a slot’s

docs/p256-fast.md §11 keeps a table of 10,305 bytes for each P-256 key that verifies many signatures: one in each server identity’s stride of the configuration (docs/tls-server.md §3.5), and one after the trust store for each of a client’s P-256 roots, at most 16 (docs/x509-verify.md §11). No slot, core or work area changes size: tls_slot is 10,245 words and 187,703 bytes as before (tests/vectors/tls/liar_client_auth.txt pins them), and the server’s configuration, the engine’s ids box, grows from 314,106 to 478,986 bytes of address space, zero-filled, resident only for the identities loaded.

Measured on gram (x86-64, Linux 7.0, cores 2 and 3; raw output in assets/key-tables/), this branch against origin/main at 3d7e8e9d, alternating:

  • Per connection, nothing. scripts/tls_memory.py cancho tls_echo --counts 50,200,1000, twice each: at 1,000 connections the phases are 86.7 (connected), 110.4 (handshake), 17.1 (established), 53.1 to 53.2 (echoed), 81.5 (partial) and 142.0 to 142.1 KiB (all echoed) a connection on both; at 50 and 200 the two builds differ by at most 2.2 KiB, as runs of one build do. tls_memory.py engine tls_pool --counts 50,200 likewise: 15.3 to 15.9 KiB a pair of slots at 200 on both.
  • Per engine, the tables. tests/programs/key_tables_rss.cho reads VmRSS around the calls in one process, twice for each build: after a server engine has added the test identity and a client engine has trusted the test CA and then the system bundle (124 roots on gram, 3 of them P-256), the process holds 2,536 and 2,540 KiB against 2,492 and 2,492: +44 to 48 KiB, of which the tables are 41 KB (one identity, three anchors) and the rest the larger binary (the generator’s runs in p256_comb.odd, 15 KB more, and the new code). The steps, after and before: trusting the test CA +16 to 20 and +8 KiB; trusting the system bundle after it +332 and +308 KiB (its 3 tables, 31 KB, in the difference). (assets/key-tables/rss-x86-64.txt; an earlier pass, not kept, gave 2,540 against 2,484 and 2,488.)

Region chunks kept per thread (A1, #375)

Status: built, both backends (§8). Lever A1 of tls-performance.md §10.8 and the per-thread chunk cache #375 asks for, one mechanism. A region was a malloc of 64 KiB on the way in and a free on the way out (linearity-and-effects.md §6), and glibc gave the top of its heap back to the kernel and took it again for nearly every one: 112 brk calls a full TLS server handshake and 136 a resumed one (§1). The design: each thread keeps the chunks its regions give back, at most 16 (1 MiB), in a last-in-first-out stack; a region takes the last one given back; the bytes a region used are cleared when it ends, before the chunk is kept or freed; a thread’s chunks are freed when it ends. Same chunk size, same trap, same programs; both backends, the same runtime. As built (§8): 9 brk calls in a whole server run instead of 112 a handshake; the server’s full handshake 34.6% cheaper on x86-64 (1.86 times OpenSSL’s s_server in cycles, from 2.87), a resumed one 55.8% (1.04 times OpenSSL’s resumed: T5 as restated, met for the server), the client’s 40.5% (its resumed handshake 1.21 times OpenSSL’s: T5 not met for the client, by 0.01); on macOS 16 threads each opening 300,000 regions take 0.04 s where they took 8.8.

Related: tls-performance.md §3.5, §7 Q3, §10.3, §10.4, §10.8; threads.md §7 (the macOS half, #375); linearity-and-effects.md §6; zeroed-slices.md §1; chacha20.md §3.2 and hkdf.md §3 (clearing secrets, best effort); tls-memory.md.


1. Where the brk calls come from today

Measured on gram (i7-1260P, Linux 7.0.0-38, glibc 2.43; the machine of tls-performance.md §10.1, load average 0.1 to 0.2, CPUs 0 to 3) at 2cb860b3 (origin/main), examples/tls_echo and tests/programs/tls_many.cho built with the LLVM backend as CI builds them. System calls are strace -f -c -e trace=brk,mmap,munmap,madvise over the whole run; chunks are an LD_PRELOAD shim counting malloc(65536) and the free of each, and the most live on one thread at once.

handshakesbrkmmap / munmap64 KiB mallocsmost chunks live at once
server, full (openssl s_time -new, 5 s)3,001336,165: 112.0 a handshake17 / 10 in all227 a handshake10
server, resumed (tconn ... resume, 5 s)3,180432,665: 136.1 a handshake17 / 10 in all275 a handshake10
client, tls_many 200 connections20039,022 (195 a handshake)16 / 9 in all371 a handshake15

The path. region_stmt in both backends (cancho-codegen-llvm/src/body/memory.rs, cancho-codegen/src/body/memory.rs) calls malloc(ARENA_CHUNK) and, at the end of the block or on a return out of it, free. ARENA_CHUNK is 64 KiB, below glibc’s M_MMAP_THRESHOLD (128 KiB), so the chunk comes from the brk heap, and in a program that is not allocating much else it sits at the top of it. Its free merges it into the top chunk; when the top passes M_TRIM_THRESHOLD (128 KiB by default) free calls systrim, which gives the excess back with brk, and the next region’s malloc finds the top too small and grows it with brk again, faulting the pages back in. The profile says so: of the 31.6% that memory is of a full server handshake, 26.4 points are inside brk and 20.2 under systrim (tls-performance.md §10.4). mmap and munmap are not on the path at all (17 and 10 calls in a whole run: start-up and the libraries).

What it costs. The experiment of tls-performance.md §10.3 (GLIBC_TUNABLES=glibc.malloc.trim_threshold=33554432: glibc.malloc.top_pad=1048576, alternating with the default, OpenSSL’s rows unmoved) took the x86-64 server’s full handshake from 2.834M to 1.860M cycles (−34.4%), a resumed one from 2.004M to 0.904M (−54.9%), the client’s from 4.513M to 2.694M (−40.3%); arm64 −27.2%, −49.5%, −32.0%.

On macOS it is a different cost with the same cause. libmalloc serves a request above 32 KiB from the kernel’s VM allocator, not from a per-thread magazine (threads.md §7). Measured on this M4 Max today (scripts/region_scaling.c, mode 1, 100,000 pairs a thread, load average 2 to 3): 0.07 s on one thread (0.7 µs a malloc/free pair), 1.24 s on eight (sys 6.6 s), and 0.00 s at 32,768 bytes. Every region pays it, and threads queue on it.

Nesting is why one chunk is not enough. The key schedule opens regions inside regions (hmac.init inside hkdf.expand inside hkdf.expand_label, tls-performance.md §10.4); the server holds 10 chunks at its deepest and the client 15. A cache that keeps fewer than the depth hands the excess back to free every time the stack unwinds, and takes it from malloc again on the way down.

2. The options

(a) A per-thread free list of chunks, kept by the runtime the compiler emits. region takes a chunk from the list, or mallocs one if it is empty; the end of the region puts it back, or frees it if the list is full. Bounded by a number of chunks, so by memory. Portable: it asks libc for nothing but malloc, free and a thread-specific value, which glibc, musl and Darwin all have. It removes the allocator from the path entirely, not only the kernel: a region costs a few loads and stores.

(b) Tell the allocator not to trim. mallopt(M_TRIM_THRESHOLD, ...) and M_TOP_PAD in the emitted main, or a malloc_trim policy. Its gain is measured: it is §1’s experiment (−34 / −55 / −40% on x86-64). Against it:

  • glibc only. musl’s mallopt does nothing, and Darwin has none; the compiler targets macOS (threads.md §7’s cliff is libmalloc’s large-allocation path, which no mallopt reaches) and WASI.
  • It changes the policy for every allocation the program makes, box_slice, Box, std.buffer, not only the arenas: a service that frees a large heap buffer would keep it resident too. The cache keeps only chunks, and only so many.
  • It still pays malloc and free (glibc’s _int_malloc and _int_free for a 64 KiB request, which skips the per-thread tcache above 1,032 bytes); the cache pays neither on a hit.
  • A program cannot do it itself: mallopt needs Ffi, and the TLS stack is built to need none (tls-performance.md §4.2).

(c) A per-thread region stack that reuses the last chunks first. A thread’s regions are strictly nested: a region is a block, return leaves the innermost first, and there is no break (linearity-and-effects.md §5.2: the outlives relation is a stack). So the region opened at depth d is opened when exactly the regions at depths 0 to d − 1 are open, and the chunk given back last is the one a region at that depth used last time. A list used last-in-first-out is this stack, with no depth counter to keep: the two options are one design when (a) pops what it pushed last. LIFO also keeps the warm chunk (its pages resident, its lines likely cached) for the next region.

Chosen: (a) used as (c). A stack of at most REGION_CACHE_SLOTS = 16 chunks per thread. 16 because the deepest measured nesting is 10 (the server) and 15 (the client, its test program’s own region included), and a stack shallower than the depth puts the excess back on the allocator’s path every time it unwinds (§1). 16 chunks are 1 MiB of address space a thread at most; what is resident is the pages the regions touched (§4).

What happens at the bound. A chunk given back to a full stack is cleared (§3) and freed, as every chunk is today. A recursion that opens a region at each of 1,000 levels holds 1,000 chunks while it is deep, as today, and keeps 16 when it returns. Nothing traps or waits.

3. The runtime

Four functions, emitted into the program by each backend when a function in it opens a region, and nowhere else:

what it does
cancho_region_init()called first in main, before any cancho code: pthread_key_create(&key, cancho_region_drop). If it fails (PTHREAD_KEYS_MAX), the key stays unset and every region behaves as today: malloc in, free out
cancho_region_take() -> ptrthe thread’s stack (pthread_getspecific); if it has a chunk, pop it; otherwise malloc(ARENA_CHUNK). Null still traps at the call site, as today
cancho_region_give(base, end)clear [base, end), the bytes the bump pointer handed out; then push the chunk on the thread’s stack, making the stack on the thread’s first release (malloc of 8 + 8 × 16 bytes, pthread_setspecific), or free it if the stack is full or cannot be made
cancho_region_drop(stack)the key’s destructor, run by the thread library when a thread that has a stack ends: free every chunk, then the stack

region_stmt calls take where it called malloc; the end of a region, and every return out of one (innermost first), calls give with the arena’s base and its bump pointer where it called free. The chunk size, the bump pointer, the bounds check and the trap on exhaustion do not change, so allocating_past_an_arenas_chunk_traps_with_sigill and every program’s behaviour are the same.

3.1 Secrets: what a reused chunk shows

The language has no uninitialised memory (zeroed-slices.md §1), and an arena is no exception. Both ways to take bytes from one write them before handing them out: alloc_slice[a](n, fill) is Expr::AllocSlice, whose fill is not optional (cancho-ir/src/ir.rs), lowered as a bump and then a loop that writes fill into every element (fill_slice in both backends); alloc[a](v) is Expr::Alloc, a bump and then a store of v’s leaves (store_leaves). No other expression bumps. So cancho code cannot read a reused chunk’s old bytes: there is no read before the write. A slice handed to C (&r [byte] across extern fn) shows C its own bytes, which the program filled; what C reads beyond them is Ffi’s unbounded authority, and today’s malloc already hands back an untrimmed chunk’s old contents in the same way.

But reuse lengthens how long the old bytes live, so the cache clears them. Today a chunk at the top of the heap is mostly given back to the kernel when its region ends (that is the 112 brk calls), and a page given back is gone; with the stack it would stay in the process, holding whatever the region left in it, until the next region at that depth writes over the part it uses. The best-effort clearing the TLS stack does (chacha20.md §3.2, hkdf.md §3: the Poly1305 key, HMAC’s pads, the last T(i)) clears named buffers, not the hash states and schedules beside them. So give clears [base, end), every byte the region handed out, before the chunk is kept or freed. Three properties follow:

  • It is stronger than today, not as strong: every region’s bytes are cleared when it ends, on both backends, whether or not the program cleared them; what chacha20.md §3.2 calls impossible to promise (“a region’s memory is freed without being cleared”) becomes true of every region the program leaves normally or by return. A trap ends the process.
  • The optimiser cannot delete it, which it could do to a program’s own clearing before a free (chacha20.md §3.2): the cleared chunk is then stored into the thread’s stack, where any later take reads it, so the clearing is not a dead store. §8 checks the object code.
  • It costs at most what the region’s fill cost, since the fill already wrote every byte cleared: the clearing is one memset over the same range. §8 measures it in the handshake.

Not claimed: the chunk’s bytes on the path where a thread’s stack is full and the chunk is freed are cleared first too, but whatever glibc then does with the freed memory is glibc’s; and memory outside regions (box_slice, Box, the stack) is not touched by this.

3.2 Threads

  • One stack a thread, found through a pthread key, so no lock and nothing shared: pthread_getspecific reads the calling thread’s own slot (glibc: an array in the thread descriptor; Darwin: the TSD area). A spawned thread starts with no stack and makes its own on its first release.
  • The key is made in main before any cancho code runs, so before any spawn (threads.md §2); every thread that runs cancho code after that sees it, whoever created the thread.
  • When a thread ends the thread library runs the key’s destructor on its stack (cancho_region_drop): its chunks and the stack are freed. This holds for any thread pthread made, spawn’s or not. main’s thread is not one of these: when main returns the process exits and the memory goes with it.
  • Native thread-local storage (thread_local in LLVM, tls_value in Cranelift) was the other way. It would save a call a region, but needs a thread-exit hook of its own to free the chunks (which is the key again), and Cranelift’s TLS models differ by object format. The key gives both backends the same runtime, with the same calls.

3.3 Both backends, WASI, the report

  • Identical in behaviour: the same four functions, the same order of calls, the same bound (cancho_ir::REGION_CACHE_SLOTS, one constant both read), the same clearing. The LLVM backend writes them as IR text (region_cache.rs); Cranelift builds them with its function builder (region_cache.rs there). pthread_key_t is 4 bytes on glibc and 8 on Darwin; the key is kept in a zeroed 8-byte global, which holds either, as the key plus one (zero means “no cache”).
  • WASI (wasm32-wasip1, LLVM only): there are no threads (spawn is refused on that target, wasm.md), so the stack is one module global and there is no key, init or destructor. Same bound, same clearing.
  • The authority report does not change. These are calls the compiler emits for region, like its malloc and free today, not extern fn declarations: no effect label, no foreign symbol, bounded unchanged. The pthread names are declared only when the program does not declare them itself, as every libc name is (#388).

4. Memory growth

  • The bound is 16 chunks, 1 MiB of address space, a thread that has opened regions. Resident is less: a chunk’s pages are resident only where a region touched them, and the clearing touches nothing the region did not.
  • What a thread holds is the most regions it has had open at once, up to 16: the server above holds 10 chunks (640 KiB of address space) whatever its number of connections, since its connections share one thread.
  • Long-running services. tls_echo with 10,000 connections is one thread: at most 1 MiB beside tls-memory.md’s 16.7 KiB a connection (167 MB), so at most 0.1 KiB a connection at 10,000; that is the bound its figures must stay within. cancho-hooks spawns a thread per worker: at most 1 MiB each. A program that wants the old behaviour has no switch; one region per batch rather than per item (threads.md §7) is unaffected.

5. Gates, stated before the numbers

  1. System calls (strace -c, as §1): a full and a resumed server handshake, and a client’s, make O(1) brk calls: at most one a hundred handshakes, from 112 and 136; mmap/munmap not more than today. A conformance test checks it on Linux in CI (§6).
  2. Cycles on gram (scripts/tls_handshake_cost.py --sudo-perf, x86-64, CPUs 0 to 3) and CPU time on the M4’s arm64 Linux VM, before and after alternating, OpenSSL beside each: the server’s full handshake at most 2.0 times s_server (the experiment reached 1.87), the resumed one at most 1.2 times OpenSSL’s resumed (T5 as restated in tls-performance.md §7 Q9 and #455; the experiment reached 1.05), the client’s at most 1.8 times tconn (1.65). T1 and T5 written into §10.
  3. macOS: region per unit of work flat across threads (threads.md §7’s table, the Cranelift row, re-run).
  4. Memory: scripts/tls_memory.py at 10,000 connections on both machines within 0.1 KiB a connection of before (§4).
  5. Secrets: the clearing is in the optimised object (§3.1), on both backends; the TLS suites unchanged.
  6. The whole gate: cargo fmt, clippy -D warnings, cargo test --workspace (Linux in Docker and CI; macOS natively), scripts/publish_packages.py --check, scripts/history.py --explain, scripts/tls_perf.py check (instruction counts may drop; a row beyond the guard is re-recorded on its own machine, tls-performance.md §1.3).

6. Tests

  • O(1) system calls: a program that opens three nested regions 20,000 times, built on both backends, run under strace -f -c on Linux: at most a handful of brk calls in all (today about 20,000). Where strace is missing it is skipped, except in CI, which installs it.
  • Chunks reused: the same program under an LD_PRELOAD shim counting 64 KiB mallocs (Linux): at most 3, against 60,000.
  • Threads: eight spawned threads each run the nested loop and end; counted the same way, each makes its own stack (the count is at most 3 a thread, so no thread took another’s chunk) and every chunk is freed by the time the threads are joined (the shim counts the frees).
  • The bound: a recursion 40 regions deep, repeated: the shim sees at most 40 − 16 = 24 chunk mallocs a repetition after the first and never more than 16 kept.
  • Clearing: the shim checks that every chunk freed (past the bound, or by a thread’s end) has its first page cleared, where the program filled it with non-zero values; a program that fills a region, ends it, and opens another reads only its own fill (the language’s guarantee, checked on both backends). As built: the object code was read by hand (§8.3) rather than by a unit test; the shim’s check is what fails when the clearing goes (§8.6, the mutants).
  • The trap and every existing arena test unchanged.

7. Not in scope

  • Growing a region past 64 KiB (linearity-and-effects.md §6 leaves it to M3); smaller chunks (they change which programs trap, threads.md §7).
  • box_slice and Box, which use the heap directly and are freed when the program says.
  • Returning cached chunks to the system while a thread lives (a service that once nested 16 deep keeps 1 MiB of address space on that thread).

8. As built, and measured

Built as §3 says: crates/cancho-ir/src/region_cache.rs (the bound and the names), cancho-codegen-llvm/src/region_cache.rs (IR text) and cancho-codegen/src/region_cache.rs (Cranelift), with region_stmt and the return path of each backend calling take and give. Measured at this change’s head against 2cb860b3 (its base), each pair alternating on the same machine in the same session; raw output in assets/region-chunk-cache/.

x86-64arm64
machinegram, i7-1260P, Linux 7.0.0-38, glibc 2.43, powersave; CPUs 0 to 3, the server on 0 and its peer on 1; load average 0.1 to 3.6 (another user’s build at 10 during one memory run, §8.5)Apple M4 Max, Docker Desktop VM (6 vCPUs), image lexsys-hooks-env, Ubuntu 24.04, Linux 6.8.0-100, glibc 2.39; VM load average 1.6 to 3.4
countersuser and kernel cycles through sudo perf (perf_event_paranoid stayed at 4)none: CPU time
OpenSSL3.5.53.0.13

8.1 System calls (gate 1)

strace -f -c as §1, the same 5-second runs (the handshake counts differ because tracing every brk slowed the old binary):

x86-64: beforeafterarm64: beforeafter
server, full: handshakes; brk3,001; 336,165 (112.0 a handshake)5,148; 9 in the run4,159; 465,861 (112.0)8,637; 9
server, resumed: handshakes; brk3,180; 432,665 (136.1)11,362; 95,209; 708,609 (136.0)17,299; 9
client, tls_many 200: brk39,022939,8259
mmap / munmap in each run16 to 17 / 9 to 10the same14 to 15 / 11 to 12the same
64 KiB mallocs (the shim): server full; client227 a handshake; 371 a handshake10 in the run; 15

The raw files are a second run of the same commands (sysc.sh, chunkcount.c beside them), on a busier gram: fewer handshakes, the same rates (112.04 and 136.14 brk a handshake before; 9 in a run after). The 9 brk calls are start-up’s and the heap’s first growth; the 10 chunks are the server’s deepest nesting, taken once and kept. Gate 1 met on both machines. The conformance test’s program (three nested regions, 20,000 rounds) made 80,003 brk calls before and 4 after, on both backends (arm64).

8.2 Handshakes (gate 2)

scripts/tls_handshake_cost.py --seconds 8 --rounds 2, run before, after, before, after: the least of four rounds a row. x86-64 in M cycles a handshake (user and kernel), arm64 in ms of CPU. The ratio is to OpenSSL’s row in the same runs.

x86-64: beforeafterchangeratio before → afterarm64: beforeafterchangeratio before → after
server, full (s_server)2.8211.844−34.6%2.87 → 1.86 (oserv 3.04 → 1.98)0.3820.291−23.8%1.93 → 1.50 (oserv 2.67 → 2.09)
server, resumed2.0010.884−55.8%2.33 → 1.040.2700.147−45.6%1.99 → 1.12
client, full (tconn, verified)4.4742.664−40.5%2.74 → 1.640.6820.423−38.0%2.44 → 1.51
client, resumed1.9010.994−47.7%2.32 → 1.210.2840.159−44.0%2.35 → 1.33
  • As the experiment said (tls-performance.md §10.3: 1.860M, 0.904M, 2.694M cycles with glibc told not to trim): the cache reaches 1.844M, 0.884M and 2.664M, a little below, since it skips malloc and free as well as brk. Instructions fell from 9.68M to 8.09M a full server handshake and from 5.24M to 3.30M a resumed one. OpenSSL’s rows did not move (s_server 0.984 to 0.997M before, 0.991 to 0.992M after).
  • Gate 2 met: the server’s full handshake at most 2.0 times s_server (1.86 on x86-64, 1.50 on arm64); the resumed one at most 1.2 times OpenSSL’s resumed (1.04, 1.12): T5 as tls-performance.md §7 Q9 restates it is met for the server on both machines; the client’s at most 1.8 (1.64, 1.51). T5 also covers the client’s resumed handshake (#455), and there it is not met: 1.21 on x86-64, 1.33 on arm64 (from 2.32 and 2.35); that gate was stated before #455 widened T5 and named the server’s only. The arm64 changes are smaller than the experiment’s there (−27.2%, −49.5%, −32.0%) on the server and larger on the client (−38.0% against −32.0%); the VM’s figures move by about 10% between rounds.

8.3 What clearing costs (§3.1)

A compiler built with the memset taken out of give (LLVM; everything else the same), alternating with this one, two passes of two rounds, x86-64: a full server handshake 1.849M cycles without clearing and 1.844 to 1.850M with it, 8.081M against 8.090M instructions (+0.1%); a resumed one 0.882M against 0.883M, 3.293M against 3.303M instructions (+0.3%). The difference in cycles is inside the rounds’ spread. The clearing is in the optimised object: on macOS cancho_region_give begins sub x1, x1, x0; bl _bzero (the memset with a zero byte, which Darwin’s libc spells bzero), before the branch that keeps or frees the chunk; and the shim of §6 finds every freed chunk cleared on both backends (§8.6). On Cranelift there is no dead-store elimination to defeat.

8.4 macOS: a region per unit of work, by threads (gate 3; #375)

A program spawns T threads; each opens 300,000 regions of 80 ints and sums them through a function (threads.md §7’s shape); cancho build --backend <b>, M4 Max, macOS 26, load average 7 to 13 (other work on the Mac), the least of three passes, wall time:

backendT = 1: before → afterT = 8T = 16
Cranelift0.18 → 0.03 s3.82 s (sys 21.1 s) → 0.03 s8.76 s (sys 99.4 s) → 0.04 s (user 0.59 s)
LLVM0.15 → 0.01 s3.80 s (sys 21.4 s) → 0.01 s8.76 s (sys 100.2 s) → 0.01 s

Flat across threads, and #375’s table becomes a row of zeros of system time. Before, the old binaries’ system time grew with the threads (each region a large malloc and free through the VM allocator); after, a thread takes one chunk and keeps it. The LLVM build did not delete the malloc/free pair here before (threads.md §7’s toy loop did): the sum is a call it did not inline away. threads.md §7’s workaround (one region per batch) is no longer needed for speed.

8.5 Memory (gate 4)

scripts/tls_memory.py cancho <tls_echo> --counts 10000, before and after, tls_echo one thread; KiB of server RSS a connection over the RSS at start (tls-memory.md §2’s phases), and the absolute RSS:

connectedhandshakeestablishedechoedpartialall-echoedRSS established, absolute
x86-64, before86.7110.417.053.081.5142.0172,824 KiB
x86-64, after86.7110.417.053.081.5142.0172,964 KiB (+140)
arm64, before86.7108.017.053.081.5142.1172,212 KiB
arm64, after86.7108.017.053.081.5142.0172,360 KiB (+148)

Gate 4 met: no phase moved by 0.1 KiB a connection. The server is 68 to 140 KiB larger in every phase on x86-64 and 144 to 256 KiB on arm64, start included: the pages of the chunks its one thread keeps, against §4’s bound of 1 MiB a thread. The tls-memory.md figures stand as they are.

8.6 Tests, mutants, the guard

  • crates/cancho/tests/conformance/region_cache.rs (§6): every program reads back its own fill on both backends (macOS and Linux); on Linux, nested regions take 3 chunks in 2,000 rounds; eight threads and main take 27 and the threads’ 24 are freed, cleared, before join answers; the recursion 40 deep takes exactly 2,416 and frees exactly 2,400, all cleared; under strace, 20,000 rounds make at most 16 brk calls (4 measured; 80,003 before). CI’s Linux job installs strace.
  • cancho-codegen-llvm’s a_region_left_by_return_frees_its_chunk_before_the_ret now checks that every ret out of a region comes straight after a give, and counts take and give where it counted malloc and free.
  • Mutants (scripts/region_cache_mutants.py, Linux): 14 of 14 killed in the arm64 VM: in each backend, no clearing; a stack that keeps 17; take that never pops; give that never keeps; a thread’s chunks not freed when it ends; main that makes no key (and, on LLVM, the key stored without its one); and 15 slots in cancho-ir. No mutant is argued equivalent.
  • WASI: a recursion 40 regions deep, 200 times, built with --target wasm32-wasip1 and run under wasmtime, exits 0.
  • scripts/tls_perf.py check: 33 of 33 rows within the guard on both entries, and none beyond it, so no baseline is re-recorded. The rows with a region in them got cheaper: on arm64 aes128gcm_prepare 0.82 of its recorded count, hkdf_expand_label_32 0.94, chacha20poly1305_open at 64 bytes 0.94; on x86-64 aes128gcm_prepare 0.90 and chacha20poly1305_open at 64 bytes 0.96; one row rose, hkdf_expand_label_48, to 1.04.
  • The authority report is unchanged: tls_echo’s pinned report (bounded: true, no foreign symbol) passes, and the pthread names are not extern fn declarations.

8.7 Not done, and not verified

  • macOS natively, cargo test --workspace: run on this M4 (macOS 26): every test passed except backends::spawn_and_join_run_concurrently_not_sequentially, a wall-clock bound of 400 ms that the first run of a freshly built binary misses on this Mac before and after the change alike (0.43 and 0.44 s the first run, 0.20 and 0.21 s after; the program opens no region). Linux (the arm64 VM): every test passed except two that need a git checkout the container does not have (project::a_compiler_other_than_the_one_named_is_refused, vcs_remote::a_lock_holds_a_commit_and_never_a_name); CI runs both.
  • The kept-request rows of tls-performance.md §10.5 were not re-measured: they reached no brk before this change.
  • Not measured: cancho-hooks under its soak with this compiler; a Linux program with more than a few threads for RSS; musl.
  • Chunks a thread keeps are not given back while it lives (§7).

std.ecdsa_sign: ECDSA P-256 signing in constant time, and P-256 private keys

Status: built. Not independently reviewed (#209). Step 1 of the TLS server (docs/tls-server.md §8): the signature a server sends in every full handshake’s CertificateVerify, made with a long-lived secret key, and the parsers that read that key from a PEM file. The design is docs/tls-server.md §3 and §4; this document is what was built, how it was checked, and what building it found.


1. What is built

FileWhat
std/ecdsa_sign.cho, module std.ecdsa_signsign, sign_checked and to_der on P-256 with SHA-256 (ecdsa_secp256r1_sha256)
packages/x509/key.cho, module x509_keyparse_pem, parse_der, public_point and matches_certificate, for P-256 keys in PKCS#8 and SEC 1
std/bigmod.choload_secret: a register loaded from a secret without a branch or an index on its value (§2.2)
std/ecdh.choscalar_ok: in_range’s constant-time check of a scalar against n, public
tests/programs/ecdsa_sign_driver.cho, ecdsa_sign_timing.chothe drivers of §5 and §6
scripts/ecdsa_sign_{differential,keys,mutants,timing,bench}.pythe evidence (§5 to §7)

The API.

  • sign(digest, key, extra, sig, work) signs a SHA-256 digest (32 bytes) under a private key (32 bytes, big-endian, in [1, n)), into sig as r || s (64 bytes). extra is the 32 bytes of added randomness RFC 6979 §3.6 allows, from the caller’s DRBG, or empty for RFC 6979’s deterministic nonce. work is work_len() words, std.ecdh’s, and is zeroed before the answer.
  • sign_checked(digest, key, point, extra, sig, work) signs, then verifies the signature with std.ecdsa.verify_raw under point before giving it out (docs/tls-server.md §3.3). A signature that does not verify is zeroed and refused. So is one made with a point that is not the key’s, which the engine’s own check (§4) should already have ruled out.
  • to_der(sig, out) writes r || s as the DER SEQUENCE { INTEGER r, INTEGER s } TLS sends, at most der_max() = 72 bytes.
CodeTagWhen
-70ecdsa-sign-digest-lengththe digest is not 32 bytes
-71ecdsa-sign-key-lengththe key is not 32 bytes
-72ecdsa-sign-key-rangethe key is 0 or not below n
-73ecdsa-sign-extra-lengththe added randomness is neither empty nor 32 bytes
-74ecdsa-sign-output-lengthsig is not 64 bytes; for to_der, out shorter than the encoding
-75ecdsa-sign-work-lengthwork shorter than work_len()
-76ecdsa-sign-nonce16 nonces in a row were out of range or gave r = 0 or s = 0: each has a probability near 2^-32, so no input reaches it
-77ecdsa-sign-checksign_checked: the signature did not verify under point

Why a module of its own. The design says std.ecdsa.sign. But signing multiplies with std.ecdh’s ladder, and std.ecdh imports std.ecdsa for the curve constants (curve_param, copy_point), so std.ecdsa cannot import std.ecdh back. A new module that imports both is the change that moves nothing; std.ecdsa stays verification, on public data, as docs/ecdsa.md describes it.

2. Constant time

2.1 The signature

SEC 1 §4.1.3, with every operation that touches d or k one that std.ecdh already made constant time:

k        = RFC 6979's nonce (§3)                         HMAC-SHA-256 over d, h and the added randomness
(x, y)   = k·G       std.ecdh.public_key                 the 4-bit window over complete formulas, the table read under masks
r        = x mod n   bigmod.load_reduced                 x is public once r is sent
s        = k^-1 (e + r·d) mod n                          bigmod: load_secret, to_mont, inverse, mul, add
  • k·G is std.ecdh.public_key, the same ladder the key exchange uses (docs/ecdh.md §2), with G as the point. No second ladder was written; the fixed-base table docs/tls-server.md §3.1 names as a later optimisation is still later. Its scalar range check is constant time and answers only in range or not.
  • The arithmetic mod n is std.bigmod’s registers: mul (Montgomery, with the masked ct_reduce), add and sub (masked), and inverse, which is Fermat’s a^(n-2): its square-and-multiply branches on the bits of n − 2, which are public, so its time does not depend on k.
  • d and k are loaded with bigmod.load_secret (§2.2), not load_reg.
  • The key’s range is checked with std.ecdh.scalar_ok, std.ecdh’s borrow over every byte; only the answer is public.

What stays variable time, all of it on public values:

  • the lengths of the inputs and the curve’s sizes;
  • whether a candidate k is in [1, n): one that is not is discarded and the next drawn (RFC 6979 §3.2 step h), and whether a discarded candidate was out of range says nothing about the k kept;
  • r == 0 and s == 0, each with a probability of about 2^-256, and both public once the signature is: is_zero stops at the first nonzero limb of a value about to be sent;
  • the DER encoding of r and s;
  • the digest, reduced mod n with load_reduced: it is the hash of a message the peer sees.

2.2 What reading a secret into a register needed

bigmod.load_reg was written for public values (docs/rsa.md): load skips zero bytes (if v != 0) and compare_n stops at the first limb that differs from n. Either one, given a private key, would be a branch on it. ECDH never met this, because its scalar is read a nibble at a time by the ladder and never enters a register. load_secret places every byte by its position only: which limb it lands in, and whether it straddles two (off > 22), depend on where it is, not what it is. It does no range check; the caller has made one in constant time.

2.3 The audit

scripts/chacha20_branches.py (it now also takes a package’s symbol, lexs_x509_key.within) over the object of tests/programs/ecdsa_sign_driver.cho, built by the LLVM backend on Linux x86-64. Every conditional jump that is not to a trap was read in the disassembly:

FunctionThe jumps that are not to a trap compare
bigmod.load_secretthe limb count, the length, the loop counter, and off > 22 (cmp $0x17,%rcx): positions
ecdh.in_range, ecdh.scalar_okthe loop counter and the curve’s size; the borrow and the OR are seta/setg, not jumps
ecdsa_sign.finishis_zero on r and on s (public, above), store_reg’s positions, set_small’s counter
ecdsa_sign.reseed, rekey, candidate, copy32; bignum.zerothe HMAC state’s length (cmp $0xca), and counters
ecdsa_sign.signthe lengths, the attempt counter, public_key’s answer and the code: §2.1’s public values
lexs_x509_key.within, base64_valuenothing but overflow traps: the masks stay arithmetic through value_barrier
lexs_x509_key.base64_decodewhether a character is base64 at all (cmp $0xff), and only for one that is not: whitespace (bt), =; the count of padding and of bits, which are positions; and the final check that the bits left over are zero, whose answer is the file’s validity

The functions std.ecdh and std.bigmod already had (mont_mul, ct_reduce, add, sub, select, add_points, double_point, multiply) show the same jumps docs/ecdh.md §3 read: loop counters and k. inverse’s bt %rax,%rcx is its test of a bit of n − 2.

3. The nonce

RFC 6979 §3.2 over HMAC-SHA-256, with §3.6’s additional data. For P-256 with SHA-256, qlen = hlen = 256, so bits2int is the 32 bytes as they are, int2octets(x) is the key, and bits2octets(h1) is the digest reduced mod n once.

V = 01 × 32,  K = 00 × 32
K = HMAC_K(V || 00 || d || h || extra);  V = HMAC_K(V)
K = HMAC_K(V || 01 || d || h || extra);  V = HMAC_K(V)
loop: V = HMAC_K(V); k = V; if 1 <= k < n and r, s != 0: done
      else K = HMAC_K(V || 00); V = HMAC_K(V)
  • Deterministic in d and h, so a broken DRBG cannot give two messages the same k: that is the failure that gives a key away (Sony’s PlayStation 3, Android’s SecureRandom in 2013).
  • Hedged with the caller’s 32 bytes, so a fault injected into one signature does not recur when the same message is signed again, and the same message is not signed twice with the same k when the randomness is good.
  • With extra empty the nonce is RFC 6979’s exactly, which is what lets Appendix A.2.5’s vectors test it (§5.1).

The DRBG’s K and V, the candidate, and k·G’s y coordinate are zeroed before sign answers, best effort as docs/hkdf.md §3 says. work, which held d, k and the ladder’s table, is zeroed by std.ecdh after the multiplication and by sign at the end.

4. Keys

4.1 Where they are read, and why there

In packages/x509, as x509_key, not in std and not in packages/tls:

  • the DER is read with x509.tlv, the strict reader the certificate parser uses, and the OIDs come from its table: a second DER reader in std would be a copy (conformance/duplication.rs);
  • a key file is the same kind of thing as a certificate file, PEM around DER, and matches_certificate needs the certificate’s parsed public key, which is x509’s view;
  • packages/tls already imports x509; the engine will call x509_key from tls.add_identity (step 2). Like x509_verify, it imports std (std.ecdh for the point), and docs/package-system.md §4.8 already covers that.

4.2 The formats

LabelFormatAccepted when
PRIVATE KEYPKCS#8 PrivateKeyInfo (RFC 5208 §5), or RFC 5958’s v2 with the public keyversion 0 or 1; algorithm id-ecPublicKey with the named curve prime256v1 (RFC 5480); the private key an ECPrivateKey; [0] attributes and [1] publicKey optional
EC PRIVATE KEYSEC 1 ECPrivateKey (RFC 5915)version 1; the key 1 to 32 bytes; [0] the named curve prime256v1, required, as RFC 5915 §3 says; [1] the public key optional
  • The first private-key block is used. Blocks that are not a key (EC PARAMETERS, which openssl ecparam -genkey writes first, or a certificate) are skipped. The END line must name the BEGIN line’s label.
  • A key shorter than 32 bytes is left-padded. OpenSSL before 1.1.0 wrote a key whose top byte was zero one byte short; this accepts it rather than refusing one key in 256 from an old tool.
  • A public key in the file must be the key’s own: the point is derived with public_point (k·G on the ladder) and compared. PKCS#8 v2 can carry it twice, inside and outside; both must agree.
  • matches_certificate(cert, point) parses the certificate and compares its public key with the point: the check docs/tls-server.md §4 puts in add_identity (tls-server-key-mismatch).
CodeTagWhen
-80key-pemno private-key block; no END line or a different label; a character outside base64; bad padding
-81key-encryptedENCRYPTED PRIVATE KEY, or a traditional block with Proc-Type: 4,ENCRYPTED
-82key-algorithmnot an EC key: RSA PRIVATE KEY, an RSA or Ed25519 PKCS#8 key
-83key-curvean EC key on another curve, explicit parameters, or SEC 1 without its curve
-84key-dernot the DER structure the format names: truncated, trailing bytes, a wrong tag, an extra element
-85key-versionPKCS#8 version not 0 or 1; SEC 1 version not 1
-86key-lengththe private key empty or longer than 32 bytes
-87key-rangethe key 0 or not below n
-88key-public-mismatchthe file’s public key is not the key’s
-89key-sizethe block decodes to more than 8,192 bytes
-90key-buffer-lengththe caller’s key or point not 32 and 65 bytes, or work short
-91key-certificatematches_certificate: the certificate does not parse, or its key is not P-256
-92key-certificate-mismatchthe certificate’s key is another P-256 key

The TLS server maps them onto docs/tls-server.md §5.4’s three: key-encrypted, key-algorithm and key-curve are tls-server-key-type; key-certificate-mismatch is tls-server-key-mismatch; the rest are tls-server-key-format.

4.3 Reading a secret

The rest of packages/x509 reads public data and has no constant-time rule. x509_key reads a private key, so:

  • base64 is decoded without a branch or an index on a key character’s value. Each character’s six bits are computed from range masks (within, through value_barrier) the same way for every character; what branches is whether it is a base64 character at all, which for a key’s body is the file’s layout (its line breaks), not its content. That is the leak “Util::Lookup” (Sieck et al., CCS 2021) used to recover RSA keys from table-driven base64 decoding inside SGX enclaves.
  • The DER’s tags and lengths are the format’s, the same for every P-256 key; the key’s bytes are only copied.
  • The decoded DER is zeroed before parse_pem answers, and the key on a refusal.

This runs once, when a program loads its key, from a file the operator gives it, and nothing a peer sends reaches it. It is audited (§2.3) but not timing-tested.

5. How it is tested, and the results

tests/programs/ecdsa_sign_driver.cho runs everything, built with packages/x509/x509.cho and key.cho. crates/cancho/tests/conformance/ecdsa_sign.rs has the fixed cases; the scripts run OpenSSL and Python.

5.1 RFC 6979 and the refusals (conformance/ecdsa_sign.rs)

  • RFC 6979 Appendix A.2.5, P-256 with SHA-256, both messages (“sample” and “test”), with the added randomness empty: r and s exactly as the RFC prints them, from sign and from sign_checked, on both backends, and their DER (“test”’s s starts 01, so its INTEGER is 32 bytes and the SEQUENCE 69). The other hashes of A.2.5 use HMAC with that hash, which this signer does not offer.
  • Four values of added randomness give four signatures, none RFC 6979’s deterministic one, each verifying; the same randomness twice gives the same signature.
  • Every refusal of §1 the driver can reach, with its tag, and the key range’s edges: 0 and n refused, n − 1 signs.

5.2 Against OpenSSL and against RFC 6979 in Python

On Linux x86-64 (ubuntu:24.04, OpenSSL 3.0.13), in Docker:

python3 scripts/ecdsa_sign_differential.py …CasesResult
openssl: random keys (1, 2, n − 2 and n − 1 drawn often), random messages, randomness empty one time in ten; each DER signature verified by openssl dgst -sha256 -verify under a public key computed by the script, and by std.ecdsa.verify_der; then one bit flipped10,000 signaturesall 10,000 accepted by both; all 10,000 flipped refused by both; 0 differences
reference: the signature byte for byte against RFC 6979 written in Python (hmac, hashlib), with digests 0, n − 1, n and 2^256 − 1 drawn often so bits2octets’ reduction is reached; raw and DER10,000 signatures0 differences, raw and DER
keys: openssl genpkey keys (PKCS#8), the same converted by openssl ec (SEC 1), and openssl ecparam -genkey files (an EC PARAMETERS block first); each parsed to the key and point openssl pkey -text prints, signed with and verified by openssl dgst -verify, and matched against its openssl req -x509 certificate and not the next key’s100 keys (67 PKCS#8, 33 after EC PARAMETERS), 600 checks0 differences

Why reference as well as openssl. A signature that OpenSSL verifies shows that r and s are right for some k. It does not show that k is RFC 6979’s, that the added randomness reached the DRBG, or that the digest was reduced before it was fed to it. Six of the mutants of §5.5 sign signatures OpenSSL accepts with a nonce that is not RFC 6979’s (the DRBG’s separator, its key, its randomness, its digest unreduced, K taken for V, V not stepped). RFC 6979’s own vectors kill four; the randomness and the unreduced digest only the byte-for-byte comparison does.

5.3 Key files (tests/vectors/ecdsa_sign/)

scripts/ecdsa_sign_keys.py made 43 key files and 5 certificates with OpenSSL 3.6.4, then damaged them one rule at a time. Every one answers its tag, or the key and point OpenSSL printed for it: 7 accepted (PKCS#8, SEC 1, CRLF line ends, PKCS#8 v2 with its public key, PKCS#8 with attributes, after an EC PARAMETERS block, a 31-byte key), and 36 refused, which reach every tag of §4.2 a file can reach. matches_certificate matches its key’s certificate and refuses another P-256 key’s, a P-384 one, an RSA one and a broken one.

5.4 The audit

§2.3.

5.5 Mutants

python3 scripts/ecdsa_sign_mutants.py target/release/cancho runs 37 mutants: 17 in std/ecdsa_sign.cho, 2 in bigmod.load_secret, 1 in ecdh.scalar_ok and 17 in packages/x509/key.cho, each against §5.1 and §5.3’s cases and the three differentials of §5.2 at 300, 100 and 6. 37 are killed, on macOS (OpenSSL 3.6.4) and in the Linux container (OpenSSL 3.0.13).

Two first survived, both in the PEM reader, both test gaps:

  • a stray = inside the base64 accepted. The only bad-padding file had its = at the end, which the final count catches anyway. Now padding-inside: a SEC 1 key’s closing == moved into its first line.
  • the END line’s label not compared. The only file with a wrong END label had one of another length, which the check of the closing ----- caught. Now end-label-same-length (END PRIVATE KEX).

std.ecdh’s ladder and std.bigmod’s arithmetic are not mutated here: scripts/ecdh_mutants.py covers them, and nothing in them changed.

6. Timing

scripts/ecdsa_sign_timing.py, scripts/ecdh_timing.py’s dudect test over ecdsa_sign.sign (tests/programs/ ecdsa_sign_timing.cho): one message and one 32-byte added randomness for every call, the key drawn from one of two classes at random. A fixed key then gives the same nonce every time and a random key a random nonce, so the whole secret input is fixed against random.

  • a fixed random key against random keys;
  • the key 1 against random keys, the shape that found std.ecdh’s leak (docs/ecdh.md §3). Here it gives r·d with a one-limb d, which a multiplication whose time depended on its operands would show.

The machine: gram, an Intel Core i7-1260P (Alder Lake laptop part, 4 performance and 8 efficiency cores), Ubuntu’s kernel, the powersave governor; the test in an ubuntu:24.04 container pinned to performance core 2 (--cpuset-cpus=2), its sibling idle. tick is rdtscp. LLVM backend. The gate: |t| below 4.5 at 10^6 measurements a test (docs/tls-server.md §3.4).

Test, LLVM backendMeasurementsMedianmax |t|
a fixed key against random keys1,000,0003,873,943 cycles1.66
the key 1 against random keys1,000,0003,779,637 cycles2.81

Both pass. The medians differ because each test’s samples are mostly from its random class and the runs were at different times (the fixed-key run shared the machine with a cargo test on the efficiency cores for part of its length); the classes are interleaved within each run, so that moves both alike. A first run, stopped at 550,000 samples when wipe was replaced by bignum.zero (the duplication check found it a copy), stood at the same level. Cranelift was not timed.

7. Cost

Corrected (#380, docs/p256-fast.md §9): sign is 59 µs on the M4 (820 µs before) and 124 µs on the loaded i7 (1,716 µs interleaved; 1.47 ms in the table below), sign_checked 164 µs; the two items §9 lists as not done are done. The table below is main before.

python3 scripts/ecdsa_sign_bench.py <driver>: 2,000 signatures with random keys and digests, best of three runs, minus a run of as many DER encodings for the driver’s own reading and printing. LLVM backend, one core:

Machinesignsign_checkedthe check
Intel i7-1260P (gram, Linux, Docker, one performance core)1.47 ms (680 a second)2.98 ms1.50 ms
Apple M4 Max (macOS, loaded: load average about 8)0.82 ms (1,225 a second)1.62 ms0.80 ms
i7-1260P, Cranelift4.10 ms7.75 ms3.65 ms

The i7’s figures are three runs that agree within 4%; one earlier run gave 2.69 ms, with the core in a lower frequency state under the powersave governor, and is not used.

Almost all of it is the k·G ladder (std.ecdh.shared on the Xeon of docs/chacha20.md §6 was 2.6 ms): the HMACs, the inversion and the two bigmod.setups are the rest. OpenSSL’s openssl speed ecdsap256 on the same machine signs in 23 µs (OpenSSL 3.0.13, 44,132 a second; 65 times faster), with a fixed-base table and arithmetic specialised to P-256’s prime (docs/ecdsa.md §5.4). docs/tls-server.md §6 now uses these numbers.

8. Found

  • bigmod.load_reg cannot take a secret (§2.2): load skips zero bytes and compare_n exits early. Nothing had loaded a secret into a register before; ECDH never does. Hence load_secret.
  • std.ecdsa cannot hold the signer, against the design’s wording (§1): the import would be a cycle.
  • Two test gaps in the PEM reader (§5.5).
  • docs/README.md had conflict markers, from a merge of main into the design’s branch, merged with it (#335). Resolved here, keeping every row.

9. Not done

  • A fixed-base table for k·G and arithmetic specialised to P-256’s prime: the two speed-ups docs/tls-server.md §8 step 7 names. Done (#380, docs/p256-fast.md): the table is std.p256_comb, the arithmetic std.p256, s = k⁻¹(e + r·d) is on the same kernels, and §2.2’s load_secret is std.p256.load, which does the same by position.
  • P-384, Ed25519 and RSA-PSS signing: docs/tls-server.md §8 step 7, each with its own timing test.
  • A timing test of the key parser: §4.3 says why it is audited only.
  • Zeroing that a test could see. The wipes of §3 are best effort and no test reads work after sign; a mutant that removes one survives by construction, so none is listed.
  • The independent review (#209).

std.p256: faster P-256 (#380, part of #378)

Status: built; not independently reviewed (#209). §1 is the profile that came first (the design was committed before the code); §2 to §7 are the design as built, §8 its gates, §9 what it costs, §10 what fell short. §11, a table for a key that verifies many signatures (B2 and B3 of #378: a TLS server’s own key, a client’s trust anchors), and §12, P-384 measured and decided, came after (#380 reopened); §13 moved the field’s multiplications onto mul_wide (#381). The TLS performance work (#378) names P-256 as the largest single cost in a server handshake: std.ecdsa_sign.sign is 0.80 ms on an M4 and 1.24 ms on the i7 of gram, OpenSSL’s is 23 µs. docs/ecdsa.md §5.4 and docs/ecdsa-sign.md §9 said where the time goes without measuring it. §1 measures it; §2 to §7 are the design that follows from the numbers; §8 is how it is checked.


1. Where the time goes

All numbers python3 scripts/p256_bench.py <driver> (tests/programs/p256_profile_before.cho for this section, built from main at the base of this PR): the time of one operation is (T(rounds) - T(1)) / (rounds - 1), the best of five runs, LLVM backend, one core. Machines, with the load when measured:

  • M4: Apple M4 Max, macOS 26, native arm64. Load average 4.8 (other agents building): the figures move about 10% between runs.
  • Instructions, which no load moves. scripts/tls_perf.py (docs/tls-performance.md §6, which landed on main during this work) counts each primitive’s instructions under valgrind. On gram (x86-64, valgrind 3.26) at this branch’s head:
Operation (tests/programs/tls_prims_bench.cho)main (the recorded baseline)this branch
p256_public23,446,0221,197,24519.6x fewer
p256_shared23,449,2545,002,7714.7x
ecdsa_p256_sign25,330,4501,630,47815.5x
ecdsa_p256_sign_checked50,575,4255,126,3929.9x
ecdsa_p256_verify25,246,1463,488,0547.2x

These are the five entries of benches/tls_baseline.json’s linux-x86_64 re-recorded in this change, as the guard asks of a primitive that got more than 1.3 times cheaper; the 28 other operations counted within 2% of their baseline and were left as they were. The linux-aarch64 entries were not re-recorded: the Docker VM’s disk was full and the compiler could not be rebuilt in it (a build of an older revision of this branch counted the same five about as many times fewer). A check on Linux aarch64 will fail on those five until they are; CI runs the guard on x86-64 only.

Docker arm64: the image lexsys-hooks-env on the same M4 Max, in Docker Desktop’s 6-vCPU Linux VM. Load average 4.3.

  • gram: Intel Core i7-1260P (Alder Lake, performance core 2 of a hyper-threaded pair; taskset -c 2), Linux x86-64, the powersave governor. Load average 5 to 6 (a soak of another project on cores 8 to 15, other agents on 0-1, 4-5 and 6).
Operation, mainM4Docker arm64gram (i7-1260P)
field multiplication (bigmod.mul, mod p)115 ns114 ns167 ns
field addition (bigmod.add)23 ns22 ns34 ns
field subtraction (bigmod.sub)16 ns14 ns24 ns
inversion mod p (Fermat)43.7 µs44.7 µs64.7 µs
inversion mod n (Fermat)50.2 µs50.1 µs73.0 µs
ecdh.public_key (k·G)767 µs757 µs1,143 µs
ecdh.shared (k·P)779 µs746 µs1,152 µs
ecdsa_sign.sign803 µs808 µs1,240 µs
ecdsa_sign.sign_checked1,650 µs1,631 µs2,476 µs
ecdsa.verify_raw827 µs848 µs1,231 µs

1.1 Field operations per operation

python3 scripts/p256_opcount.py counts the bigmod.mul, add and sub calls in each point function’s body, so a count cannot drift from the source, and composes them as the code runs them:

muladdsub
RCB complete addition (ecdh.add_points)14209
RCB complete doubling (ecdh.double_point)13156
Jacobian addition (ecdsa.add)1649
Jacobian doubling (ecdsa.double)8106
inversion by Fermat255 squarings + 127 (mod p) or 168 (mod n) multiplications
Operationmuladdsubaccounted at the M4’s unit costsmeasured
ecdh.shared / public_key (14+64 additions, 256 doublings, one inversion)4,8105,4032,239713 µs767 to 779 µs
ecdsa.verify (Shamir, 192 additions expected, two inversions)5,9543,3353,274814 µs827 µs
ecdsa_sign.sign (the ladder and two inversions)5,2395,4002,238762 µs803 µs

The field operations account for 93 to 98% of every operation. The rest is the copies of a point (copy_point), the table select, bigmod.setup and the loops around them. So the cost is in the arithmetic, and the question is how cheap a field operation can be:

  • Inversions are 5 to 13% of an operation: 43.7 µs of 767 for public_key; the two of a signature, 94 µs of 803 (12%); the two of a verification, about 94 µs of 827. They are small now and become the largest single item once the ladder is fast (§5).
  • The scalar multiplication loop is the rest: 4,810 multiplications at 115 ns is 553 µs of a 767 µs public_key (72%); the additions and subtractions, 7,642 of them at 14 to 23 ns, are 150 µs (20%).
  • A subtraction or addition costs 14 to 23 ns against 115 ns for the multiplication: a fifth. There are 1.6 of them for every multiplication, so they are a fifth of the time and, once the multiplication gets cheaper, they would be the largest part (§4.5).

1.2 Inside one bigmod.mul (115 ns on the M4)

mont_mul is the CIOS loop of docs/rsa.md §2.1 on 30-bit limbs: for P-256, 9 limbs, 162 multiply-adds in loops of a count read from memory, with the temporary and the operands in memory, then ct_reduce (the masked subtraction of §2 of docs/ecdh.md) and a copy.

  • The reduction is 10%. The same module with ct_reduce removed (a scratch copy, wrong answers, to time only): 103 ns against 115. So docs/ecdh.md §6’s “+11%” for ECDSA verification was this, and no part of the remaining 103 ns is the masked reduction.
  • The loop is the rest, and the loop is the language’s checked arithmetic. A straight-line P-256 multiplication on 10 limbs of 28 bits (prototype of §4.2: the same code written with + and *, then with wrapping_add and wrapping_mul), 100 + 70 multiplications, operands in registers:
One field multiplicationM4gram (i7-1260P)
bigmod.mul, generic115 ns167 ns
straight-line, checked + and *55 ns65 ns
straight-line, wrapping_*23 ns44 ns

So of the 5-fold on the M4 (3.8-fold on the i7), about 2 is going from loops over memory to straight-line code, and 2.4 (1.5 on the i7) is not testing every addition and multiplication for overflow. §4.3 is why the second is safe here.


2. What the language allows

  • int is 64 bits, signed, and traps on overflow; wrapping_add, wrapping_sub and wrapping_mul do not. There is no 128-bit product and no carry flag. A limb product must fit 63 bits with room to add: the widest limb for a fused step is 30 bits (docs/rsa.md §2.1), and for a column sum of ten products, 28.
  • No allocation per call: work is the caller’s [int]. A table the size of the generator’s cannot be in work, because the caller’s work is zeroed after every call (§6) and filling it costs more than the signature. A static holds it (docs/compile-time-data.md): evaluated while compiling, read-only data in the binary, indexed like any slice. Built from a hex string by a loop, a 10,400-word static takes 0.14 s to compile and nothing at run time (measured).
  • A function a static calls is emitted into every program built with --std, used or not (modules::std_declarations_cost_nothing_unless_called found it: the first version of the table module called a helper to decode its hex digits). So the tables’ bodies hold their literal and decode it inline, and std stays free when unused. Found, and fixed here, not fixed in the compiler.
  • A builtin for a wide multiply may land later (#378, another change). Nothing here depends on it, and §4.2 says where it would be used.

3. The steps, and what each was worth

Each step was measured when it was built (M4, LLVM, scripts/p256_bench.py, load average 4 to 8, so each figure moves about 10%) and is kept because it paid. “Expected” is what this document’s first commit computed from §1’s parts.

StepWhatExpectedMeasured (cumulative)
0mainpublic_key 767, shared 779, sign 803, verify 827 µs
1Ten-limb 28-bit field, straight-line kernels, lazy reduction; the ladder on it (§4)ladder 767 → about 170 µsshared 161, public_key 162 µs; sign 229 µs
2Arithmetic modulo n on the same kernels (§5)inversions 94 → about 20 µsinversion mod p 6.1, mod n 9.8 µs; sign 183 µs
3Fixed-base table for k·G, signed four-bit digits (§6)public_key about 40 µspublic_key 39.5, sign 62.7 µs
4Verification by wNAF, Shamir’s trick, the generator’s odd multiples (§7)1.3 times fewer multiplications on top of step 1verify 124 µs
5Slices for the operands (one bounds check, not ten), no copy loops, a projective final checknone computedshared 141, public_key 34, sign 59, verify 105 µs

Every expectation held or was beaten: step 1 and step 3 within 3%, step 2 a little better, step 4 (computed 112 to 130 µs) at 124. The issue’s expectation (signing 3 to 4 times faster, verification about 2 times) was a fraction of what the field alone gives.

Tried and measured, not kept:

  • The special form on 30-bit limbs (9 limbs, row by row): 27 ns a multiplication against 23 for 10×28 (§4.1).
  • Checked arithmetic in the kernels: 55 ns against 23 (M4), 65 against 44 (i7). The wrapping_* is proved (§4.3).
  • A dedicated squaring: 18 to 21 ns against 22 to 23 for a multiplication, kept (55 products against 100, but the reduction and the carries are the same).
  • Addition and subtraction without the carry pass (timing only, wrong answers): an add and a subtract in 7 ns against 16. Not built: it needs the bounds script to track limb widths as well as values, and the ladder’s additions are a fifth of its time, so it is worth at most 10% for a change to the part that must stay provable. Left as §10’s first item.

4. The field

4.1 Representation

A field element is ten limbs of 28 bits, least significant first, one limb to a word of the caller’s work, in Montgomery form with R = 2^280; the same for the group order n. 28 bits and not 30 because:

  • A column of products must fit in 63 bits. Product scanning (Comba) sums a whole column in one register before taking its carry, which removes the carry chain that makes the row-by-row loop slow. Ten products of two 28-bit limbs and the ten of the reduction are below 2^61 (proved by the generator, §4.3). With 30-bit limbs, nine products of 2^60 overflow 2^63.
  • R = 2^280 is 2^24 times p. A multiplication answers below p + x·y/R, which is under 2p for any inputs up to 4096 p. So no addition or subtraction needs to reduce, and the formulas can add and subtract freely (§4.4). With 9 limbs of 30 bits (R = 2^270) the bound is 128 p; with 9 of 29, 5 p.
  • Measured: a 9×30 row-wise kernel (the same special-form reduction) costs 27 ns on the M4 against 23 ns for 10×28.

4.2 Using the prime’s form

p = 2^256 − 2^224 + 2^192 + 2^96 − 1 is −1 modulo 2^28, so −p⁻¹ = 1 and the Montgomery quotient digit is the low limb itself: no multiplication by n′. In radix 2^28 its limbs are 2^28−1 (limbs 0, 1, 2 and 8), 2^12−1, 2^24 and 2^4−1 (limbs 3, 6, 9) and zero (4, 5, 7). So m·p_j is a shift and a subtraction, and a column’s reduction terms are summed first, then shifted once: no multiplication at all in the reduction, on top of the 100 of the product. The generator (scripts/p256_gen.py) writes it; the same generator writes the kernels for n, whose limbs are not special, with the multiplications. That is “Montgomery with the special-form simplification” and not Solinas reduction: Solinas needs the 32-bit words of 2^256’s structure, and a 32-bit limb’s product (2^64) does not fit this language’s signed 64-bit int.

Where a wide multiply would be used. The product of two 28-bit limbs fits in int. A builtin giving the high and low halves of a 64×64 product would let the limbs be 64 bits (4 limbs, 16 products), and this module’s kernels would be rewritten on it by changing the generator; nothing outside std/p256_kernels.cho (generated) would change, and its tests (tests/vectors/p256.txt) would be the check. Corrected (#381, §13): four 64-bit limbs would change more than the kernels. R = 2^256 leaves no headroom for §4.4’s lazy reduction, and the representation is read outside them (the comb’s 10,400 words, p256_vf’s wNAF digits, load, store, pow’s nibbles). What was built is five 56-bit limbs inside the kernels only, each packing two of the 28-bit limbs, R unchanged; §13 measured both.

4.3 wrapping_*, and the proof that it cannot wrap

bigmod is checked arithmetic on purpose (docs/ROADMAP.md #234: “an overflow would be a bug, so it traps”). That costs a branch after every addition and multiplication, and §1.2 measured it at 2.4 times the multiplication on the M4. The kernels here are wrapping_* because the generator computes, column by column, the largest value the accumulator can hold when every limb is at its maximum (2^28 − 1) and refuses to write the file if any reaches 2^63. The largest is 2^60.0 (the multiplication modulo n, column 9). So the wrapping_* is a statement about code that is straight-line and has no data dependence: the same bound holds for every input. A wrong bound is a failed generation. And it is tested at the bound: tests/vectors/p256.txt has the four kernels on operands of 280 bits with every limb 2^28 − 1 and other extreme limb patterns, against the exact identity (x·y + m·p) / R in Python. std.field25519 made the same choice (its header). Everything else in std.p256 is checked arithmetic.

Invariants, stated in std/p256_kernels.cho’s header and checked by scripts/p256_bounds.py against every formula:

  • every input is tight (limbs below 2^28) and every output is tight;
  • mul and sqr answer below p + x·y/R: under 2 p for inputs whose product is below 2^24 p² (4096 p each), and under 1.001 p when both are below 128 p, which is every input the formulas give (§4.4: at most 66 p). Corrected in review: this line said “inputs up to 4096 p, answer below 1.001 p”, which holds only for the second case.
  • add(x, y) answers x + y, tight, not reduced;
  • subK(x, y) answers x − y + K p, for y below K p, K = 2, 4, 8, 16, 32 or 64, tight; it needs no mask, no branch, no borrow handling: every limb of the constant is above 2^28, so no limb goes negative before the carry pass;
  • an element stays below 2^280, which is 16 million p.

4.4 No reductions inside the formulas

The formulas of docs/ecdh.md §2 add and subtract between multiplications, up to six deep. scripts/p256_bounds.py parses each formula’s source (add_points, mixed_add, double_point, the Jacobian double, add_jac, madd, the final check, the curve equation), propagates an upper bound through every p256.mul, sqr, add, copy and subK, and fails if a multiplication input exceeds 4096 p, a subtrahend exceeds its K, or a value 2^280. Functions whose outputs feed each other’s inputs form a group, and the script finds the smallest bound B such that every output is at most B when every input is: 5 p for the complete formulas of the ladder and the table, 32 p for the Jacobian ones (the largest product is 4,356 p², against a limit of 16.7 million, the largest value 66 p against 16.7 million). With --rewrite it also chooses each bare p256.sub’s K. The only full reductions are from_mont, which multiplies by 1 (the result is at most p) and subtracts p once under a mask (value_barrier, docs/value-barrier.md), for the output of an operation and for the comparisons that need an exact value.

4.5 Additions and subtractions

An addition or subtraction is ten limbs and a carry pass, 5 to 8 ns on the M4. With 1.6 of them for every multiplication they are about a fifth to a third of the ladder’s time (§3 lists the carry-free version as measured and not built).


5. Inversion and the group order

Fermat’s a^(p−2) and a^(n−2), the exponents public, so their windows may be read from memory. Fixed four-bit windows over the exponent’s nibbles: 252 squarings, 14 multiplications for the table and one per nonzero nibble (31 for p − 2, whose runs of zeros are long, 53 for n − 2). Measured on the M4: p 5.7 µs, n 10.1 µs (against 43.7 and 50.2). An addition chain exploiting the runs of ones in p − 2 would save about 33 multiplications (0.8 µs of 6): not worth its code.

Signing’s s = k⁻¹ (e + r·d) mod n is smul, sadd, sinvert and the conversions on the same kernels with n’s constants: no second Montgomery implementation.

6. Fixed-base multiplication by the generator

Signed four-bit digits and a table of every digit at every position. k = Σ dᵢ·16ⁱ, dᵢ in [−7, 8], 65 digits (the recoding carries: a nibble above 8 becomes negative and adds one to the next; corrected in review from “[−8, 8]”: −8 never occurs, 8 does). The table holds (j+1)·16ⁱ·G for i in 0..64, j in 0..7: 520 affine points, in Montgomery form, 83 KB as a static of 10,400 words (std/p256_comb.cho, 79 KB of hex). k·G is then the sum of 65 table entries, no doubling at all, each a mixed (affine) addition, RCB algorithm 5, 11 multiplications.

Constant time, for the secret k:

  • the recoding is arithmetic on the nibbles ((v + 7) >> 4), no comparison;
  • a digit’s sign and magnitude come from a shift and an xor, the masks through value_barrier;
  • the entry is chosen by reading all eight under masks, each ORed in under a mask that is all ones for the one wanted (docs/ecdh.md §2); no address depends on the digit. Each of the 65 positions is a different part of the table, so the position is public and only the entry within it is secret;
  • the sign is applied by selecting between y and 2p − y under a mask, never by a branch;
  • a zero digit adds a dummy, the position’s first entry (16ⁱ·G, a point of the curve), and keeps the old sum under a mask: the same operations whatever the digit, and never zero operands. Corrected in review: the first version added an all-zero entry. That was the same instructions, but zero operands are what docs/ecdh.md §3 measured leaking through data-dependent timing (|t| = 16 on the M4); a scalar whose digits are all zero but the top one is now one of the timing tests (§8.3).
  • the formulas are complete, so a sum equal to the entry, or the point at infinity, needs no case.

The table is generated by scripts/p256_tables.py from Python’s integers and checked against OpenSSL’s scalar multiplication (552 points, --check); the file’s currency is a test.

7. Verification

Public data, so variable time is allowed. u1·G + u2·Q with one doubling chain (Shamir), wNAF: u1 in width 7 against a table of the generator’s odd multiples 1G, 3G, …, 63G (32 affine points, the second static; ≈ 1/8 of the bits nonzero, mixed additions); u2 in width 5 against 1Q, 3Q, …, 15Q built per call (7 additions and a doubling). Jacobian coordinates with the doubling of dbl-2001-b (8 multiplications), add-2007-bl and madd-2007-bl, and the cases they do not cover (an equal pair, an opposite pair) handled by the caller of the formula, as std.ecdsa did; whether a lazy value is zero modulo p is asked of its reduced form, whether the sum is the point at infinity is a flag. About 256 doublings and 75 additions, against 256 and 192 before. The final check has no inversion: x = X/Z² equals r modulo n when X = r·Z², or X = (r+n)·Z² when r + n < p (a case with probability 2^-128 that no real signature reaches, so check_x and vectors test it on its own). The inversion mod n for u1 and u2 stays. P-384 verification is unchanged: it stays on std.bigmod (§10).


8. Constant time, and the gates

8.1 The argument, function by function

FunctionSecretWhy it is constant time
p256_kernels.mul_*, sqr_*, add, sub_Kthe operandsstraight-line, every loop unrolled: no branch, no index
p256_kernels.canon_*the valuethe borrow over ten limbs, then the subtraction under a mask through value_barrier
p256.load, storea scalar’s bytesthe limb a byte lands in, and whether it straddles two, depend on its position; no range check (the caller made a constant-time one)
p256.invert, sinvert (pow)the operandthe exponent p − 2 or n − 2 is public; a window of zero is skipped because the exponent’s is zero
p256.is_zerothe valueevery limb ORed, one branch on the answer (it used to stop at the first nonzero limb; affine asks of a secret’s Z)
p256_pt.select, multiplythe scalaras docs/ecdh.md §2: all sixteen entries read under masks, complete formulas
p256_pt.recode, comb_multiplyk§6
ecdsa_sign.finishd, kload, sto_mont, sinvert, smul, sadd as above; is_zero of r and s is on public values (docs/ecdsa-sign.md §2.1)
p256_vf.*nonepublic data; nothing here is constant time

What stays variable time, all of it on public values, is what docs/ecdh.md §2 listed: the peer’s point checks, the infinity test of the result (which a valid input never reaches), the exponents, the scalar range check’s answer.

8.2 The audit of the x86-64 object

scripts/chacha20_branches.py over the objects of tests/programs/ecdh_timing.cho and ecdsa_sign_timing.cho (LLVM, on gram), every non-trap conditional jump read in the disassembly (scripts/p256_ctx.py prints each with the instructions before it): 69 non-trap jumps in all (ecdh.o) and every one read. None tests a secret.

  • Loop counters, lengths and the code of a call (cmp $K,%reg against 8, 20, 30, 65, 0x40, a slice’s length): select, multiply, comb_multiply, recode, base_multiply, point_multiply, store, load, wipe, check_p256.
  • p256.load and p256.store’s cmp $0x15,%rcx; jb: off > 20, a byte’s position in its limb.
  • p256.pow: the started flag, scalar (which field), and nib != 0: all of the public exponent.
  • comb_multiply’s one test %cl,%cl; je is not a branch on a digit: cl is setb/setb/and of two pointer comparisons, the overlap test LLVM makes before it vectorises the masked OR loop (the entry in work against the row of the static). The mask in the loop is and/or on xmm registers.
  • cmovb, cmovne, cmovae, cmovle: max(len - 0x370, 0) and similar lengths, the hexadecimal digit of a curve constant, and check_p256’s answer of in_range (a public result of a constant-time comparison, as docs/ecdh.md §3).
  • affine’s cmpq $0x0,...; jne chain was is_zero stopping at the first nonzero limb of a secret’s Z: a leak of the position of the first nonzero limb, at probability 2^-28 a limb. It is now an OR of all ten limbs and one branch on the answer (§8.1).
  • p256_kernels.* (mul, sqr, add, sub_K, canon): no conditional jump except the overflow and bounds checks to ud2; canon_* has none and its mask is value_barrier’s.
  • ecdsa_sign.finish: is_zero of r and of s (public once sent) and store’s loop; sign: the lengths, the attempt counter and the codes of public checks.

Re-read after the review’s fixes (commit 3b71b529, ecdh_timing.cho and ecdsa_sign_timing.cho again): comb_multiply now has five non-trap jumps: the counters of its three loops (cmp $0x8, $0x14, $0xf0, then the position), and the overlap test, now test %sil,%sil after the same two setb and an and. The dummy’s selection, eq_mask(j + 1, mag) | eq_mask(j, 0) & eq_mask(mag, 0), compiled to lea/and/or/sar, no branch; the one test %cl,%cl; jne beside it goes to ud2 (a hoisted overflow check, seto). The length checks added to base_multiply, point_multiply and finish compare lengths. finish and sign otherwise as above; sign’s new test $K,%r9; jne is the wipe loop’s remainder (it now zeroes all of work).

8.3 Timing: dudect on gram

Which code was timed. The branch was squashed to two commits before merging, because scripts/history.py --explain (the stability plateau) replays every revision of every std/ file and the development’s intermediate ones would not read today. The commits the evidence names (3b71b529 timed, d0b35f70 and 0c5e1930 mutated) are kept on the branch p256-faster-unsquashed; the blobs of every file they timed and mutated (std/p256*.cho, ecdh, ecdsa, ecdsa_sign, bigmod, hmac, crypto, and tests/programs/ecdh_timing.cho and ecdsa_sign_timing.cho) are byte for byte this commit’s: git diff 3b71b529 <head> -- <file> is empty for each, and p256_kernels.cho is blob 258095cda311, p256_pt.cho 320680bcc0ba, ecdsa_sign.cho b643f7c30b13.

The runs, raw: tests/evidence/p256/raw_public_key.txt, raw_shared.txt and raw_sign.txt, each with its header: the commit timed (3b71b529; git diff 3b71b529 HEAD -- std tests/programs is empty), the machine, the governor, and the load at its start and end. Intel Core i7-1260P, Linux x86-64, LLVM backend, rdtscp; taskset -c 2-3 (one physical core, both of its threads, for the script and the program it runs); the governor of cpu2 and cpu3 set to performance for the runs and back to powersave after. The machine was shared with other jobs on its other cores: load average 4.3 to 8.0 during the runs (the headers have each). The classes are interleaved within each batch, so what that load does to the time it does to both; it is why the medians move between tests run at different times. Fixed against random, 10^6 measurements a test, batches of 50,000. The gate is |t| < 4.5.

FunctionTestMeasurementsMedian (TSC cycles)max |t|
ecdh.public_key P-256 (the table)scalar 1 against random1,000,000181,9642.21
a fixed scalar against random1,000,000182,4811.85
scalar 0x88..88 (every digit 8) against random1,000,000325,3712.21
scalar n − 1 (every digit carried) against random1,000,000306,2052.32
scalar 16^63 (every digit zero but the top: the dummy added 64 times, the sum at infinity throughout) against random1,000,000330,2972.18
ecdh.shared P-256 (the ladder)scalar 1 against random1,000,000978,5551.73
a fixed scalar against random1,000,000785,4861.78
scalar 16^63 against random1,000,000799,8121.70
ecdsa_sign.signa fixed key against random1,000,000253,9812.40
the key 1 against random1,000,000260,7152.25

All ten pass. Corrected in review: the first runs of this section were at the powersave governor, of code two commits older than the head they were reported for (a commit had since changed p256.init and the constants’ layout), and their output was not committed; their figures (all |t| ≤ 2.07) are replaced by these. Not timed: Cranelift, and Apple silicon on either backend. docs/tls-assurance.md §6.1 found scalar 1 failing on the M4 for both backends through the CPU’s data-dependent timing (Arm’s DIT bit off); nothing here changes that, and the new P-256 code was not run there.

8.4 Mutants

python3 scripts/p256_mutants.py, on gram (Linux x86-64, taskset -c 2,3, pyca/cryptography 46 and openssl 3.0.13), at commit d0b35f70 (the code of the head; the commits since change the driver, the vectors and documents). The raw output is tests/evidence/p256/mutants.txt. 125 mutants: 116 killed, 9 survived, 0 failed to build.

  • Eight survivors are argued equivalent in EQUIVALENT (each argument is printed beside it in the output): (v + 8) >> 4 in the recoding; the infinity test of affine (no valid input reaches it); the wNAF window threshold (the window is odd, the threshold even); the early carry (it lands in bits that are skipped); load_q’s k/2 for (k-1)/2 (k is odd); u1’s digits at width 5 (still in the table); the fast path skipped (the old path agrees, which is itself a check); sfrom_mont unreduced (n·G = O).
  • One was a test gap: sign zeroing only the table’s 980 words of work. The driver’s check looked at those words only; it now checks all of work after sign, and the mutant run alone is killed (tests/evidence/p256/mutants_rerun.txt, commit 0c5e1930).

Corrected in review: this section said the run was in the arm64 container and on gram both. The first full run (127 mutants, 111 killed) was in the container; it found the gaps since closed (the top limb of is_zero, the wipes, an equivalent straddle offset I had written); its output was not kept. The run above is the one this section reports, and the review’s “r not reduced mod n” is now killed by ecdsa_sign.finish called with x in [n, p).

8.5 Vectors and differentials

  • Every existing vector, byte for byte: RFC 6979 A.2.5 on both backends; Wycheproof (ecdh_secp256r1_ecpoint, 355 cases; ECDSA, 1,370 valid and 1,728 invalid); NIST CAVP KAS and SigVer; the key-parser files; the x509 matrix; the TLS suites of cargo test.
  • tests/vectors/p256.txt (1,823 cases, both backends, in cargo test): the field and group-order arithmetic against Python’s integers; k·G of 100-odd edge and random scalars (8 and 9 in every nibble, n − 1, 2^255, the refusals of 0, n and 2^256 − 1); the wNAF digit for digit; signatures crafted for chosen u1 and u2 under the keys 1, 2, n − 1 and a random one, so that Shamir’s trick adds equal and opposite points and reaches infinity (-40), with u1 = 0 too; r and s at the edges of the limb representation; the final check of r + n; the four kernels at the worst case of their accumulators; ecdsa_sign.finish with x in [n, p), which no real k·G reaches; every new public entry point given wrong lengths, which must answer its code and not trap.
  • Python, uncapped (scripts/p256_field_differential.py <driver> 20000): 276,671 cases on LLVM, 41,693 on Cranelift, 0 differences.
  • OpenSSL on gram (Linux x86-64, pyca/cryptography 46, OpenSSL 3.5.5 behind it and openssl 3.0.13 on the command line): ecdsa_sign_differential.py openssl 10,000 signatures accepted by both, 10,000 flipped refused by both, 0 differences; reference (RFC 6979 in Python, byte for byte) 10,000, 0 differences; keys 100 keys, 600 checks, 0 differences; ecdsa_differential.py openssl 4,000, registers 20,000, 0 differences; ecdh_differential.py 1,000 key pairs a curve, 4,200 checks, 0 differences.

8.6 What it found

  • A static’s callee is emitted into every program (§2).
  • The lazy zero: a lazy element has several representations of zero (0, p, 2p), so bigmod’s is_zero idiom is wrong on it; the Jacobian formulas ask the reduced form, and a mutant that skipped the reduction (sfrom_mont unreduced) survived every vector, including the digests 0 and n: the value n instead of 0 reaches the wNAF, which turned out harmless (n·G is the point at infinity) and is now argued equivalent.
  • The equal mutants: a byte’s offset in a limb is a multiple of 4, so “straddles when the offset is above 20” and “above 21” are the same test (an equivalent mutant I wrote and the run reported).
  • Two lines of the first design were wrong: the duplication test found eq_mask and copy_point copied between files, and the formatting test found that cancho fmt drops parentheses the generators had written.

9. Cost

python3 scripts/p256_bench.py <after> --vs <before> ...: each driver run alternately five times for every operation, the best of five of each, LLVM backend, one core, minus the run of one round. <before> is tests/programs/p256_profile_before.cho built by main’s compiler (the worktree origin/main at the base of this PR), <after> is p256_profile.cho built by this branch’s.

Apple M4 Max, macOS 26, native arm64 (load average 12.7 to 13.5, other jobs building; three passes, the median shown, passes within 3%):

Operationbeforeafter
field multiplication mod p117 ns20 ns5.8x
field squaring mod p117 ns19 ns6.1x
addition + subtraction mod p36 ns13 ns2.7x
inversion mod p44.4 µs5.8 µs7.7x
inversion mod n51.9 µs10.0 µs5.2x
multiplication mod n112 ns32 ns3.5x
ecdh.public_key (k·G)768 µs34.6 µs22x
ecdh.shared (k·P)755 µs138 µs5.5x
ecdsa_sign.sign820 µs59.2 µs13.8x
ecdsa_sign.sign_checked1,644 µs163.6 µs10.1x
ecdsa.verify_raw814 µs105.8 µs7.7x

Intel Core i7-1260P (gram), Linux x86-64, performance core 2 (taskset -c 2): the machine was shared with other jobs the whole time (load average 6 to 13, the core at 0.5 to 1.8 GHz under powersave, on a sibling thread busy with someone’s build), so the absolute times of three passes ranged over a factor of 2 to 4. The ratio of each interleaved pair is steady, and is what is reliable; the times are the fastest of the three passes:

Operationbeforeafterratio (range of the three passes)
field multiplication mod p255 ns64 ns3.8 to 4.0x
ecdh.public_key1,575 µs84 µs16.7 to 18.6x
ecdh.shared2,052 µs577 µs3.6 to 4.2x
ecdsa_sign.sign1,716 µs124 µs13.6 to 14.5x
ecdsa_sign.sign_checked3,508 µs379 µs8.6 to 9.3x
ecdsa.verify_raw1,742 µs258 µs5.9 to 7.0x

An earlier, quieter minute (load 5.8, docs/p256-fast.md §1’s first table) gave main at 1,143 µs for public_key, 1,240 µs for sign and 1,231 µs for verify; the prototype multiplication on that machine was 44 ns against 167 ns generic. So an unloaded i7 signs in about a tenth of 1.24 ms by the ratio, which is not a measurement. Not measured on a quiet core: it was not available.

Docker arm64 (the image lexsys-hooks-env, a 6-vCPU Linux VM on the same M4 Max): main before, measured early (load 4.3): public_key 757 µs, shared 746, sign 808, sign_checked 1,631, verify 848, multiplication 114 ns, inversions 44.7 and 50.1 µs. The after was not measured: the VM’s disk filled (other jobs’ images; 63 GB of 90, 36 GB of volumes) while the mutant run was in it, and no container could start. What ran in it before that: the whole cargo test --workspace --no-fail-fast of this branch, Linux aarch64, 627 passed and 2 failed (project::a_compiler_other_than_the_one_named_is_refused and vcs_remote::a_lock_holds_a_commit_and_never_a_name, which need a git checkout the container does not have: the worktree’s .git points outside it); every p256, ecdh, ecdsa and tls test passed. The M4 above is the same CPU natively.

For reference, OpenSSL 3.0.13 on docs/ecdsa-sign.md §7’s i7: ecdsap256 signs in 23 µs and docs/ecdsa.md §5.4’s Xeon verifies in 75 µs. Against those, on the M4 (not the same machine): signing is 2.6 times OpenSSL’s time, down from 36 times (a different machine, so indicative).

The check after signing (docs/tls-server.md §3.3), re-measured. sign_checked less sign is 104 µs on the M4: the check is now 64% of the combined cost, where it was 49% (it was equal to the signature on the i7). Re-measured after the review’s fixes (sign now zeroes all of work, 9,593 words; the comb’s dummy is a point): sign 58.9 µs, sign_checked 164 µs, public_key 35.5 µs, the same within the noise. A full handshake that signs and checks now spends 164 µs there, where it spent 1.64 ms. The check stays, as the design decided: a fault during signing leaks the key, 104 µs is 0.1 ms of a handshake whose other costs are the TLS engine’s, and it is verification alone that is now the larger half.

10. Not done, fell short, not verified

  • Carry-free addition and subtraction (§3): at most 10% of the ladder; it needs the bounds script to track limb widths.
  • P-384 stays on std.bigmod: its field would be 15 limbs of 28 bits (225 products) and a table twice the size; the kernels are generated, so it is a generator parameter, but no program here signs or exchanges on P-384 in a hot path (docs/tls-server.md §6: 6 ms a handshake is the P-384 client’s choice, not the server’s). Measured and decided in §12: P-384 verification is in a client’s hot path after all (7 of the 31 signatures of the 14 saved real chains), at 9 to 16 times OpenSSL’s cost; it is built on #381’s field, not on this one.
  • Against OpenSSL: signing is still about 2.5 times its time (23 µs on the i7 of docs/ecdsa-sign.md §7 against §9’s figure), and the ladder (shared) about five times; both are field-multiplication bound at 20 ns, and OpenSSL’s are assembly with a 64-bit multiply. Since #381 (§13.4): on gram, in cycles against OpenSSL 3.5.5 on the same core, signing 3.2 times, shared 3.2 times, a verification 1.8 times.
  • Not timing-tested: p256_vf (public data) and the key parser (as before). Cranelift was not timed (docs/ecdh.md §3 found its P-384 noisy; P-256 was not re-run on it here).
  • Not independently reviewed (#209), and not run: the server interop matrix of docs/tls-server.md §10.2 needs the container of scripts/interop/server.Dockerfile with Go, wolfSSL and mosquitto; CI’s tls-assurance job runs it (see the PR).

11. A key’s table: the server’s own key (B2) and the trust anchors (B3)

#380 reopened after #397 for what of its scope was left; docs/tls-performance.md §10.8 ranked these two levers at about 7.5 points of a full server handshake (B2) and 4.7 of a client’s (B3). The design was written before the code, its sizes chosen by the measurement of §11.2; §11.4 to §11.7 are as built.

11.1 The question

A verification is u1·G + u2·Q. §7 has G’s odd multiples in a static and builds 1Q, 3Q, …, 15Q on each call, and both share one chain of 256 doublings, which is most of the time: a doubling is 8 multiplications, and there are 256 of them against about 37 mixed additions for u1 and 43 Jacobian ones for u2. Two keys verify signature after signature:

  • a TLS server’s own key (B2): every signature the server makes is verified under its public point before it is sent (docs/tls-server.md §3.3), a quarter of a full server handshake (docs/tls-performance.md §10.4);
  • a trust anchor’s key (B3): the last signature of every chain that ends at a root is verified under the root’s key.

For such a key, a table built once can do for Q what p256_comb.odd does for G.

11.2 The design, and what chose it

Split each scalar’s wNAF into runs. The 257 width-7 wNAF digits of u2 are cut into m runs of L positions (L·m ≥ 257). Digit d at position L·j + i stands for d·2^i·(2^(L·j)·Q), so with the odd multiples 1 to 63 of each 2^(L·j)·Q in a table, the m runs share one chain of L − 1 doublings; u1 is split the same way against the generator’s runs. The additions do not change in number (one in eight positions has a digit, in each scalar); the doublings fall from 256 to L − 1. The table is 32·m affine points.

docs/tls-performance.md §10.8 had assumed two combs (§6’s table, one for G and one for Q: 65 additions each and no doubling) and estimated 1.44 times. A comb has a digit at every position of 4 bits, so it adds 61 times where wNAF adds 32, and its table is 520 points (83 KB). Measured on the M4 (scripts/p256_bench.py, the same driver, load average about 4.5), for runs of 2, 4 and 8 (the generator’s runs a static of the same shape, the verification otherwise the same):

runs (m × L)the key’s tablea verification with itbuilding the table
none (§7)—104.9 µs—
2 × 1295,185 bytes64.4 µs78 µs
4 × 6510,305 bytes44.4 to 48.8 µs151 to 157 µs
8 × 3320,545 bytes40.8 to 41.0 µs276 µs

Four runs of 65. Eight runs save another 4 to 8 µs for twice the memory and twice the build; two runs lose 16 to 20 µs. What four runs leave is mostly fixed: the inverse of s modulo n (10 µs), 64 mixed additions and 64 doublings.

  • The key’s table (std.p256_vf.build_table): the point it was built from (65 bytes, 04 || x || y), then entry 32·j + k = (2k + 1)·2^(65·j)·Q for j in 0..3 and k in 0..31, x then y, each in Montgomery form, fully reduced, as ten limbs of four bytes: 10,305 bytes. Bytes, not words, so that it can live in the byte slices where the engine keeps an identity and a trust store (§11.3), and four bytes a limb rather than 32 big-endian bytes a coordinate so that reading an entry is ten loads, not a conversion and a multiplication (an entry is read about 32 times a verification).
  • The generator’s runs: p256_comb.odd now holds (2k + 1)·2^(65·j)·G for the four runs, 128 points, 2,560 words (20 KB of read-only data where it was 5 KB); run 0 is the table §7 reads, at the same words, so verify is unchanged.
  • Building it (run): each run’s base is the last one doubled 65 times; its odd multiples by additions of twice the base, in Jacobian coordinates; then all 32 made affine with one inversion (Montgomery’s trick: the running products of the Z’s, one inverse, each Z’s inverse peeled off it). Four inversions a table. scripts/p256_bounds.py checks the new formulas (run, put_limbs, load_tab, load_gj, build_table) against the kernels’ limits as it checks the others.
  • Verifying against it: the table is given in the point’s place, to p256_vf.verify, ecdsa.verify_raw and verify_der (P-256) and ecdsa_sign.sign_checked, which recognise it by its length; it begins with the point, so the callers keep their signatures and a table cannot be given for another key. verify’s checks (prepare, now shared: r, s, the digest, the range and curve equation of the point in the table’s first 65 bytes), the two wNAFs at width 7, then one chain of 64 doublings over the eight runs, mixed additions throughout, and §7’s final check without an inversion.
  • Why in the point’s place, not beside it. The first version had verify_tabled(…, point, table, …) and _tabled twins of verify_raw, verify_der and sign_checked. scripts/history.py --explain (the stability plateau) checks every past revision of every std/ file beside today’s others, and a new function of one module that another calls makes the first module’s old revisions unreadable: three revisions of std/ecdsa.cho and std/p256_vf.cho failed not-a-function, past the plateau’s recorded count. With the table in the point’s place, no std module calls a function another module did not always have; build_table, table_len and table_for are called by packages/ and tests/, which the plateau does not replay.

Public data only. A table is computed from the public point, by doublings and additions of it, never from the private key (a server holds the key, and k·(2^(65·j)·G) through the comb would have been the shorter build; it would also have been a secret path for nothing). Verification is on public data, variable time, as §7 is; the server’s check verifies the signature it is about to send, as before. Nothing secret enters std.p256_vf.

A table is never used for another key, because it is the key: its first 65 bytes are the point, checked (range, curve equation) on every call as a point given alone is. Its entries are trusted as build_table wrote them, as a key’s bytes are; a refused build leaves zeros, which begin with no point, and a caller that keeps a table beside a key gives it only when table_for(table, point) says it is that point’s (tls_identity.check_key). The answers are verify’s for every point and every table build_table made; §11.6 is how that is checked.

11.3 Where the tables live, when they are built, and what cleans them

Both are kept once per engine, per key, never per slot: a slot’s memory (docs/tls-memory.md) does not change.

the server’s identity (B2)a client’s trust anchor (B3)
wheretls_identity’s configuration, the engine’s ids box: each identity’s stride grows by the table, at o_table after o_not_after (docs/tls-server.md §3.5)the trust store, the engine’s roots box: after the roots, one entry a table (docs/x509-verify.md §11)
built bytls_identity.load, so add_identity and replace_identity, from the public point the key parser derived, after every check has passedtls.trust, for each root whose key is P-256, at most 16 (x509_verify.store_tables)
bytes10,305 a slot of the 16, in the configuration’s address space: tls_identity.cfg_len() 314,106 → 478,986; resident only for the identities loaded (a zero-filled box is calloc, docs/tls-memory.md §1)10,313 an anchor (the 7-byte entry header and a kind byte); the system bundle has 3 P-256 roots of 128 (61 RSA-4096, 28 RSA-2048, 35 P-384, 1 P-521): 30,939 bytes; at most 16 × 10,313 = 165,008, inside the store’s 1 MiB room
cost to build151 to 159 µs on the M4, 470 µs on gram (time, the powersave core), once a loadthe same, once a root a trust: about 0.5 ms for the system bundle on the M4
replacedby replace_identity with the new key’s; a refused replacement leaves the old identity and its tableby the next trust, which first zeroes the old store and its tables
clearedtls.close (tls_identity.wipe zeroes the configuration)tls.close frees the box (public data; the roots were never cleared either)
tls.residuecounts the bytes of the tables of identities not in usecounts every byte of roots past the store and its tables

Measured (docs/tls-memory.md §13, gram): nothing a connection (scripts/tls_memory.py, 50 to 1,000 connections, every phase within the noise of two runs of one build); a process with one identity and the system bundle trusted holds 44 to 48 KiB more, 41 KB of it the tables and the rest the larger binary.

Lazily, for the anchors used, was not built. The store reaches a connection’s feed read-only (tls_client.feed_with(..., store, ...), a public function of the package that tests and fuzz harnesses call directly); building a table the first time an anchor verifies would make it a mutable argument there and in every caller. Building them at trust costs about half a millisecond once for the system bundle, whose three P-256 roots take 31 KB. A bundle of many P-256 roots gets tables for its first 16 (165 KB); the rest verify without one.

11.4 Cost, per operation

gram (Intel Core i7-1260P, performance core 2, taskset -c 2; load average 5.5 to 6.9; kernel.perf_event_paranoid lowered from 4 to 2 for the run and restored): python3 scripts/tls_perf.py table (cycles:u, the least of three passes, a pass whose IPC is impossible for the core dropped as §2.1 says; instructions exact) and tls_perf.py openssl (OpenSSL 3.5.5, cycles a call from time and the run’s cycles over task-clock), on the same core, minutes apart (raw: docs/assets/key-tables/primitives-x86-64.txt):

operationinstructionscyclesOpenSSL cyclesratiobefore (§7)
ecdsa.verify_der (§7)3,488,825688.1k252.6k2.72
ecdsa.verify_der, the key’s table1,543,269326.4k252.6k1.292.72
ecdsa_sign.sign_checked5,118,7091,014.5k338.9k (sign + verify)2.99
ecdsa_sign.sign_checked, the key’s table3,173,192666.5k338.9k1.972.99
ecdsa_sign.sign alone1,632,017327.4k86.3k3.79

The check after a signature (sign_checked less sign) is 687k cycles without the table and 339k with it: 2.03 times cheaper, 2.26 times fewer instructions. A verification against a table costs 1.29 times OpenSSL’s verification of the same key, which builds nothing for it. (An earlier pass at load 3.5 to 4.5, whose output was not kept, counted 686.7k and 310.8k cycles for the two verifications, 971.4k and 636.8k for the two sign_checked, OpenSSL 238.6k and 79.9k.)

M4 (Apple M4 Max, macOS 26, native arm64; load average 8.6 to 11.7, other jobs building; scripts/p256_bench.py, the median of three passes): verify_raw 101.9 µs, with the key’s table 48.4 µs (2.1 times); sign_checked 153.8 µs, with the table 95.4 µs; sign 50.2 µs; build_table 156.9 µs.

The regression guard (benches/tls_baseline.json) has two new rows on both entries, recorded on each entry’s own machine: ecdsa_p256_verify_tabled (1,543,233 instructions on linux-x86_64, 1,007,877 on linux-aarch64) and ecdsa_p256_sign_checked_tabled (3,183,352 and 2,064,554). The other 33 rows were not re-recorded: on unchanged code they count within 0.90 to 1.04 of their baselines on x86-64 and 35 of 35 rows pass on arm64. The figures of this section and §11.5 were measured before the table moved into the point’s place (§11.2); the arithmetic is the same, and the final revision counts 1,543,019 and 3,173,882 instructions for the two rows on x86-64 (1,007,414 and 2,064,993 on arm64), and 6.141M and 8.777M instructions for the server’s and the client’s full handshakes (one round), against 6.127M and 8.767M.

11.5 Cost, per handshake

scripts/tls_handshake_cost.py <bin> server-full client-full --seconds 8 --rounds 2 --cpu 2 --peer-cpu 3 --sudo-perf on gram, this branch (after) and origin/main at its base (before, 3d7e8e9d), alternating, two passes of two rounds each; user and kernel cycles and instructions of the process measured, the least of its four rounds; OpenSSL 3.5.5 in the same passes (load average 3.9 to 5.1, other agents’ benchmarks on cores 0, 1, 4 and 5, so the cycles moved by up to 20% between rounds; the instruction counts by 0.3%):

beforeafterchangeOpenSSL (the same passes)ratio before → after
server, full (tls_echo under s_time -new)1.923M cycles, 8.092M instructions1.467M, 6.127M−23.7% cycles, −24.3% instructionss_server 1.039M / 1.003M; oserv 0.971M / 0.942M1.85 → 1.46 s_server (oserv 1.98 → 1.56)
client, full (tls_many, chain and name verified, under a P-256 test CA)2.743M, 10.717M2.303M, 8.767M−16.0% cycles, −18.2% instructionstconn 1.695M / 1.635M1.62 → 1.41
  • Each saves what its primitive saves: the server’s handshake 1.965M instructions, the check 1.946M (§11.4); the client’s 1.950M, one verification of its chain (the CA’s signature on the leaf, under the anchor’s table; CertificateVerify is under the leaf’s key, which has none).
  • Against docs/tls-performance.md §10.8’s projection: B2 was “7.5 points” of the handshake before A1 and the check 1.44 times cheaper; as built the check is 2.03 times cheaper and the server’s handshake 24% fewer instructions. B3 was “4.7 points”; as built 18% of the client’s instructions. The split-wNAF table beats the two combs the projection assumed, at an eighth of their memory.
  • arm64 (the M4’s Docker VM, lexsys-hooks-env, OpenSSL 3.0.13, CPU time a handshake, CPUs 0 and 1 of the VM, the same script and alternation, the least of four rounds; VM load average 7.2 to 8.2, the Mac busy with other jobs): the server’s full handshake 0.292 → 0.232 ms (−20.5%; s_server 0.192 and 0.189 ms in the same passes: 1.52 → 1.23 times), the client’s 0.422 → 0.364 ms (−13.7%; tconn 0.277 and 0.278: 1.52 → 1.31 times).

11.6 Gates

  • Every vector, byte for byte, through both paths. tests/vectors/p256.txt (now 3,536 cases, both backends; the 1,823 before are unchanged and first): every one of its 611 verification cases again against the key’s table (U), which must answer the same; 320 signatures crafted for the four runs, each through both paths, and 299 of them with r + 1 through the table (u1 and u2 at 2^65, 2^130, 2^195 and their neighbours, under the keys 1, 2, n − 1 and a random one, so that entries of the generator’s and the table’s runs are equal or opposite at one position and the sum passes through infinity); four keys’ tables against Python’s, byte for byte (their SHA-256); the points build_table refuses; every new entry point with wrong lengths. Wycheproof’s 3,098 ECDSA cases and CAVP’s 90 SigVer cases are each run again against the point’s table and must give the same line (conformance/ecdsa.rs). The x509 files (the limbo subset, 223 stores of which 195 give at least one root a table; the 14 saved real chains against the system bundle, whose 3 P-256 roots get one; the OpenSSL matrix; the no-name matrix) are each replayed with the anchors’ tables and must give every recorded answer (conformance/x509_verify.rs). The TLS suites (the liar clients, the differentials, tickets, the pool, fuzz corpora) pass unchanged: the server’s check and the client’s chain now go through the tables in all of them. conformance/key_tables.rs: an identity’s table is its key’s when loaded and the new key’s when replaced, unchanged by a refused replacement, zeros when wiped; a store’s tables follow the store, tls.residue 0.
  • Mutants (scripts/p256_mutants.py, now also over the key tables’ users in packages/x509 and packages/tls, its evidence adding the x509 replays and key_tables): 53 run, the 35 new ones, the 12 of verify (7 of whose code moved into prepare) and 6 older ones on the generator’s tables and the signer’s wipe, on arm64 (lexsys-hooks-env, pyca/cryptography 41, openssl 3.0.13): 49 killed, 4 survived, 0 failed to build, every survivor argued equivalent in EQUIVALENT, each argument printed beside it: u1’s and u2’s digits at width 5 (all in their tables); a refusal after the entries are written (a multiple at infinity, which no point of the curve gives); and a chain’s anchor verified without its table, which changes the cost and not the answer (the tabled replays of every x509 file show the answers are the same; the client handshake of §11.5 measures the cost). Running them found two gaps, both closed before the last run: nothing checked that the check verifies under the identity’s table rather than its point (tls_identity.check_key is now asserted), and nothing checked that table_for refuses a table of another point (asserted with two identities’ tables and points). After the rebase onto #463 (resting areas, whose tls.residue these additions sit beside), the anchors’ and identities’ 9 were run again: 8 killed, the ninth the argued anchor (docs/assets/key-tables/mutants-anchors-identity-rebased-arm64.txt). The 107 others of §8.4 mutate code this change does not touch and were not re-run. Raw output: tests/evidence/p256/mutants_tables.txt.
  • No timing test is needed, and none was run: nothing here touches a secret. sign_checked with a table is sign (unchanged) and then a verification of public data; the tables are built from public points.

11.7 Not done

  • The server’s store for client certificates (tls.trust_clients, docs/tls-server.md §13.3) gets no anchors’ tables; a client certificate’s chain is verified as before. The same store_tables would serve it; it is not in #380’s list.
  • Tables for intermediates, which a client meets again and again (a CA’s issuing certificate), are not kept: an intermediate is sent by the peer and is not known before the handshake.
  • The public Web PKI gains little from B3 today: of the 31 signatures in the 14 saved real chains of docs/x509-verify.md §8.2, 22 are RSA, 7 P-384 and 2 P-256, and no chain ends at a P-256 root. B3 pays for private P-256 CAs (the measured client’s, a service mesh’s); the larger gain for public sites is P-384 (§12).

12. P-384: measured, and decided

Measured. std.ecdsa and std.ecdh on P-384 are still std.bigmod’s registers, generic Montgomery multiplication on 30-bit limbs with checked arithmetic (the multiplication §1.2 measured for P-256, at 13 limbs instead of 9). tests/programs/p384_profile.cho, one operation rounds times, timed by scripts/p256_bench.py --p384 (the time of one is the difference of two runs; the raw files call it bench384.py, the same script before the flag), beside openssl speed ecdsap384 ecdhp384:

P-384M4 (macOS 26, native; OpenSSL 4.0.3), timeratiogram (core 2; OpenSSL 3.5.5), cyclesratio
ecdsa.verify_raw (SHA-384)2,791 µs; OpenSSL 268 µs10.424.0M (68.7M instructions); OpenSSL 1.48M to 2.73M8.8 to 16
ecdh.shared2,917 µs; OpenSSL 169 µs17.321.6M (60.9M instructions); OpenSSL 2.02M10.7
ecdh.public_key2,282 µs11.7M to 18.5M (60.9M instructions)

On gram (docs/assets/key-tables/p384-cycles-x86-64.txt, load average 5 to 6): cancho’s cycles through sudo perf stat of the driver at 21 rounds less 1 round, over 20, the least of three; OpenSSL’s from perf stat of openssl speed -seconds 2, its cycles over its operations, which for ECDSA mixes the 2 s of signing and the 2 s of verifying, so the verification’s share moved with the clock between the phases (1,598 to 2,894 verifications in three runs); ECDH’s was steady. A first pass in time on the same core gave 5,273 against 645 µs (8.2) and 4,724 against 456 µs (10.4) (p384-x86-64.txt is a later, more loaded pass). A P-256 verification is 48 µs with a table and 102 without on the M4: P-384’s is 27 to 58 times dearer.

Where it is paid. Not on the server: its signature is P-256, and it prefers X25519, then P-256, then P-384 for the key exchange (tls_hello.share_group). Not in the client’s key exchange either: the client sends an X25519 share (tls_client), and P-384 only after a HelloRetryRequest that asks for it. In the client’s chain verification, yes: of the 31 signatures of the 14 saved real chains, 7 are P-384 (4 chains: DigiCert’s ECC chain, Apple’s and Cloudflare’s intermediates under P-384 roots, and a Let’s Encrypt chain whose three signatures are all P-384). Such a chain costs a client two or three P-384 verifications, 48M to 72M cycles on gram, where the whole full client handshake of §11.5 is 2.3M cycles: 20 to 30 times the handshake it is part of.

Decided. P-384 verification is worth the design of §4 to §7 and §11: it is the largest cost of a client handshake to a P-384 chain by an order of magnitude. P-384 ECDH is not, on the handshakes measured, and there is no P-384 signing to make faster. It is not built here, because its field belongs to #381: that issue’s scope is “P-256/P-384 field arithmetic, bigmod” on the mul_wide builtins, and it is moving P-256’s field to four 64-bit limbs now. A P-384 field on this document’s 28-bit design would be 15 limbs (225 products a multiplication against §4’s 100, and its own generator and bounds) and would be replaced by #381’s six 64-bit limbs. The rest is this document’s and ports unchanged in shape: wNAF verification (§7) with the generator’s runs, and the key tables (§11) for P-384 anchors, which are 35 of the system bundle’s 128. #380 stays open for it; #381’s std.bigmod on mul_wide (3 to 3.5 times on mont_mul, docs/tls-performance.md §10.8, M1) is the interim gain. No secret path changed, so there is no P-384 timing test to run.

13. The field on mul_wide (#381)

#381 (of #378) asked for P-256’s field on the wide-multiply builtins of wide-multiply.md (#393), and for the choice between four 64-bit limbs (#397’s estimate: 16 mul_wide a multiplication against 100 products, 10 a squaring against 55, at the cost of the lazy reduction’s headroom) and five limbs to be made from measurements. §13.1 is what was measured first, §13.2 the design that follows, §13.3 its gates, §13.4 its cost. Machine names and loads as §1; gram figures are cycles (cpu_core/cycles/u through sudo perf stat, CPU 0 pinned, the least of five runs a driver, two drivers alternating) and instructions (exact), because its clock moves under powersave and other agents’ jobs; kernel.perf_event_paranoid was not changed (it reads 4; perf ran under sudo).

13.1 Measured before choosing

Three kernels for p, each p256_bench.py’s m, q and p loops on gram: today’s 28-bit field (main), five limbs of 56 bits inside the kernels (each packing two of the 28-bit limbs, so R stays 2^280 and nothing else changes), and a prototype of four 64-bit limbs with R = 2^256 (mul_wide product scanning into a three-word accumulator, four reduction rounds, the final conditional subtraction; add and subtract fully reduced, as R = 2^256 forces: there is no headroom above p). The prototype was checked against Python on chains of 1,000 operations; it is not in the tree, its figures are assets/wide-multiply/381/prototype_4x64.txt.

kernel (x86-64, gram)10 × 28 bits (main)5 × 56 bits4 × 64 bits, prototype
multiplication mod p158 cycles, 782 instructions98, 45186, 332
squaring mod p114, 57381, 345 (dedicated)84, 331 (a multiplication)
an addition and a subtraction75, 39036, 22030, 117 (each reduced)

On the M4 the first run (load 4.7; later ones, at load 8 to 17, moved by up to two times and are not used) gave 16 ns for the 5 × 56 multiplication (before §13.2’s wrapping_* additions and length test) against 14 for 4 × 64, 14 against 15 for the squaring, and 13 against 9 for the pair.

The choice: five limbs of 56 bits. Four 64-bit limbs were 12% cheaper on the multiplication and 17% on an addition and a subtraction, which on the ladder’s 4,810 multiplications and 7,642 additions and subtractions (§1.1) is about 10 to 15% of an operation (arithmetic). For it they change the representation every caller reads: p256_vf’s wNAF reads a scalar’s bits as 28-bit limbs, the comb’s 10,400 words are 28-bit limbs in R = 2^280, and load, store, below and pow’s nibbles are written for them. The formulas of §4.4 add and subtract without reducing, up to 66 p, which R = 2^256 cannot hold, so every addition would reduce and p256_bounds.py’s proof would be replaced. Those files are being changed by #380 at the same time. Five 56-bit limbs keep all of it: the change is inside std/p256_kernels.cho and its generator. #397’s statement that four 64-bit limbs would be confined to the generator was wrong for this reason (corrected in §4.2).

13.2 As built

scripts/p256_gen.py writes the four Montgomery kernels (mul_p, sqr_p, mul_n, sqr_n) on the builtins:

  • Packing. Each pair of 28-bit limbs of an input becomes one 56-bit limb (xs[2i] | xs[2i + 1] << 28); the five limbs of the answer are split back into ten. An element in work is what it was.
  • The product, column by column. Every product of two 56-bit limbs is taken whole with mul_wide and added into a two-word accumulator (mac: mul_wide, then add_carry), 25 products a multiplication, 15 a squaring (the cross terms against a doubled limb). A column’s carry is the accumulator shifted right by 56 (shr56, one funnel shift).
  • The reduction, in radix 2^56: five steps, the same R = 2^280. p is -1 modulo 2^96, so -p⁻¹ is 1 modulo 2^56 and the quotient digit is the column’s low limb, as in §4.2; p’s 56-bit limbs are 2^56 − 1, 2^40 − 1, 0, 2^24 and 2^32 − 1, so the first costs nothing and the others are three macs a step. n’s are general: n’ modulo 2^56 is one wrapping_mul, then five macs a step. The Montgomery quotient below R is unique, so every answer is the 28-bit kernels’ answer word for word.
  • The bounds, proved by the generator for every column at the inputs’ largest limbs (the header of the generated file): the largest accumulator is 2^114.92 (limit 2^127; mul_n, column 4), the largest carry between columns 2^58.92 (limit 2^63). A wrong bound is a failed generation. §4.3’s invariants are unchanged, so p256_bounds.py needs nothing new and still closes.
  • add and sub_K stay on 28-bit limbs but are wrapping_*: they were checked arithmetic, a test and a branch for each of their 20 additions. The generator checks every limb’s sum before its carry: under 2^29.99 (limit 2^31).
  • One test of the slices’ lengths a kernel, if len(xs) < 10 || ... { return -1; }, which no call can make true (a slice is ten words or the slicing trapped). It tells LLVM what it could not prove from w[x..x + 10], and the bounds checks of the kernel’s thirty constant indices go: 33 compares in add before, 6 after (arm64 object).

Tried, measured, not kept: the reduction’s constants written as shifts instead of mac (94 cycles against 98 for a multiplication on gram, 75 against 81 for a squaring, but 36 more instructions, and shared within 0.3%); an addition with one parallel carry round instead of a chain (slower on the M4, 11 ns against 8 for the pair).

13.3 Gates

  • Vectors, byte for byte, both backends: tests/vectors/p256.txt (1,823 cases, the four kernels at their worst-case limbs among them), RFC 6979 A.2.5, Wycheproof ECDH and ECDSA, NIST CAVP KAS and SigVer, the key files, the TLS suites of cargo test.
  • Differentials (arm64 Linux, OpenSSL 3.0.13; wide-multiply.md §12.2): the field against Python 444,704 cases (LLVM) and 69,476 (Cranelift); signing against OpenSSL 10,000 and against RFC 6979 in Python 10,000; keys 600 checks; verification 4,000 a curve; ECDH 1,000 key pairs a curve. 0 differences.
  • Bounds: the generator’s (§13.2); scripts/p256_bounds.py closes for every formula, unchanged.
  • Mutants: scripts/p256_mutants.py, its kernel mutants rewritten for the new code (a cross product dropped, the quotient digit’s carry, each special limb of p off, a limb packed at 27 bits, an output limb unmasked, the top limb dropped, the accumulator’s carry, the column shift at 55 bits and unmasked, the squaring’s doubling, a square and a cross term left out, n’ off by one, a limb of n, the quotient unmasked, the carries of add and sub_4, and the unchanged canon_* and constants mutants): 36 selected, 36 killed at the head (assets/wide-multiply/381/p256_mutants.txt); after the rebase onto §11’s tables (#465), the whole script: 165, 155 killed, 10 surviving, each one EQUIVALENT argues, none on the kernels (p256_mutants_full_rebased.txt).
  • Constant time: dudect on gram at 10^6 measurements a test, the ten tests of §8.3, all below 4.5 (largest 2.29; the table is wide-multiply.md §12.3). The branch audit of §8.2 repeated over the kernels: each has exactly one conditional jump that is not to a trap, the never-taken test of the slices’ lengths (cmp $0xa, setge, je), and canon_* none (assets/wide-multiply/381/branches_p256.txt).

13.4 Cost

gram (x86-64), cycles and instructions as in the note above §13.1, the least of six runs in two sessions (load average 4 to 7; the branch’s kernels at b2651fea and at the head are the same object code); raw: assets/wide-multiply/381/p256_cycles.txt:

operationmainbranchgain (cycles; instructions)
field multiplication mod p158 cycles, 782 instructions99, 4511.60; 1.73
field squaring mod p113, 57380, 3451.41; 1.66
multiplication mod n174, 827130, 5261.34; 1.57
an addition and a subtraction73, 39034, 2202.15; 1.77
inversion mod p34,950, 179,24824,936, 106,5661.40; 1.68
inversion mod n49,534, 212,06038,764, 141,3951.28; 1.50
ecdh.public_key (k·G)233,437, 1,197,041141,568, 713,5291.65; 1.68
ecdh.shared (k·P)980,090, 5,002,519567,604, 2,912,4701.73; 1.72
ecdsa_sign.sign326,959, 1,632,023224,736, 1,075,6601.45; 1.52
ecdsa_sign.sign_checked1,007,407, 5,062,738646,106, 3,101,3081.56; 1.63
ecdsa.verify_raw682,396, 3,431,653416,757, 2,026,5871.64; 1.69

Against OpenSSL 3.5.5 on the same core (openssl speed, cycles from perf stat, openssl_gram.txt): ECDH 176,845 cycles, so shared is 3.21 times it (5.54 on main); signing 70,906, 3.17 times (4.61); verification 233,829, 1.78 times (2.92).

Apple M4 Max, macOS, scripts/p256_bench.py <branch> --vs <main> (time, best of five alternating; load average 5.4; m4_p256.txt):

operationmainbranch
field multiplication mod p19 ns14 ns1.3x
field squaring mod p17 ns12 ns1.3x
an addition and a subtraction12 ns7 ns1.7x
inversion mod p / mod n5.6 / 10.0 µs4.3 / 7.6 µs1.3x
ecdh.public_key34.7 µs22.5 µs1.5x
ecdh.shared138.4 µs97.0 µs1.4x
ecdsa_sign.sign52.7 µs41.2 µs1.3x
ecdsa_sign.sign_checked159.7 µs113.6 µs1.4x
ecdsa.verify_raw105.2 µs71.7 µs1.5x

The M4 gains less than x86-64: its old kernel’s 100 multiply-adds were already cheap there (§1.2: 23 ns against 44 on the i7), and its new one’s mul and umulh are two instructions a product where x86-64’s mul is one. In a TLS handshake (tls-performance.md §10.15) the server’s full handshake is 19.9% cheaper on x86-64 and 14.3% on arm64.

Not done: mulx and adcx/adox behind a CPU test (the baseline x86-64 target has no BMI2; wide-multiply.md §10.6’s first item); a dedicated reduction for n’s form; four 64-bit limbs, which would also need p256_vf, the comb and the lazy formulas changed (§13.1).

Hardware AES and carry-less multiply: two builtins, and a way to ask whether the CPU has them

Status: design, decided; the DIT bit and the builtins built. #382 (docs/gcm-wide.md) retired ghash_update (kept for programs that call it; std uses ghash_powers, gcm_tag and gcm_tag_diff instead), added aes_ctr32, and grew the hardware part of a prepared key from 256 to 368 bytes; where this document describes those, the notes marked #382 say what is true now. §9’s five answers were accepted as proposed (2026-10-06), and the four PRs of §10 follow in that order: the DIT bit (§6) and the builtins with their LLVM lowering (§3 to §5, as built below) are done, and so are per-key caching (§6) and the std change (§8.1). docs/tls-parity.md §3.1 measured AES-GCM at about 170 times slower than OpenSSL’s, and said the gap is the instructions cancho cannot emit. This document says what it would take to emit them, what is measured so far, and what is not. Where a later PR finds a claim here false, that PR corrects it here, in place.


1. What is asked, and why

std.aes and std.gcm are constant-time in software: bitsliced AES (BearSSL’s aes_ct) and GHASH by masked integer multiplies. That is the safe software choice, and it is slow (docs/tls-parity.md §3.1, one core of an x86-64 Xeon):

MessageAES-128-GCM here (LLVM)openssl speed -evp aes-128-gcm
16,384 bytes32.6 MB/s5,546 MB/s
64 bytes12.8 MB/s1,356 MB/s

OpenSSL uses AES-NI and PCLMULQDQ on x86-64. The same operations exist on aarch64 (AESE, AESMC, PMULL), which is the other target here. They are both fast and, by design, take a time independent of their operands. A 16 KiB TLS record costs about half a millisecond to seal here, and a client that is offered AES-GCM only pays that on every record.

Not asked for: a vector type, SIMD for ChaCha20 (its gap to OpenSSL is 17 times, and it needs vectors, not special instructions: docs/chacha20.md §6), or any call into C. #210’s gate (docs/tls-pure.md §9, gate 2) needs the pure backend to have no Ffi; the builtins here are pure ([]), as value_barrier is.

2. What is measured, and what it decides

Each result is from the machine named. The rest of this document is a design built on them.

Cranelift cannot emit them. Neither cranelift-codegen 0.121.2 (the version pinned in crates/cancho-codegen/Cargo.toml) nor 0.132.0 mentions aesenc, aesdec, pclmulqdq, aese, aesmc or pmull64 anywhere in src/ or meta/. So on the Cranelift backend the builtins have no lowering, and §5 gives them an answer that keeps today’s code.

LLVM can, and only with the target feature on. Compiled with clang -O2 -c from hand-written IR on an Apple M4 Max (Homebrew’s LLVM, targets named with -target):

TargetFeaturesResult
aarch64-unknown-linux-gnubaselinefails: Cannot select: AArch64ISD::PMULL
aarch64-unknown-linux-gnu+aesaese, aesmc, pmull
x86_64-unknown-linux-gnubaselinefails: Cannot select: intrinsic llvm.x86.pclmulqdq
x86_64-unknown-linux-gnu+aes, +pclmulaesenc, aesenclast, pclmulqdq

The intrinsics used were llvm.aarch64.crypto.aese, llvm.aarch64.crypto.aesmc, llvm.aarch64.neon.pmull64, llvm.x86.aesni.aesenc, llvm.x86.aesni.aesenclast and llvm.x86.pclmulqdq. (A first attempt without -target compiled the aarch64 file for the host and said nothing about x86, which is why §2 names the targets.)

What that decides:

  • The feature must be on per function, not per module. Turning +aes on for the whole module would let clang use it anywhere, and the binary would fault on a CPU without it. A "target-features"="+aes,+pclmul" attribute on only the functions that contain the intrinsic keeps the rest of the module at baseline.
  • Whether to take the hardware path is a run-time answer, not a build-time one. A program built on one machine runs on others (scripts/package-release.sh builds for a target, not for a CPU).

This CPU has them: hw.optional.arm.FEAT_AES, FEAT_PMULL and FEAT_DIT all read 1 on the M4 Max of docs/tls-assurance.md §6.1.

Not measured: how fast the hardware path would be in cancho. §8 sets that as a gate for the std PR, not a claim.

3. The builtins

cancho has no 128-bit value: the scalar types are int (64-bit), byte, bool and float. An AES block and a GHASH product are 128 bits. Two ways round that were considered:

OptionWhat it isVerdict
Block-level builtins over byte slicesthe compiler loads the block into a vector register, does the whole operation there, and stores itchosen: no new type, and no round-trip through memory between rounds
A vector type (v128) with aes_round, clmula language change: the checker, both backends, arithmetic rules, and how a vector meets checked arithmeticthe general answer, and the one ChaCha20’s SIMD would need. Its own design, later

The builtins, as edition N; (the latest, 6 today, as value_barrier was: docs/editions.md §5, a name a program could already declare). As built: edition 7, the latest by then.

hw_aes_gcm() -> [] bool
aes_encrypt_block(round_keys: &[byte], rounds: int, block: &[byte], out: &![byte]) -> [] int
ghash_update(h: &[byte], y: &![byte], data: &[byte]) -> [] int

hw_aes_gcm() answers whether this CPU has everything the other two need: on x86-64, AES, PCLMULQDQ and SSSE3 (the byte-swap GHASH needs); on aarch64, AES and PMULL. It reads the CPU once and caches the answer in a module-local. It is public information, so branching on it is not a timing leak.

aes_encrypt_block(round_keys, rounds, block, out) is the FIPS-197 cipher: round_keys is the expanded key in its standard form (rounds + 1 blocks of 16 bytes, rounds 10 for AES-128 and 14 for AES-256), block is 16 bytes, out is 16 bytes. The instructions differ in where they place AddRoundKey (AESE first, AESENC last), and the lowering handles that: the program sees the one standard layout on both targets.

(#382: retired, still accepted. ghash_update takes a block at a time and reduces after each; docs/gcm-wide.md §3.2 and §3.4 give the aggregated form std.gcm uses instead, ghash_powers, gcm_tag and gcm_tag_diff, and aes_ctr32 for counter mode. docs/editions.md §7 says why it was kept.)

ghash_update(h, y, data) is y = (y XOR block) * h in GF(2^128) for every 16-byte block of data, in GCM’s bit order, with y updated in place. The reduction stays in registers. It takes data whole, and std.gcm does its own padding and length block as it does now.

Key expansion stays in software. x86 has AESKEYGENASSIST and aarch64 has nothing equivalent, so one portable key schedule (the existing bitsliced SubWord) is used on both paths. It runs once per key, and §6 moves it to once per connection.

What a wrong argument does. A slice of the wrong length, or rounds other than 10, 12 or 14, traps, as an out-of-bounds index does; it never reaches an instruction. No input may reach that trap (CLAUDE.md), and none can: the lengths are fixed by the suite, not read from the network, and std.gcm already refuses a bad key or nonce length with its rule tag (refused_key_length) before it would call the builtin. The trap is for a caller’s bug, which the fuzz harnesses (docs/tls-assurance.md §3) would report as a crash.

4. How the LLVM backend lowers them

crates/cancho-codegen-llvm/src/body/expr.rs already lowers value_barrier to inline assembly. These three lower to intrinsics instead, which LLVM can schedule:

  • The functions that contain them carry "target-features"="+aes,+pclmul,+ssse3" (x86-64) or +aes (aarch64). Because LLVM refuses to inline a function with more features into one with fewer, the lowering is an out-of-line function per builtin, called directly, and the feature never reaches the caller.
  • aarch64 aes_encrypt_block loops aese then aesmc for all but the last round, then aese and an XOR with the final key. x86-64 loops aesenc and ends with aesenclast. Both are checked against FIPS-197’s vectors (§7).
  • ghash_update is four carry-less multiplies a block and a reduction by the GCM polynomial, on both targets. The aarch64 form byte-reverses with rev64 and ext; the x86-64 form with pshufb. As built (crates/cancho-codegen-llvm/src/crypto.rs): the instruction is used only for the four 64-by-64-bit products; the bit order (each byte’s bits reversed, into the polynomial’s natural order) and the reduction (the high half folded twice by x^7 + x^2 + x + 1) are LLVM i128 and i256 arithmetic, every shift by a constant. Simpler to check than a hand-scheduled register version, and it leaves the choice of instructions to LLVM. Its speed is the std PR’s measurement.
  • hw_aes_gcm():
    • x86-64: cpuid leaf 1, ecx bits 25 (AES), 1 (PCLMULQDQ) and 9 (SSSE3), by inline assembly, as value_barrier is.
    • aarch64 Linux: getauxval(AT_HWCAP), bits HWCAP_AES and HWCAP_PMULL.
    • aarch64 macOS: sysctlbyname("hw.optional.arm.FEAT_AES") and FEAT_PMULL.
    • the last two are libc calls. Whether the backend already links them, and whether a builtin that calls libc is still [], are not checked here, and §9 asks the second. Checked when built: the DIT start-up (§6) already declares getauxval and sysctlbyname on those targets, and every program links libc; §9 answered the second yes. The answer is cached in a module global, read and written with monotonic atomics.
  • As built, the length checks are at the call site, with the backend’s own trap instruction, and the out-of-line functions are defined only in a module that calls them.

5. The Cranelift backend

No instruction exists (§2), so:

  • hw_aes_gcm() answers false. std.aes and std.gcm then take the software path, which is today’s code, unchanged.
  • (#382: ghash_update is retired and kept; it and aes_ctr32, ghash_powers, gcm_tag and gcm_tag_diff, which std uses instead, are lowered on Cranelift exactly as the correction below says, a trap, docs/gcm-wide.md §4.)
  • aes_encrypt_block and ghash_update are never reached by a correct program on Cranelift. If one is, they answer the software result (an interpreter for the same function in the backend’s runtime), so a program is never wrong, only slow. That keeps the two backends agreeing, the rule docs/tls-pure.md §9 gate 1 states. Corrected when built: they trap instead, as trap() does (and so does the LLVM backend on a target without the instructions, WebAssembly). The software result would have been AES and GHASH written a third time, in the backend, for code no correct program reaches: std.aes and std.gcm call the builtins only after hw_aes_gcm() answered true, which on Cranelift it never does. The two backends still agree on every program that asks first, and the differential of §7 compares the hardware path with the software one inside one LLVM program, where both run.

A TLS build should use LLVM. That is already the default backend. Cranelift’s AES-GCM is also the one place its timing test failed with Arm’s data-independent-timing bit set (docs/tls-assurance.md §6.1).

What this does not fix: Cranelift’s AES-GCM is slower than LLVM’s (12.1 against 7.1 µs for a 64-byte seal on the M4, 3.1 times at 16 KiB on the Xeon), and its data tests failed their timing test on the M4 (docs/tls-assurance.md §6.1). The hardware path does not reach Cranelift, so that result stands.

6. The code that uses them, and what goes first

Two changes need no builtin and come first, each its own PR, because each helps the software path that Cranelift and a CPU without the instructions will keep running, and because the builtins’ gain should be measured against that improved baseline:

  • Arm’s data-independent-timing bit. Setting PSTATE.DIT in the runtime’s start-up is a few lines. On the M4 it removed the ECDH timing failure on LLVM (|t| 16.19 to 1.84 and 10.40 to 2.15) and most of AES-GCM’s on Cranelift (docs/tls-assurance.md §6.1). What it costs in speed is not measured here, and the DIT PR measures it. Done (the first PR after this design): the LLVM backend’s main sets it on aarch64 Linux and Darwin when the OS reports FEAT_DIT (crates/cancho-codegen-llvm/src/dit.rs). Measured: P-256 with scalar 1 fails without it (|t| 9.11) and passes with it (2.11); the symmetric ciphers cost 3 to 6% more (docs/tls-assurance.md §6.1). Two things this paragraph did not know: a Darwin thread starts with DIT clear, so only main’s thread has it there (a Linux thread inherits it), and Cranelift has no inline assembly, so its programs do not set it.
  • Per-key work once per connection (step 1 below).

std.aes and std.gcm then change in three ways, in this order of risk:

  1. Per-key work once per connection. The key schedule, and H = AES(0), are 25% and 14% of a 64-byte seal today (docs/tls-parity.md §3.1). They depend only on the key, so the record layer keeps them. This helps the software path too, and is a change of its own. Done: std.gcm’s prepare, seal_with and open_with (a context of context_len() words: the rounds, the expanded key and H), with seal and open as prepare-then-use; the record layer prepares each direction’s key when it is installed (tls_slot.i_read_aead, i_write_aead) and forget and drop overwrite it. Measured on the Apple M4 (LLVM, AES-128-GCM, best of three): a 64-byte seal 8.8 µs one-shot, 1.6 µs with the key prepared, 5.5 times faster, more than the shares above (taken on the Xeon) suggested; a 16 KiB seal 187 against 181 µs. The lying server’s 84 recordings replay byte for byte; 25 GCM mutants and the TLS mutants all killed.
  2. A branch on hw_aes_gcm() at the top of seal and open. Both paths produce the same bytes, and a test says so.
  3. The software path stays, whole. It is what Cranelift runs, what a CPU without the instructions runs, and what the differential test checks the hardware path against.

7. How it is tested

  • Known answers, on both paths and both backends: FIPS-197 Appendix B and C (AES-128 and AES-256), NIST’s GCM test vectors, and the vectors std.gcm already has.
  • A differential: the hardware path against the software path over random keys, blocks and lengths, which is a test neither backend alone can give.
  • Forcing the software path on a machine that has the instructions, so CI covers both: std.aes and std.gcm keep their software functions public, and the differential calls each path directly. There is no switch, because a switch that changes the path at run time could ship in a release by accident.
  • Both CI runners: linux-x86_64 and darwin-aarch64 run the hardware path. A runner without the instructions would show up as hw_aes_gcm() answering false. A test asserts true on the two CI runners, once the builtins PR has measured that they answer it, so a silent fallback cannot hide.
  • The timing test, rerun (scripts/gcm_timing.py), on both paths. The hardware instructions are specified as data-independent, but this CPU’s own result (docs/tls-assurance.md §6.1) is the reminder that the specification and the machine can disagree, so it is measured.
  • Mutants: at least 12 killed, as every package here has (scripts/tls_mutants.py is the form).
  • The record-layer corpus: once #272 is merged, its fuzz corpus (conformance/tls_fuzz.rs) and the recorded handshakes replay on the new path.

8. Gates

The numbers the std PR must show, each with its command, and an honest “not met” if it is not:

  • Speed: the 16 KiB and 64-byte rows of §1, on the same two machines as tls-parity.md §3.1 and tls-assurance.md §6.1. No target is claimed here. The measurement decides how far tls-parity.md §3.1’s “OpenSSL is about 170 times faster” is corrected, in place. Met on the M4 and on aarch64 Linux; not met on the Xeon, which was not available (§8.1).

8.1 Results (the std PR, step 4)

std.gcm’s seal_with and open_with take the hardware path where hw_aes_gcm() is true (seal_hardware, open_hardware: CTR with aes_encrypt_block, GHASH with ghash_update, the padding and lengths block as before; #382: now aes_ctr32 for the data and gcm_tag/gcm_tag_diff for the tag, ghash_update retired, docs/gcm-wide.md §3.4) and the software path otherwise; seal_software and open_software stay public. A prepared key gains its hardware part as bytes (gcm.hw_len(), 256 (#382: 368, the round keys then the eight twisted powers of H): the round keys in FIPS 197’s form from the new aes.round_keys, and H), which the record layer keeps in the slot’s keys (tls_slot.k_read_hw, k_write_hw). The one-shot seal and open prepare only the path they take.

Speed, AES-128-GCM seal with a prepared key (tests/programs/gcm_speed.cho, best of three; OpenSSL speed -evp aes-128-gcm):

MachineSizehardware pathsoftware pathOpenSSLOpenSSL against hardware
aarch64 Linux (the M4’s VM, OpenSSL 3.0.13)64 B557 MB/s54 MB/s4,007 MB/s7.2 times
16 KiB1,110 MB/s84 MB/s8,035 MB/s7.2 times
Apple M4, macOS (OpenSSL 3.6.4)64 B99 MB/s39 MB/s611 MB/s6.2 times
16 KiB1,050 MB/s91 MB/s10,497 MB/s10 times

At 16 KiB the hardware path is 11 to 13 times the software one. At 64 bytes Darwin is five times slower than Linux on the same CPU: a call allocates one region, which is a 64 KiB malloc, and Darwin’s is slow at that size (three regions a call took 235 ms per 200,000 seals, one takes 129). What remains of the gap to OpenSSL at 16 KiB is one call a block of each builtin, with its length checks, where OpenSSL interleaves several blocks in registers. (#382 measured it again before changing anything, and corrected this: the call was not the cost. The cipher’s instructions were 17% of a 16 KiB record; the byte-at-a-time XOR in cancho, and GHASH’s bit reversal and reduction after every block, were most of the rest, and on Darwin a region’s malloc was 88% of a 64-byte one. docs/gcm-wide.md §2 has the profile and §8 the result.)

Correctness: every GCM vector (FIPS 197, CAVP, Wycheproof, every refusal) runs on the hardware path on LLVM, and on the software path on Cranelift; the_hardware_and_software_paths_agree seals and opens 400 random messages on both paths in one program and compares every byte; the lying server’s 84 recordings replay byte for byte. Mutants: 32 of 32 killed (scripts/gcm_mutants.py, which now runs every seal and open on both paths; six of them the hardware path’s). Timing: scripts/gcm_timing.py on the hardware path on the M4, max |t| 1.46 to 3.65, all passes. No new capability: the builtins are [].

  • Correctness: §7’s known answers and differential, on both backends.
  • No new capability: cancho authority on the pure backend shows no ffi(...), as #210 requires.
  • The gate: cargo fmt --all --check, cargo clippy --all-targets -- -D warnings, cargo test --workspace, and no source file over 2,000 lines.

7.1 Results (the builtins PR)

crates/cancho/tests/conformance/crypto_builtins.rs, through tests/programs/crypto_builtins_driver.cho:

  • Known answers: FIPS 197 Appendix C.1 and C.3, and NIST’s GCM test case 2 assembled from the two builtins (H, AES(K, J0) and the tag each the standard’s value).
  • A differential: 600 random AES blocks (AES-128, -192 and -256) and 600 random GHASH runs of 1 to 12 blocks against plain reference implementations written from FIPS 197 and SP 800-38D in the test. Before it, by hand: 603 cases against Python (cryptography for AES, SP 800-38D’s algorithm for GHASH) on an Apple M4 and on aarch64 Linux, all equal.
  • hw_aes_gcm() is true on the suite’s hosts with LLVM, asserted, and false on Cranelift, where a block builtin traps.
  • A wrong length or round count traps before any instruction runs, and writes nothing.
  • x86-64 is checked by CI’s linux-x86_64 runner, which runs the same test; it was not run by hand.

9. Open questions, and the answers proposed

Each is a decision for a person. The answer given is the one this PR proposes, with its reason; a reviewer who disagrees changes the answer here before anything is built. Decided: all five as proposed (2026-10-06).

  1. A bad length: a trap or a return code? A trap. No attacker’s bytes reach it (§3), the key length is refused earlier with a rule tag, and a return code would add a check that can only fail on a programming error.
  2. Is a builtin that reads the CPU []? Yes, as value_barrier is. Its answer is the same for the life of the process and nothing a program does can observe the read. A capability would cost the pure backend its empty row (#210’s gate) for nothing. The builtins PR must confirm that the backend already links the libc calls aarch64 needs (getauxval, sysctlbyname); x86-64’s cpuid needs none.
  3. A vector type instead? Not now. The block builtins go straight at the AES gap with a small, testable change. A vector type is the answer if ChaCha20’s 17-times gap starts to matter, and it is a larger design of its own: the checker, both backends, and how a vector meets checked arithmetic.
  4. Does a Cranelift-only user accept the software speed? Yes, and a TLS build should use LLVM (§5), the default.
  5. How the tests force the software path. They do not switch it: both paths stay public and are called directly (§7). This replaces the first draft’s environment variable read in a test build.

Order proposed (§6): this design merged with these answers, then Arm’s DIT bit, then per-key caching, then the builtins and the LLVM lowering, then the std change and the speed measurement against OpenSSL.

10. Not done here

  • Any code. This document is the design. After it come four PRs, in §9’s order: Arm’s DIT bit, per-key caching, the builtins with their lowering, and the std change.
  • ChaCha20 SIMD (docs/chacha20.md §6).
  • AES-CBC, AES-CCM or any mode but GCM. The block builtin would serve them; nothing here uses it.
  • SHA instructions (SHA256H, SHA256RNDS2). The same shape. Whether they matter has not been measured. Measured and built in #445: sha256-builtin.md, hw_sha256, sha256_blocks and sha256_block_words.

Wide multiply and add-with-carry: three builtins, prototyped on X25519

Status: built, on X25519 (§10), P-256, std.bigmod and Poly1305 (§12). Issue #381, part of #378 (TLS performance: closing the gap with OpenSSL). The question it answers is whether the language is missing two instructions that every public-key operation here is waiting for. §2 is the measurement that was made before the compiler was touched; §3 to §9 are the design; §10 is what the prototype (X25519 only) measured; §11 is what each other user of narrow limbs would gain, by arithmetic, for a person to decide. Where a later change finds a claim here false, it corrects it here, in place.


1. What is asked, and why

X25519, P-256 and P-384 (std.ecdh, std.ecdsa), RSA (std.bigmod) and Poly1305 do their multi-word arithmetic in narrow limbs: 16 bits in std.field25519 (TweetNaCl’s representation, docs/x25519.md §2), 30 bits in std.bigmod (docs/rsa.md §2.1), 26 bits in the Poly1305 of std.chacha20. The reason is the same each time and is written at the top of each file: the language has int, which is 64 bits, checked or wrapping_*, and nothing else. There is no 64 x 64 -> 128-bit multiply and there is no carry flag. A limb must therefore be narrow enough that a product, and the sum of a column of products, still fits in 64 bits.

Hardware does not have that limit. Every 64-bit CPU multiplies two words into two words (mul on x86-64; mul and umulh on aarch64) and adds with a carry (adc, adcs). A 255-bit field element is 5 limbs of 51 bits there and 16 limbs of 16 bits here, so a field multiplication is 25 products, not 256.

The cost is visible. std.x25519 takes 0.58 ms on an Apple M4 and 0.66 ms on the x86-64 machine of §2, where OpenSSL 3.5 takes 26.7 µs (issue #381 put it at “0.57 ms against OpenSSL’s tens of µs”; §2 has the measurements). X25519 is one key-exchange operation in every TLS 1.3 handshake, and the same shortage is behind P-256’s cost (the common choice for servers) and RSA’s.

Not asked for: a 128-bit integer type, any change to checked arithmetic, SIMD (docs/chacha20.md §6), or any call into C. The builtins are pure ([]), as value_barrier and wrapping_add are (docs/crypto-builtins.md §1; #210’s gate needs the pure backend to have no Ffi).

2. What is measured before the compiler is touched

The question this section decides is whether to build anything at all: what would a field multiplication cost on 51-bit limbs, against the 16-bit limbs the language has today? It is answered with C, because C already has unsigned __int128 and so shows what the instructions give without this language’s own overheads (bounds checks, slices, loops) in the way. scripts/field25519_widths.c holds both versions: TweetNaCl’s 16 x 16-bit multiplication, carried and folded as std/field25519.cho does it, and curve25519-donna-c64’s 5 x 51-bit one. Each ladder is the same Montgomery ladder (RFC 7748 §5) and each is checked against RFC 7748 §5.2’s first vector before it is timed. A third version writes the 51-bit multiplication the way this language will be able to write it, with no 128-bit variable: a (hi, lo) pair per product and an explicit (sum, carry) addition into a two-word accumulator, which is mul_wide and add_carry. Minimum of 25 runs; cc -O2.

Machine (CPU, load)16-bit limbs, field mul51-bit, __int12851-bit, (hi, lo) pairs16-bit, X2551951-bit, X25519ratio
gram, x86-64: Intel i7-1260P, pinned to cores 4-5, load average 3 to 5 from other agents155 ns15.8 ns15.1 ns496 µs49.9 µs9.9
Apple M4 Max, macOS, idle77.6 ns10.3 ns9.6 ns237 µs27.1 µs8.8

(clang -O2 on gram, and -O2 -march=native, which lets the compiler use mulx, changes nothing: 15.6 ns. gram’s clock is a laptop’s and its load is other agents’, so its figures move by up to 30% between runs; the table is the minimum, and §10 repeats every gram figure the same way.)

What the language does today, on the same two machines, with the same ladder (scripts/curve25519_bench.py, median of five, less the cost of starting the process):

Machinestd.x25519 todayOpenSSL 3.5 / 3.6 speed ecdhx25519today / OpenSSL
gram0.664 ms26.7 µs25
Apple M4 Max0.577 ms19.0 µs30

So the ceiling on 51-bit limbs with a wide multiply, from C, is about 10 times the C code on 16-bit limbs, and the code here is slower than C on 16-bit limbs by 1.3 times (gram) to 2.4 times (M4), which is room for the new code to land nearer the C figures than the old code does. A target of 3 times (the issue’s) asks for X25519 at 0.22 ms on gram; the C estimate says the instructions allow 0.05 ms before this language’s own overhead. That is a wide margin, and it is why §3 onward builds the prototype rather than stopping here. (§10 says where it landed.)

3. The builtins

mul_wide(a: int, b: int) -> [] (int, int)
add_carry(a: int, b: int, carry: int) -> [] (int, int)
sub_borrow(a: int, b: int, borrow: int) -> [] (int, int)

Every word is an int read as an unsigned 64-bit integer: -1 is 2^64 - 1. There is no new type. A word is stored in the int the language has, and the int operations that do not care about the sign (&, |, ^, <<, wrapping_add, wrapping_sub, wrapping_mul) already treat it as a bit pattern. >> is arithmetic, so extracting a field of bits above bit 0 takes a mask (x >> 51 & 0x1fff); §10.6 shows LLVM folds it into one funnel shift on aarch64.

  • mul_wide(a, b) is the exact product a * b as (hi, lo): hi * 2^64 + lo. It cannot overflow, so it never traps.
  • add_carry(a, b, carry) is a + b + (carry != 0) as (sum, carry_out): sum the low 64 bits, carry_out 0 or 1.
  • sub_borrow(a, b, borrow) is a - b - (borrow != 0) as (difference, borrow_out): the low 64 bits and 0 or 1 (1 when the true result is negative).

The carry in is “nonzero”, not “one”: any other value counts as 1, so that no input can reach a trap and the instruction sequence has no check on it. A caller that holds a carry as 0 or 1, as the builtins return it, never sees the difference.

Why tuples. A result is two words, and the language has tuples (docs/tuples.md): let (hi, lo) = mul_wide(a, b);. A tuple of two ints is two leaves, so it lives in two registers (§4) and nothing is allocated or copied. The alternatives were a struct (a type to name for each builtin) and an out-parameter (&![int], a store and a load through memory, which is what the builtin exists to avoid).

3.1 Why exactly this set

The set is the smallest one the inner loops of the algorithms that want it can be written in. Each row is the loop body as it is written with 64-bit limbs.

AlgorithmInner stepNeeds
X25519, Ed25519 (5 x 51 bits; ref10, donna-c64)column += a_i * b_j, 25 times, into a 128-bit accumulator; then carry each column’s top into the nextmul_wide, and add_carry to add the low word and carry into the high
P-256, P-384 (Montgomery on 64-bit limbs; or Solinas)CIOS: t[j] + a_i * b[j] + c, then t[j] + m * n[j] + c; add and subtract mod p; the final conditional subtractionmul_wide, add_carry, sub_borrow
std.bigmod, RSA (Montgomery, up to 4,096 bits)the same CIOS step, over up to 64 limbsmul_wide, add_carry, sub_borrow (the final subtraction)
Poly1305 (3 limbs of 44 bits, poly1305-donna-64; or 130 bits as 64-bit limbs)h = (h + m) * r: nine products into three 128-bit accumulators; a final h - pmul_wide, add_carry, sub_borrow

Three builtins cover all four, and each is needed by at least two. What was left out, and why:

  • mul_add_wide(a, b, c, d) -> (hi, lo), a * b + c + d, the CIOS step as one operation. It cannot overflow (the maximum is 2^128 - 1) and it is what the loops above are made of. It is mul_wide and two add_carrys with a zero carry, and on both targets those three become mul; add; adc; add; adc, the same instructions a fused builtin would end in (§6 shows the object code). A fourth name that adds no instruction is a fourth name to keep.
  • A funnel shift ((hi << 13) | (lo >> 51), shrd on x86-64, extr on aarch64). The 51-bit carry extraction is one. It is three instructions in the language and one in the instruction set. Not added, for the reason above, and recorded in §10 as the place a further step would look first.
  • A 128-bit type. The same answer as docs/crypto-builtins.md §3: a language change (the checker, both backends, how it meets checked arithmetic) for a need that a pair of words meets.
  • A carry as a separate flag value (adc as an instruction that reads the flags a previous one left). It cannot be expressed without a type that is a flag; threading the carry as an int is what the compiler can already see through.

4. How the LLVM backend lowers them

crates/cancho-codegen-llvm/src/body/wide.rs. Each builtin is straight-line IR on registers, inline at the call.

  • mul_wide, on x86-64 and aarch64 (the architectures with a widening multiply): zext both words to i128, one mul i128, trunc for the low word and lshr 64; trunc for the high one. LLVM selects one mul (x86-64) or mul and umulh (aarch64) for it. It is not llvm.umul.with.overflow: that returns the low word and a flag, and the high word is the point.
  • add_carry, sub_borrow: llvm.uadd.with.overflow.i64 (usub) twice, once for a + b and once for adding the carry, and the two overflow bits are ored (they cannot both be set: after the first overflow the value is at most 2^64 - 2). The carry in is icmp ne 0; zext.
  • Elsewhere (WebAssembly, 32-bit targets): the same IR would become a call to __multi3, which the WASI sysroot does not ship (docs/wasm.md, W0.3). The product is built from four 32 x 32 -> 64 multiplies (Knuth’s Algorithm M in Hacker’s Delight’s mulhu order). One thing about it was found by running it: LLVM’s AggressiveInstCombine recognises that idiom and turns it back into an i128 multiply, so __multi3 returned. The low halves of the operands therefore pass through an empty asm, as value_barrier’s argument does, which hides the idiom and costs no instruction. The wasm32 module was linked and run under wasmtime (§9).
  • The module declares the two new intrinsics once, with the three it already declares.

5. How the Cranelift backend lowers them

crates/cancho-codegen/src/body/expr.rs. The pinned Cranelift is 0.121.2.

  • mul_wide: umulhi for the high word and imul for the low. umulhi has lowerings for i64 on x86-64 and aarch64 (isa/x64/lower.isle: mul, or mulx where BMI2 is enabled, which it is not by default; isa/aarch64/lower.isle: umulh).
  • add_carry, sub_borrow: iadd (isub) twice and an unsigned icmp against the first operand, which is the carry out of each addition, bored, uextended. The flag instructions exist in 0.121.2 (uadd_overflow, usub_overflow, uadd_overflow_cin, usub_overflow_bin), but isa/aarch64/lower.isle has no rule for them (checked by searching the file, not by running one), and uadd_overflow_cin has no rule in either ISA’s lower.isle. An icmp used as a value is setcc or cset, never a branch, on both. It costs more instructions than LLVM’s adc chain, and §10 measures what that costs.

Cranelift does not set Arm’s PSTATE.DIT and cannot emit inline assembly (docs/crypto-builtins.md §6), so its constant-time story for these is the instruction’s own, not the platform’s (§6).

6. Constant time

A builtin used on a secret (mul_wide of two limbs of a scalar-derived field element) must take the same time whatever its operands are. Three things are claimed and checked, none by argument alone.

  1. No branch, no call, no memory access in the code of the builtins. scripts/wide_multiply_objdump.py builds tests/programs/wide_multiply_ct.cho, whose function words is nothing but the three builtins, disassembles the object and fails on any jump, call or return other than the last ret. What it found (the function, in full, for x86-64 and aarch64 with LLVM; all four are in §10.3):

    x86-64, LLVM:    mul %rdi ; setne ; setne ; lea ; add ; adc ; setb ; adc ; movzbl ; add ; sbb ; adc ; ret
    aarch64, LLVM:   mul ; umulh ; cmp ; cset ; cmp ; cset ; cmp ; adcs ; cset ; add ; adc ; cmp ; sbcs ; cset ; cmp ; adc ; ret
    

    Both contain the widening multiply and a carry chain, and no __multi3: the script also fails if the object names it. crates/cancho-codegen-llvm/src/wide_tests.rs checks the same on the IR for x86-64, aarch64 and wasm32, so that a change that introduces a select, a branch or a libcall fails without a disassembler.

  2. The instructions themselves. mul, adc and sbb have no operand-dependent timing on the x86-64 cores this runs on; mul, umulh, adc and sbc are in the set Arm’s data-independent timing bit makes data-independent. Both are the architectures’ documented behaviour, which this document did not re-verify from the manuals, so the measured evidence is the third item.

  3. A timing test of the whole. scripts/x25519_timing.py, dudect, on the prototype (§10), on every machine, at 10^6 measurements, with the bar |t| < 4.5.

DIT. On aarch64 the LLVM backend’s main sets PSTATE.DIT where the OS reports it (docs/crypto-builtins.md §6), which covers umulh like every other integer instruction. The builtins add nothing to that code.

The value barrier. The builtins return values and make no selection, so there is no mask for a barrier to protect; a program that selects on a carry still uses value_barrier on the mask, as std.field25519.cswap does. docs/value-barrier.md §4’s audit of the new field module is in §10.

7. Targets, editions, effects, tables

  • Targets. They are accepted everywhere. On WebAssembly under WASI they build and run (§4), so there is no unsupported-on-target refusal and wasi_gap (crates/cancho-ir/src/target.rs) answers None for all three. A refusal would have been wrong: nothing about the target is missing.
  • Editions. Edition 7, the latest, as hw_aes_gcm was (docs/editions.md §5, additive): mul_wide, add_carry and sub_borrow are names a program may already declare. tests/reject/wide_multiply_is_edition_seven.cho shows the file in edition 6 refused with not-a-function, and none of this repository’s files declares one of the three (searched).
  • Effects. None: Effects::pure(). No region parameters.
  • The self-hosted tables. examples/selfhost/tables.cho holds the builtin names and the edition each is visible from; regenerated by UPDATE_SELFHOST_TABLES=1 cargo test -p cancho-ir selfhost_tables.
  • The new std module is registered in crates/cancho/src/main.rs’s list.

8. What happens to every existing user of narrow limbs

Nothing changes for them. The builtins are additive and edition-gated, no existing function is edited, and a file in edition 6 or earlier cannot see them. The prototype moves one user, std.x25519, onto a new module, std.field25519_51; std.field25519 stays, whole, for std.ed25519, which has its own point arithmetic over 16 limbs and is not moved here. std.bigmod (P-256, P-384, RSA) and Poly1305 are not touched. §11 lists what each would gain.

9. How it is tested

  • tests/programs/wide_multiply_driver.cho and crates/cancho/tests/conformance/wide_multiply.rs, on both backends, against Rust’s u128 as the oracle: every pair of 15 operand edges (0, 1, 2, around 2^32, around 2^63, 2^64 - 1, alternating bits) with carries of 0, 1, 2 and 2^64 - 1 (the rule that a carry is “nonzero”); an 8-word ripple of carries and of borrows, each carry-in the previous carry-out; known answers; and three seeds of 2,000,000 random, edge-biased cases of each of the three builtins (6,000,000 products), whose answers the driver folds into one checksum that the test recomputes from the same generator.
  • The same driver on wasm32-wasip1, built with the compiler and run under wasmtime, giving the native checksum.
  • The object code (§6) and the IR (wide_tests.rs).
  • The reject fixture for edition 6, and an accept fixture (tests/accept/wide_multiply.cho) on the values the carries turn on.
  • X25519: RFC 7748’s eight vectors including the 1,000-iteration one, Wycheproof, scripts/curve25519_differential.py against OpenSSL, mutants, ctgrind-style and dudect checks (§10).

10. Results

10.1 What was built

std/field25519_51.cho (std.field25519_51): add, sub, mul, square, mul_small, cswap, invert, pack, unpack, over five 51-bit limbs; mul is 25 mul_wides, each added into a two-word accumulator by add_carry, and square is 15. std.x25519 imports it instead of std.field25519; its ladder, clamping and refusals are the same lines. The inversion is ref10’s addition chain (254 squarings and 11 multiplications) where the old field did 253 squarings and 251 multiplications by a bit loop, so part of the gain below is not the builtins; §10.5 separates it. std.field25519 is unchanged and std.ed25519 still uses it. tests/lex/field25519_51_test.cho pins the corners a ladder reaches with probability near 2^-51 (a result needing the second carry pass of pack, p and p + 1, (p - 1)^2, 2 * 2^-1).

10.2 Correctness

  • RFC 7748’s eight vectors (including the 1,000-iteration one) and every Wycheproof X25519 case, on both backends, on macOS arm64 and x86-64 Linux (crates/cancho/tests/conformance/x25519.rs).
  • scripts/curve25519_differential.py against OpenSSL (pyca/cryptography 50.0.2): 200,000 X25519 and 1,000 Ed25519 rounds, 404,000 checks, 0 differences on macOS arm64 with LLVM; 20,000 and 300, 0 differences, on aarch64 Linux.
  • mul_wide, add_carry, sub_borrow: §9, on all of LLVM, Cranelift and wasm32, on macOS arm64 and on x86-64 Linux (both backends there too; wasm32 only on macOS).
  • A bound that was false, found in review and corrected here. The first version of this module said mul accepts limbs under 2^54. It did not: with every limb at 2^54 - 1, the carry out of column 4 times 19 is above 2^63, finish’s r0 >> 51 is an arithmetic shift, and mul(a, a) gave wrong bytes. X25519 was not affected (its limbs stay under about 3 * 2^51), a port of Ed25519’s larger sums would have been. The shift is now masked to the 13 bits it means, and test_mul_and_square_at_the_input_bound checks the bound itself against Python’s (v * v) % p.
  • Mutants (raw output in docs/assets/wide-multiply/). scripts/wide_multiply_mutants.py: 20 of 20 killed (the LLVM lowering and its wasm32 path, the Cranelift lowering, the edition), with the wasm32 toolchain set. scripts/curve25519_mutants.py: 39 mutants of the four files, 36 killed; both scripts now match a mutant’s text as tokens, so cancho fmt cannot stop them (it did once: the first count, 33 of 38, could not be re-run after the formatting commit). The three that survive: the X25519 “final swap left out” (equivalent: clamping clears bit 0) and Ed25519’s point_equal (docs/x25519.md §5) were known; “pack of the new field does one weak carry pass, not two” is, I believe, equivalent: the later q step absorbs the one extra bit a single pass can leave (seen on the crafted element of the unit test, not proved for every input), and the second pass stays as margin. Two std.field25519 mutants (“pack does one trial subtraction”, “unpack keeps the top bit”) used to be killed only by X25519’s vectors, which no longer run on that field; tests/lex/field25519_test.cho now kills them directly.

10.3 Constant time

Raw logs, with the commit and the machine, are in docs/assets/wide-multiply/.

Whatx86-64 gram (i7-1260P, cores 4-5)Apple M4 Max, macOSaarch64 Linux (Docker on the M4)
dudect (scripts/x25519_timing.py), LLVM, 10^6 samples a test, fixed / sparse scalar, max abs t (bar 4.5)1.27 / 1.411.78 / 1.901.40 / 1.21 (before the review’s fix to finish)
dudect, Cranelift1.32 / 1.37 (load 7.8)1.15 / 9.20, again 1.75 / 15.24: fails; with PSTATE.DIT set, 1.44 / 1.72not run
ctgrind (scripts/curve25519_ctgrind.sh, Valgrind Memcheck, scalar marked undefined), LLVM0 reports; the control (an if on the secret bit planted in the ladder) 251 errors; Ed25519 signing, the known exception, 1,937not run: no Valgrind on macOS0 reports (control not run there)

Cranelift carries no constant-time claim for X25519 on Apple silicon. Its X25519 fails dudect on the sparse scalar on the M4, twice, at |t| 9.2 and 15.2, and passes when PSTATE.DIT is set from outside the program (a constructor in libtick.a, the method of docs/tls-assurance.md §6.1). So what varies is the CPU’s own timing of some instruction on these operands with DIT clear, which the LLVM backend’s main sets and Cranelift’s does not (it cannot emit the instruction). The 16-bit field on Cranelift passed (200,000 samples, |t| 1.28 / 1.17; fewer samples, as it is 20 times slower, so a weaker test). This is the limitation docs/crypto-builtins.md §5 already states for Cranelift’s AES-GCM: a TLS build uses LLVM, the default.

  • Branch audit (scripts/chacha20_branches.py, x86-64): in every function of std.field25519_51 the only conditional jumps are bounds-check traps, plus square_n’s loop counter; pack has three cmovb, on the output slice’s length. In x25519.scalarmult the 11 jumps that are not traps are on the loop counter i, scalar_bit’s tests of it, and the lengths. None is on a field value.

  • Value barrier (docs/value-barrier.md §4): cswap, the one selection, makes its mask through value_barrier, as before.

  • The builtins’ own object code (scripts/wide_multiply_objdump.py; words is the three builtins and nothing else):

    Backend, machineinstructionswidening multiplycarry instructionsbranch or call
    LLVM, x86-6418muladc, sbb, setbnone
    LLVM, aarch6417mul, umulhadc, adcs, sbcs, csetnone
    Cranelift, x86-6443mul, imulsetbnone
    Cranelift, aarch6432umulhcsetnone

    The x86-64 LLVM code is mulx-free: the baseline x86-64 target has no BMI2, and -march=native bought nothing in C (§2).

10.4 Speed

scripts/x25519_before_after.py (rerunnable): “before” is std/x25519.cho as it was before this change, taken from git and built as a local module over the unchanged std.field25519 with the same compiler; “after” is the tree. The two alternate, each figure scripts/curve25519_bench.py’s (median of five runs of N operations, less one). Raw output in docs/assets/wide-multiply/before-after-*.log. OpenSSL is openssl speed ecdhx25519 on the same machine.

Machine, CPU, loadBackendbeforeafterspeed-up (medians)OpenSSL
Apple M4 Max, macOS, load 6 to 9 from other agents, 5 alternating runsLLVM0.575 ms0.025 ms23x0.019 ms (OpenSSL 3.6.4)
Cranelift1.277 ms0.065 ms20x
aarch64 Linux, Docker on the M4 Max (an earlier run, before the review’s fix)LLVM0.60 ms0.026 ms23x0.019 ms (OpenSSL 3.0.13)
gram, Intel i7-1260P, cores 4-5, load average 7 from other agents, 7 alternating runsLLVMmedian 2.36 ms (min 1.58)median 0.132 ms (min 0.051)18x0.0267 ms, measured earlier when quiet
Cranelift, 5 runsmedian 3.81 ms (min 2.56)median 0.327 ms (min 0.224)12x
gram, earlier runs: load 3.6 before the squaring change; load 6 to 12LLVM0.664 to 0.93 ms; median 1.870.051 to 0.063 ms; median 0.10812 to 13x; 17x
Cranelift, load 6 to 12median 3.98 msmedian 0.398 ms10x

gram’s absolute figures are not reproducible: it ran at one third of its early speed by the end of the work, other agents’ jobs included one on a sibling hardware thread of the pinned cores. The ratios within one interleaved run are the evidence, not the milliseconds; the three quiet-time anchors are the early 0.664 ms before, the C figures of §2 (16-bit 0.50 ms, 51-bit 0.050 ms), and OpenSSL’s 0.0267 ms.

10.5 Where the gain came from

On the M4, LLVM: 0.575 to 0.585 ms before. The new field with the old bit-loop inversion would cost 253 + 251 = 504 field operations for the inversion against 265; with 2,550 in the ladder that is about 8%, so 1.1x of the 23x is the addition chain, and the squaring (15 products against 25, measured 8% on the whole) another 1.1x. The rest, about 19x, is the representation: 25 products in 5 limbs against 256 in 16, and mul_wide and add_carry letting each be two instructions. That is more than §2’s C ratio (8.8x) because the code in this language on 16 limbs was 2.4 times slower than the same code in C (loop control, bounds checks and every limb a load and a store through a slice), and the 51-bit code, being straight-line on locals, has none of that to lose: 0.025 ms is the C unsigned __int128 figure (0.027 ms) to within 8%.

10.6 Verdict

It reaches 3x, and by a wide margin: 23x on arm64 and 17 to 18x on a loaded x86-64 with LLVM; 20x on arm64 and 10 to 12x on the loaded x86-64 with Cranelift. (Cranelift’s X25519 is not constant time on the M4 without DIT, §10.3; a TLS build uses LLVM.) X25519 costs 1.3 times OpenSSL’s hand-written assembly on the M4 (0.025 against 0.019 ms) and about 2.5 to 4 times on gram (0.051 to 0.132 ms under load against 0.0267 ms quiet; the comparison is unfair to this language until gram is quiet). The wide builtins are therefore worth keeping, and the larger win for every public-key operation exists if the others behave like X25519 (§11).

What limits it from here, in order of what I would check, none of it measured:

  1. x86-64 baseline instructions. mul, not mulx, and one carry chain, not two (adcx/adox). OpenSSL’s x86-64 X25519 uses both. Reaching them needs "target-features"="+bmi2,+adx" on the functions that use the builtins and a run-time CPU test, the shape docs/crypto-builtins.md §4 built for AES. C at -march=native (§2) gained nothing, so the expected gain is small.
  2. Limbs through memory. add, sub and cswap are separate passes over 5-element slices, as the old ladder’s were; the ladder step is about 10 field operations, and fusing them by hand would keep the limbs in registers. A language-level fix is an array value type, not a builtin.
  3. The carry extraction. (hi << 13) | (lo >> 51) is one extr on aarch64 (LLVM found it; 5 in mul’s object code), so no funnel-shift builtin is needed there. On x86-64 it is shrd when LLVM finds it; not checked.
  4. Bounds checks in each function (udf/ud2 branches): cheap and predicted, about 15 in mul. The language’s loop overhead is not what limits it: the 51-bit code has no inner loops.

11. What this does not do, and what each user would gain

Not done here, and each a decision for a person: P-256, P-384, RSA and Poly1305 are not moved. The estimates are by arithmetic from the inner loops, not measured; §10 is the only evidence about how well this language turns such a loop into instructions, and it was better than its C estimate.

UserTodayWith the builtinsArithmetic
std.bigmod Montgomery multiplication (P-256, P-384, RSA), mont_mul30-bit limbs: k = 9, 13, 69 for 256, 384, 2,048 bits; the inner step does two multiplies and fits 62 bits64-bit limbs: k = 4, 6, 32; the inner step is two mul_wides and three or four add_carrysinner steps k x k: 81 to 16, 169 to 36, 4,761 to 1,024 (5.1x, 4.7x, 4.6x fewer); each step costs about 1.4x more instructions (10 against 7); so 3 to 3.5x on mont_mul
P-256 ECDH, ECDSAthe same mont_mul plus table selection that reads all 16 entries under masks, additions, copiesat most the mont_mul gain; the share of mont_mul in std.ecdh was not measured here. If it is 80%, 2.2 to 2.5x on the whole
RSA-2048 private-key operationpow_mod on 69 limbson 32 limbs3 to 3.5x
Poly1305 (std.chacha20)5 limbs of 26 bits: 25 products and reductions a 16-byte block3 limbs of 44 bits (poly1305-donna-64): 9 mul_wides2 to 2.5x on the MAC, which is a minority of ChaCha20-Poly1305’s per-byte cost; ChaCha20 itself is untouched
std.ed2551916-bit std.field25519, 3 ms to signstd.field25519_51 ported into its point arithmeticits field multiplications are most of a signature, so the X25519 factor is the ceiling; the bit-serial scalar arithmetic mod L, which does not use them, is the remainder

The ordering by value, which is a decision for a person: std.bigmod first (one change speeds P-256, P-384, ECDSA verification and RSA), then Ed25519, then Poly1305.

As built (§12): P-256 had left std.bigmod before this was built (#397’s own field), so P-256, std.bigmod and Poly1305 were three changes. The table’s arithmetic was low for std.bigmod (RSA-2048 verification 6.8 times fewer instructions on x86-64 against 3 to 3.5, P-384 4.2 times) and for Poly1305 (2.3 times on a 16 KiB MAC against 2 to 2.5), and does not apply to P-256, whose field was already straight-line: 1.6 times on the multiplication, 1.4 to 1.7 on the operations. Ed25519 was not moved.

12. As built: P-256, std.bigmod, Poly1305 (#381)

The three other users of narrow limbs, moved in that order, each a commit kept because it paid. All figures are on gram (the i7-1260P of §2, Linux x86-64, LLVM backend, CPU 0 pinned) in user-space cycles and instructions through sudo perf stat (kernel.perf_event_paranoid was left at 4), each the least of several runs alternating the two builds. Other agents’ jobs ran on the machine throughout (load average 4 to 7, some scheduled on the measured core’s sibling), so cycles moved by up to two times between runs; the instruction counts are exact and are the steady figure. OpenSSL is 3.5.5 openssl speed on the same core, cycles from perf stat over its run. Raw output: assets/wide-multiply/381/.

The commits named here and in the raw output (b2651fea, 6b5299e5, 8a596168, 541a03cc, d4436b62, 028ce885) are the history as measured, kept on the branch p256-wide-multiply-evidence; the PR’s commits are the same changes rebased onto main. At 541a03cc and 028ce885 every std file is byte for byte the PR’s. At 6b5299e5 (the P-384 timing) std/bigmod.cho is; at b2651fea (the P-256 timing) std/p256_kernels.cho differs only in calling its own mac where the PR calls std.field25519_51‘s, and its kernels’ object code is the PR’s instruction for instruction (kernels_object_identity.txt). ed51aed6, named by the runs made after #463 merged, is the branch rebased onto it (before #467 merged); its std, scripts and tests/programs are byte for byte the PR’s.

useroperationbefore: cycles, instructionsaftergain (cycles; instructions)OpenSSL cyclesafter / OpenSSL
P-256 (p256-fast.md §13)field multiplication158, 78299, 4511.60; 1.73
ECDH shared (k·P)980,090, 5,002,519567,604, 2,912,4701.73; 1.72176,8453.21 (5.54 before)
public_key (k·G)233,437, 1,197,041141,568, 713,5291.65; 1.68
ECDSA sign326,959, 1,632,023224,736, 1,075,6601.45; 1.5270,9063.17 (4.61)
ECDSA verify682,396, 3,431,653416,757, 2,026,5871.64; 1.69233,8291.78 (2.92)
std.bigmodRSA-2048 verification (PKCS#1 v1.5, e = 65537)1,052,321, 5.36M154,232, 0.79M6.8; 6.859,1732.61 (17.8)
RSA-4096 verification4,096,539, 21.05M570,300, 2.95M7.2; 7.1193,3842.95 (21.2)
P-384 ECDSA verify13,341,161, 67 to 69M2,608,260, 13.8 to 14.2M5.1; 4.91,290,5562.02 (10.3)
P-384 ECDH shared11,950,097, 60.84M3,066,688, 14.55M3.9; 4.2989,4813.10 (12.1)
P-384 public_key12,209,672, 60.82M3,217,849, 14.54M3.8; 4.2
Poly130516-byte message209, 712186, 5981.12; 1.19
1 KiB5,810, 17,4702,577, 7,1502.25; 2.44
16 KiB89,605, 272,83039,039, 106,9902.30; 2.55

The P-256 rows are tests/programs/p256_profile.cho (scripts/p256_bench.py’s ops); the std.bigmod rows the conformance drivers of std.rsa, std.ecdsa and std.ecdh run the same input many times (a random key a run, so the P-384 verification’s instructions move with it; the outputs of the two builds compared equal on every run); Poly1305 a driver that chains chacha20.poly1305 over one message. In the TLS benchmark of docs/tls-performance.md §6, ChaCha20-Poly1305’s rows came down by 7 to 9% in instructions (1.790M to 1.628M at 16 KiB on x86-64, 1.790M to 1.698M on arm64): that is Poly1305’s part of the AEAD; ChaCha20 is the rest, untouched (§10.15 there).

12.1 What each change is

  • P-256 (p256-fast.md §13): five limbs of 56 bits inside the generated kernels, each packing two of the element’s 28-bit limbs, so R = 2^280, the representation, the constants, the tables and every caller stay; 25 mul_wide a multiplication and 15 a squaring against 100 and 55 products; the reduction in radix 2^56 using the prime’s form. Four 64-bit limbs were measured first and not chosen (there, §13.1).
  • std.bigmod: 64-bit limbs read as unsigned words. CIOS with the reduction fused as a second carry chain: a step is two mul_wide and four add_carry, and neither chain’s sum can exceed two words. The reductions, additions and subtractions are add_carry and sub_borrow chains; a limb comparison is a borrow, never < on a signed int (a mutant that compares them as signed is killed by every RSA modulus, whose top limb has its top bit set). A byte no longer straddles two limbs. The register slot stays 139 words so every caller’s work keeps its layout. tests/vectors/bigmod_powers.txt gains 180 cases at moduli a multiple of 64 bits long (400 in all).
  • Poly1305 (std.chacha20): poly1305-donna-64’s three limbs of 44, 44 and 42 bits, nine mul_wide a block against 25 products, 20 r1 and 20 r2 made once per key, and a block loop (poly_blocks) that keeps the accumulator in registers from one block to the next instead of storing it in the state between calls; poly1305 and the AEAD’s poly_padded call it once for their whole blocks. The bounds, from the clamped key: a column under 2^92, a carry under 2^48, h1 under 2^44 + 48 between blocks.
  • One two-word accumulator. mac and addw are std.field25519_51‘s, made public; the P-256 kernels and Poly1305 call them (the duplication check found the copies). The program is one LLVM module, so this changed no instruction: the P-256 kernels’ x86-64 object code is the same before and after, instruction for instruction (kernels_object_identity.txt).

Why the gains differ from §11’s arithmetic. std.bigmod gained more than the k² count: its 30-bit loops were checked arithmetic over w[x + i] indices, each addition and index tested, which #397 measured at 2.4 times on P-256’s multiplication; the new loops are wrapping_* and the builtins over slices, and LLVM’s inner loop is two muls and their carries with no bounds check left in it. P-256 gained less: its field was already straight-line with a multiplication-free reduction (#397), and on x86-64 without BMI2 every mul writes rax:rdx, so the 35 muls of a multiplication keep the register allocator spilling (161 movs among about 440 instructions in mul_p); §10.6’s first item, mulx behind a CPU test, is where that would go.

12.2 Gates

  • Vectors, byte for byte, both backends where the suite runs both: tests/vectors/p256.txt (the kernels at their worst-case limbs), RFC 6979, Wycheproof ECDH (P-256, P-384), ECDSA and RSA, NIST CAVP KAS and SigVer, the RSA NIST files, RFC 8439’s ChaCha20-Poly1305 and Poly1305 vectors and Wycheproof’s, the new bigmod limb boundaries. cargo test --workspace in the arm64 Linux container at 541a03cc: 685 passed, and the one known container failure (project::a_compiler_other_than_the_one_named_is_refused, which needs a git checkout); CI runs both targets.
  • Differentials (assets/wide-multiply/381/differentials.txt, at d4436b62, before the shared accumulator, which changed no instruction; arm64 Linux, OpenSSL 3.0.13, pyca/cryptography 41): ECDSA signing against OpenSSL 10,000 signed and 10,000 flipped, against RFC 6979 in Python 10,000, keys 600 checks; ECDSA verification 4,000 a curve and registers 20,000; ECDH 1,000 key pairs a curve (4,200 checks); the P-256 field against Python 444,704 cases (LLVM) and 69,476 (Cranelift); pow_mod against Python 20,000 (LLVM) and 2,000 (Cranelift); RSA against OpenSSL 2,000 and 2,000 flipped; ChaCha20-Poly1305 against OpenSSL 20,000 cases, 60,000 checks (LLVM) and 2,000 (Cranelift), and after the rebase onto #463 20,000 cases with its in-place forms, 120,000 checks (aead_differential_rebased.txt); ECDH on Cranelift 100 key pairs a curve. After the rebase onto #465 (differentials_rebased.txt): signing 2,000 against OpenSSL and 2,000 against RFC 6979, verification 1,000 a curve and registers 5,000, ECDH 300 a curve, the P-256 field 152,967 cases, pow_mod 5,000, the AEAD 5,000 (30,000 checks). 0 differences in all.
  • Bounds: scripts/p256_gen.py proves every kernel’s accumulators and carries (above); scripts/p256_bounds.py closes for every formula, unchanged, since the element and §4.3’s invariants are.
  • Mutants, all killed or argued: p256_mutants.py, the 36 its kernel names select (32 on the kernels, the shared accumulator and the constants, rewritten for the new code, and four formula mutants whose names share a fragment), 36 killed at the head, and after the rebase onto #465 the whole script, 165: 155 killed, the 10 that survive the ones p256_mutants.py’s EQUIVALENT argues (#397’s and #465’s; none on the kernels) (p256_mutants_full_rebased.txt); rsa_mutants.py 26 of 26; ecdh_mutants.py 17 of 17 (one stale since #397 re-anchored, P-384’s encoding check reached again); chacha20_mutants.py 28 of 28 after the rebase onto #463 (whose in-place AEAD calls the same aead_tag; its eight mutants included), and one dropped as equivalent with its argument in the script (a single final carry pass: h1 cannot reach 2^44 after one).
  • Constant time: below.

12.3 Constant time

dudect on gram, LLVM, rdtscp, CPUs 0 and 1 (one physical core, both threads; the script and the program), governor powersave (not changed), 10^6 measurements a test, the classes interleaved; the bar is |t| < 4.5. Raw logs with the commit, machine, governor and load at start and end are in assets/wide-multiply/381/.

pathtestmedian (TSC cycles)max |t|
P-256 ecdh.public_key (b2651fea)scalar 1 / a fixed scalar / 0x88..88 / n − 1 / 16^63, each against random90,541 to 95,5171.35 / 1.61 / 1.29 / 1.78 / 2.10
P-256 ecdh.sharedscalar 1 / fixed / 16^63486,934 to 565,1732.14 / 1.59 / 1.98
P-256 ecdsa_sign.signa fixed key / the key 1213,032 / 215,3001.61 / 2.29
P-384 ecdh.shared (std.bigmod, 6b5299e5)scalar 1 / fixed / 16^952,478,068 to 3,571,8881.82 / 1.34 / 2.10
P-384 ecdh.public_key (the same)scalar 1 / fixed / 0x88..88 / n − 1 / 16^952,085,241 to 2,593,3541.79 / 1.46 / 2.50 / 1.72 / 2.43
ChaCha20-Poly1305, 64 bytes and 13 of associated data (541a03cc)seal: a fixed key / fixed data; open: fixed data / a tag wrong first or last, each against random (gcm_timing.py --chacha20)1,154 to 2,6222.45 / 2.02 / 1.55 / 1.69
the same at 1 KiB (--size 1024)the same four2,433 to 20,0222.11 / 2.18 / 2.31 / 2.08
#463’s in-place forms (--chacha20 --in-place), after the rebase (ed51aed6), 64 bytes, CPUs 0-1seal: fixed data; open: fixed data / a tag wrong first or last1,461 / 862 / 8837.52 / 2.86 / 1.77
the same, 1 KiBthe same three1,917 to 13,0532.08 / 1.54 / 1.35

The P-256 runs timed b2651fea; the kernels’ object code at the head is that commit’s, instruction for instruction (kernels_object_identity.txt). std.bigmod has not changed since 6b5299e5. P-384’s public_key tests ran last (stopped once to free the core for the other measurements, then run whole). Not run: Cranelift; Apple silicon.

One row failed the bar, and the harness was the cause. #463 (the in-place AEAD) merged while this was in review, and its --in-place test of ChaCha20-Poly1305 on the rebased branch gave 7.52 for “seal, data” in its first run. Alternating with main (#463, the old Poly1305), 64 bytes, 10^6 a test (dudect_inplace_main_vs_branch.txt), the branch then had a test at 4.5 or more in 4 runs of 15 (up to 19.58) and main in 1 of 14 (5.56), several in “open, tag”, whose two classes give Poly1305 the same bytes.

A control showed it (scripts/gcm_timing.py --control, each test’s second class timed against itself, so any |t| is the harness’s): on the in-place harness as #463 left it, 9.75, 9.10 and 13.89 in three runs of four, with identical records in both classes (dudect_inplace_control.txt). Each call read its nonce and associated data from its own class’s pool of records, so a class’s time depended on where its records sat, not on what they held (#466 found the same in its HMAC harness). The fix (tests/programs/gcm_timing.cho, this PR): every measurement copies its record into one set of buffers before the call, outside the time measured. After it the control is clean on both builds: 24 tests, largest 2.73.

On the fixed harness, main and the branch alternating (dudect_inplace_fixed_main_vs_branch.txt; CPUs 0-1, powersave, load average 1.0 to 4.4):

in place, ChaCha20-Poly1305runs a buildtestsat 4.5 or morelargest |t|
64 bytes, main (old Poly1305)103003.01
64 bytes, branch103002.68
1 KiB, main41203.13
1 KiB, branch41202.44

The branch passes every run. The two rows of the table above for #463’s in-place forms were taken on the old harness and are kept as what was measured.

The branch audit (scripts/chacha20_branches.py and p256_ctx.py over the x86-64 objects; assets/wide-multiply/381/branches*.txt): every P-256 kernel has exactly one conditional jump that is not to a trap, the je after cmp $0xa and setge on the three slice lengths, the never-taken test of §13.2 of p256-fast.md; canon_* none. In std.chacha20 (aead_driver.cho) none of the functions that do arithmetic on the key or the accumulator has one (poly_block, poly_finish, le64, shr44, shr42, field25519_51.mac and addw among them); poly_blocks’s jumps are its loop over the message’s length and the never-taken test of the block’s. In std.bigmod (ecdh_driver.cho), mont_mul, ct_reduce, add and sub have 29, every one read in the disassembly: the limb count k (test, cmp $1, its parity for the unrolled loops), loop counters, and LLVM’s overlap and remainder tests of the copy it vectorised; none tests a limb, a borrow or the mask, which is made without a branch (setb, sete, neg) and passes through value_barrier.

Multi-block AES-GCM and aggregated GHASH

Status: built (#382, part of #378). docs/crypto-builtins.md gave std.gcm the hardware AES and carry-less multiply instructions, one block at a time through out-of-line builtin calls, and reduced GHASH after every block. It reached 0.56 to 1.1 GB/s against OpenSSL’s 4 to 8. This document profiles where the time goes (§2), says what was built and computes what to expect from the measured parts (§3), and gives what it measured (§8) and what it did not do (§9). Where a later commit finds a claim here false, that commit corrects it here, in place.


1. What is asked

std.gcm’s hardware path calls aes_encrypt_block once for every 16 bytes of keystream and XORs the keystream in cancho, then calls ghash_update over the ciphertext, which multiplies and reduces for every block. The issue asks for the two things OpenSSL does that this does not: several blocks of counter mode at once (so that one block’s aesenc latency is hidden behind seven other blocks), and GHASH aggregated (one reduction for eight blocks, from precomputed powers of H held in the prepared key of docs/crypto-builtins.md §6). Expected: 2 to 4 times, “a hypothesis to measure first”.

Not asked for, and noted only: ChaCha20-Poly1305 for machines without AES instructions needs vector arithmetic (four ChaCha20 blocks in four lanes, or Poly1305 in vector multiplies), which the language does not have. It is the same decision docs/crypto-builtins.md §9 (3) and docs/chacha20.md §6 left open: a vector type is a language change (the checker, both backends, how a vector meets checked arithmetic), larger than anything here, and nothing in this document depends on it or makes it harder. The software AES-GCM path is unchanged, and so is what Cranelift and a CPU without the instructions run.

2. Where the time goes today

Method. tests/programs/gcm_cost.cho seals and opens records of 64, 1,024 and 16,384 bytes (13 bytes of associated data, a TLS record header’s length) under one prepared key, three runs of 64 MB, and prints the best. The split is a sampling profile of the same program: perf record on x86-64 Linux, sample on macOS. Cycles are perf stat -e cpu_core/cycles/u over the whole run divided by the bytes sealed and opened, so they do not depend on the clock the governor chose.

Machines. gram: Intel Core i7-1260P (Alder Lake, a P-core thread, taskset -c 7, the powersave governor: 1.9 to 2.0 GHz while measured), Linux x86-64, load average 6.5 to 9.5 from other users’ jobs, OpenSSL 3.5.5. This Mac: Apple M4 Max, macOS 26, load average 14 to 22 (other sessions). Neither was quiet, so each number below is the best of three runs, and the comparisons that matter are made on the same machine in the same minute.

2.1 What a record costs today (AES-128-GCM, a prepared key)

Sizegram: cycles/recordcycles/byteMac: ns/recordMac: MB/s
64 B83213.065897
1,024 B6,7726.61,556657
16,384 B102,3206.215,6251,048

(Seal and open cost the same within 5%: gcm_cost prints both.) On gram that is 6.2 cycles a byte, 100 cycles for every 16-byte block; OpenSSL 3.5.5 on the same core in the same minute does 0.55 cycles a byte at 16 KiB (424,608 operations of 16,384 bytes in 3.8 billion cycles). The gap is 11 times there. The first-order cost is the same on both CPUs, 65 cycles a block on the M4 Max at about 4.5 GHz and 100 on the i7.

2.2 The split

Share of the samples by function, seal and open together:

gram64 B1,024 B16,384 B
ctr_hw: the counter, the 16-iteration XOR loop with its checks, the call into the cipher32.7%36.7%39.9%
cancho_ghash_update: bit reversal of each block, four multiplies, the reduction27.8%27.1%27.7%
cancho_aes_encrypt_block: the AES instructions themselves16.0%33.7%17.5%
ghash_hw, tag_hw, seal_hardware, open_hardware: padding, the length block, the call glue15.7%1.7%8.5%
malloc and free (a region is one 64 KiB malloc)6.6%0.4%3.8%
Apple M4 Max64 B16,384 B
ctr_hw6%39%
cancho_ghash_update2%43%
cancho_aes_encrypt_block1%16%
malloc and free, and the kernel’s page-reclaim accounting under them88%3%

What that says:

  1. The instructions are the smallest part. The AES instructions are 16 to 18% of a 16 KiB record on both machines (the 1,024-byte row is an outlier of that one run; the 16 KiB and the 64 B rows agree with each other). The rest is cancho code and the way the builtins are shaped: a call a block, with eight length checks at the call site and a byte-at-a-time XOR loop (40%), and GHASH reduced a block at a time, with a 128-bit bit reversal of every block that the reflected form of §3.2 does not need (28 to 43%). The issue’s hypothesis that the call overhead matters is right, but it is not the cost of a call: it is that the keystream goes through memory and a cancho loop on its way to being XORed.
  2. A wider cipher builtin alone would not do. Eight blocks of aesenc per call with the XOR left in cancho would still leave the 40% in ctr_hw. The builtin has to take the data and the output and do the XOR itself.
  3. At 64 bytes the cipher is not the cost on either machine. On Darwin 88% of a 64-byte record is the region: 64 KiB malloc and free (docs/crypto-builtins.md §8.1 measured it at a third of a record; it is now most of one, with the page-reclaim trap under it). On Linux it is 6.6% and the rest is glue. So the hardware path must not allocate at all. That decides the shape of §3.4: no builtin needs scratch from its caller, and the tag is one call.

This redirects part of the work, as the issue asked it to if the profile said so: the multi-block builtins are the main part, but the larger gain at the record sizes a TLS connection spends most of its bytes on (1 to 16 KiB) is the XOR loop and the GHASH reductions, and at small sizes it is the allocation. All three are in this design.

2.3 The record layer around the cipher

tests/programs/record_cost.cho times tls_record.seal and tls_record.open (TLS_AES_128_GCM_SHA256, a prepared key) the same way, so the difference from gcm_cost’s row is what the record layer adds. It is measured here, before the change, as the issue asked, and again in §8.2 after it:

Apple M4 Max, before64 B1,024 B16,384 B
tls_record.seal, ns a record1,2352,12016,113
std.gcm’s seal alone (§2.1)6581,55615,625
the record layer’s own577564488

The record layer’s own cost is about 0.5 microseconds a record at every size. It is region r { ... } (one 64 KiB malloc and free, which is what 88% of a 64-byte record was on Darwin in §2.2), the byte-at-a-time copy of the plaintext into the inner plaintext, and the 12-byte nonce. Before this change it was a third of a 64-byte record and 3% of a 16 KiB one. After it, it is most of a small record (§8.2), which is the redirect the issue allowed for: it does not change what is built here, and the cipher was the right thing to fix first, since it was 53 to 97% of every row. What follows from it is a separate change to packages/tls/record.cho (an AEAD that seals in place, so no inner copy and no region), noted in §9 and not made here: it changes packages/tls, which other work is changing at the same time, and wants a design of its own (§9).

3. The design

3.1 aes_ctr32: counter mode, eight blocks at a time

aes_ctr32(round_keys: &[byte], rounds: int, nonce: &[byte], counter: int, input: &[byte], out: &![byte]) -> [] int

out[i] = input[i] XOR E(nonce || be32(counter + i / 16))[i % 16] for every i, where E is AES under round_keys (rounds + 1 blocks of 16 bytes, rounds 10, 12 or 14, as aes_encrypt_block takes them), nonce is 12 bytes, and the counter is a 32-bit value that wraps modulo 2^32 as SP 800-38D’s inc32 does. input and out are the same length, which is any length (the last block may be partial). It answers 0.

Eight 16-byte blocks are 128 bytes: the loop builds eight counter blocks (the nonce with a counter lane, added in the machine’s byte order and swapped to big-endian by one byte shuffle), runs them through the rounds together, XORs 128 bytes of input, and stores 128 bytes of output. Eight is what keeps aesenc busy: it takes 3 to 4 cycles to answer on Golden Cove and Apple’s cores take 3, and issues two a cycle on Golden Cove, so one block in flight leaves the unit at 1/6 and eight fill it. (OpenSSL’s AES-NI code interleaves six and eight.)

The last len mod 128 bytes use the same eight-block cipher into a 128-byte stack buffer, XOR the whole 16-byte blocks of it, then the remaining bytes one at a time, then overwrite the buffer with volatile stores, since the keystream is secret. Every loop bound and every branch is on len, which is public (§5). Computing eight blocks for a one-block tail costs about the latency of one, so a 64-byte record does not pay for being short.

3.2 GHASH: one reduction for eight blocks, in the reflected order

A GCM block is a polynomial of degree under 128 in which the first bit is the coefficient of x^0. The existing builtin reversed the bits of every block (and of H, and the state) to get the natural order, which a bit reversal on a 128-bit integer makes expensive. The reflected form of Gueron and Kounavis needs none: read the block as a big-endian integer (one byte reversal), so that bit 127 - i is the coefficient of x^i, and multiply as integers.

With a and b in that form, clmul(a, b) has the coefficient of x^m at bit 254 - m. Read as a 256-bit window with its top bit as x^0 (bit 255 - m is x^m), that is x times the product, so the product of a and b * x^-1 is the window’s polynomial exactly: store each power of H times x^-1 (H^k shifted left by one, with 0xC2000...01, which is x^127 + x^6 + x + 1 and so x^-1 itself, XORed in if a bit left the top) and no product is ever shifted. The upper 128 bits of the window are then the polynomial’s coefficients 0 to 127 in the same order and the lower 128 bits are coefficients 128 to 255 (the part to fold back by x^128 = x^7 + x^2 + x + 1).

The reduction is the one the Linux kernel’s ghash-clmulni-intel uses, on 64-bit lanes only (psllq, psrlq and one byte shift of the whole register: on aarch64 shl, ushr and ext), with (T1 : D) the window, D the lower half:

phase 1:  T3 = ((((D << 1) ^ D) << 5) ^ D) << 57          every shift of each 64-bit lane
          D  = D ^ (T3 moved up one lane)                  T1 = T1 ^ (T3 moved down one lane)
phase 2:  T2 = ((((D >> 5) ^ D) >> 1) ^ D) >> 1
          result = T1 ^ D ^ T2

The first phase folds each lane’s low bits into the other lane, the second is D ^ D>>1 ^ D>>2 ^ D>>7 (the polynomial’s shifts 0, 1, 2 and 7) added to the upper half. An earlier version of this builtin did the same fold on 128-bit integers in the general registers, from a left shift of every product by one: the vector version is the same arithmetic in about half the instructions and none of the transfers between register files (a 2% faster 16 KiB record on x86-64, measured; the reduction was a third of that record’s cycles when removed outright, 0.23 of 0.68 a byte, so most of it is not the arithmetic but the multiplies and loads around it).

This was checked against SP 800-38D §6.3’s bit-by-bit algorithm in a model (300 random single products and an eight-block aggregation) before any IR was written: scripts/ghash_reflected_model.py, whose steps are the code’s.

Aggregation. The recurrence Y_i = (Y_{i-1} ^ X_i) * H unrolls over eight blocks to Y_8 = (Y_0 ^ X_1) * H^8 ^ X_2 * H^7 ^ ... ^ X_8 * H. Carry-less multiplication distributes over XOR, so the eight unreduced 256-bit products can be summed, and the fold is done once for the sum. For the product of two 128-bit values the builtin keeps three accumulators, the sums of the four 64-by-64 products: low halves, high halves, and the two cross terms together; the 256-bit window is lo ^ (mid << 64) ^ (hi << 128) and is built once per group. Four pclmulqdq (or pmull) a block and no Karatsuba: Karatsuba saves one multiply a block for extra XORs and a table of the halves’ sums, and is a measured option for later (§8), not a first design.

A group of fewer than eight blocks (the end of a message, a short record, the associated data) uses the same code with the powers shifted: a group of n blocks multiplies block j by H^(n-j), so groups of 8, 4, 2 and 1 are four short pieces of one routine, all reading the same table. The tail of m blocks (1 to 7) is split by the bits of m into groups of 4, 2 and 1: at most three reductions, not seven.

The table. ghash_powers computes H^1 to H^8 once per key (each the previous times the twisted H) and stores each times x^-1, 16 bytes, H^1 first.

3.3 The prepared key (hw_len())

The hardware part of a prepared key (std.gcm‘s hw, held by the record layer per direction) was 256 bytes: the round keys in FIPS 197’s form (up to 240) and H. It becomes 368 bytes: the 240 bytes of round keys, then the eight powers (128 bytes), H being the first power. hw_len() is that number and the callers that size by it follow it. The one that does not is packages/tls/slot.cho, which lays the two directions’ prepared keys out at fixed offsets (k_read_hw, k_write_hw, keys_len); it moves by 112 bytes per direction, 224 per connection. The software context (context_len() words) and the software path are unchanged, and forget still overwrites both. Nothing in this design needs the word layout of the software path to change, so a CPU without the instructions, and Cranelift, run today’s code.

3.4 ghash_powers, gcm_tag and gcm_tag_diff: the tag in one call, with no scratch

ghash_powers(round_keys: &[byte], rounds: int, table: &![byte]) -> [] int
gcm_tag(round_keys: &[byte], rounds: int, table: &[byte], nonce: &[byte], aad: &[byte], text: &[byte], tag: &![byte]) -> [] int
gcm_tag_diff(round_keys: &[byte], rounds: int, table: &[byte], nonce: &[byte], aad: &[byte], text: &[byte], expected: &[byte]) -> [] int

ghash_powers fills the 128-byte table with the twisted powers of §3.2 of H = AES(K, 0), which it makes from the round keys in a register and never stores: (changed after review: the first version took H as a 16-byte slice, which std.gcm made in a region and left there when the region was freed, unwiped. Now no copy of H exists in memory but its powers in the prepared key, which forget overwrites; a unit test reads the generated function for its eight stores.) gcm_tag writes the 16-byte tag of text (the ciphertext) and aad under nonce: GHASH of aad, zero-padded to a multiple of 16, then of text, zero-padded, then the length block (both lengths in bits, 64 each), XORed with AES(K, nonce || 1). The encryption of nonce || 1 is issued first so its latency runs under the hashing. gcm_tag_diff computes the same tag and answers 0 if it equals expected and nonzero if not, by ORing the two halves of the XOR of the tags, so every byte counts whatever the earlier ones were.

Why not ghash_update with a table. The previous builtin, ghash_update, left the padding, the length block, the mask, the scratch for each of them and the comparison to cancho. Every one of those needed a 16-byte buffer, hence a region, hence 88% of a 64-byte record on Darwin (§2.2). With gcm_tag and gcm_tag_diff, seal is two calls and open is two calls, and std.gcm’s hardware path allocates nothing. The old ghash_update is retired, not removed: std.gcm no longer calls it, but a program written against it still reads and runs (docs/editions.md §7). (The first version removed it within edition 7; the stability gate, docs/stability-exemptions.md, showed std/gcm.cho’s own earlier revision no longer reading. It is now one function of a block at a time on the new reduction, emitted only where it is called.) aes_encrypt_block stays (the key schedule’s H, and a single block for a test), and so does hw_aes_gcm.

seal_hardware: aes_ctr32(keys, nr, nonce, 2, plaintext, out[0..n]), then gcm_tag(..., out[0..n], out[n..n+16]). open_hardware: gcm_tag_diff(..., sealed[0..n], sealed[n..n+16]); if it is not 0, the refusal and nothing written (as before); else aes_ctr32(keys, nr, nonce, 2, sealed[0..n], out).

3.5 What the expected gain is, from the measured parts

At 16 KiB the measured 100 cycles a block are 17.5 for the instructions, 28 for GHASH and 55 for the cancho code and the call around the cipher. The design replaces them with:

  • Counter mode: aesenc issues two a cycle on Golden Cove and one on Apple’s cores (Apple issues more per cycle but at a higher clock; call it 1.5): AES-128 is 10 rounds of 8 blocks, 80 instructions per 128 bytes, about 40 cycles a group, or 5 a block (0.31 cycles a byte), plus the counter arithmetic and the XOR, 1 to 2 a block. About 7 cycles a block.
  • GHASH: four multiplies a block at one a cycle is 4, the accumulating XORs 3, the byte reversal and loads 1, and the shift-and-fold, about 40 cycles for a group, 5 a block. About 9 cycles a block.
  • Together, if the two passes do not overlap: about 16 cycles a block, 1 cycle a byte, 6 times faster than today’s 6.2; the out-of-order core overlaps the tail of one pass with the head of the next, so somewhat better. OpenSSL stitches the two into one loop and reaches 0.55; this design does two loops over a buffer that stays in L1 (16 KiB of a 48 KiB L1d), and does not expect to match it. Predicted: 5 to 8 times at 16 KiB, 0.7 to 1.2 cycles a byte; well above the issue’s 2 to 4.
  • 1,024 bytes: the same per-block cost plus a fixed cost of two calls and the 40-cycle latency of the first block and of AES(K, J0), about 150 cycles in all: about 1 to 1.2 cycles a byte, 5 to 6 times faster than today’s 6.6.
  • 64 bytes: no allocation, two builtin calls, four blocks of AES and five of GHASH: about 150 to 250 cycles a record on x86-64 (4 times), and about 40 ns on the M4 where today’s is 658 ns (15 times, for the malloc alone is 580).

(These were computed from the baseline’s parts. A first prototype of the IR was already running when this document was written, and §8 says what it and the finished code measured.)

4. The builtins in both backends

The four new names are edition 9, the latest: edition 7 is closed (docs/editions.md §6.4) and each is a name a program could already declare, so std/gcm.cho declares edition 9. (Corrected before merge: written while edition 7 was still open, the first version put them there.) ghash_update, retired and kept, stays edition 7.

BuiltinTypeRegions
aes_ctr32(&[byte], int, &[byte], int, &[byte], &![byte]) -> [] intkeys, nonce, input, out
ghash_powers(&[byte], int, &![byte]) -> [] intkeys, table
gcm_tag(&[byte], int, &[byte], &[byte], &[byte], &[byte], &![byte]) -> [] intkeys, table, nonce, aad, text, tag
gcm_tag_diff(&[byte], int, &[byte], &[byte], &[byte], &[byte], &[byte]) -> [] intkeys, table, nonce, aad, text, expected

All are [] (pure): no capability is added, and the pure backend’s cancho authority shows no ffi(...) as before.

LLVM (crates/cancho-codegen-llvm/src/crypto.rs, crypto/aes.rs, crypto/ghash.rs, body/crypto.rs). As in docs/crypto-builtins.md §4, each builtin is a call to an out-of-line function defined only in a module that calls it, carrying the target features on its own definition, never the module’s: +aes,+pclmul,+ssse3,+sse2 on x86-64 and +aes,+neon on aarch64. The functions that hold the intrinsics but are shared (@cancho_aes1, @cancho_aes8, the GHASH group routines and @cancho_gh_reduce) are alwaysinline with the same features, so they fold into the one out-of-line function that calls them and never into a caller at baseline. The call site checks every length and the round count, trapping as an out-of-bounds index does (rounds is 10, 12 or 14 and the key 16 * (rounds + 1) bytes; nonce 12; table 128; h and tag 16; counter in 0..2^32; input and out equal), then calls. No input reaches those traps: the lengths are fixed by the suite (std.gcm refuses a bad key or nonce with its rule tag first), the counter is 2 and the lengths are the caller’s slices’. On a target without the instructions (hardware_target false) the call site is a trap. The vectors are all <2 x i64> in the text and the aarch64 ones bitcast around the intrinsic, which costs nothing. The x86 multiplies are llvm.x86.pclmulqdq with the half selected by the immediate; the aarch64 ones llvm.aarch64.neon.pmull64 on the extracted lanes, which LLVM selects as pmull2 when both are the high halves.

Cranelift (crates/cancho-codegen/src/body/expr.rs). As for the two builtins of docs/crypto-builtins.md §5: no instruction exists, hw_aes_gcm() answers false, std.gcm takes the software path, and a call that is reached anyway traps. WebAssembly has none either (target.rs: HwAesGcm and the rest are on the list of builtins that need no host).

5. Constant time

  • No branch or index depends on a secret. The key, the counter blocks, the keystream, H and its powers, the state and the tag are only ever operands of aesenc/aese, pclmulqdq/pmull, XOR, constant shifts and shuffles. Every loop bound and every conditional jump is on a length (len, the number of 128-byte groups, the bits of the tail’s block count). A length is public: the record layer sends it in the clear. The audit of the object code (§7) reads every conditional jump that is not a trap and names what it tests.
  • The tail. A message whose length is not a multiple of 128 is finished by a fixed-shape path whose shape depends only on len mod 128: eight blocks of keystream computed whatever the tail’s length, then 16-byte XORs and byte XORs counted by the length. The GHASH tail is padded with zeros in a stack buffer and then goes through the same group routines as full blocks; a ragged last block is the only branch (on len mod 16).
  • Nothing is called. The builtins’ functions call each other and nothing else: no library code, whose branches the audit does not read. (Corrected after review: the first version padded the GHASH tail with memset and a memcpy of variable length, which compiled to calls into libc. The data there is public, so it leaked nothing, but it was code no check here read.) A unit test fails on a call in the generated text to anything but the builtins’ own functions and the intrinsics that are instructions, and scripts/gcm_branches.py fails on a call in the object (only the CPU probe calls the operating system).
  • Tag comparison. gcm_tag_diff XORs the two tags and ORs the halves: no early exit. Its caller branches on the answer, which is public by construction (it is whether the record authenticated), as the previous comparison’s was.
  • What is left behind, and what is not. The keystream tail buffer is overwritten before the function returns (volatile stores; a unit test reads the generated text for them). The ciphertext copied for padding is public. The table of powers is part of the prepared key and forget overwrites it. Registers the compiler spills to the builtin’s own stack frame (round keys, counter blocks) are not erased, as in every compiled implementation, OpenSSL’s included; that was equally so for the builtins this replaces.
  • DIT. On aarch64 the LLVM backend sets PSTATE.DIT in main (docs/crypto-builtins.md §6, dit.rs). This design does not touch that code, and the builtins’ instructions (aese, aesmc, pmull, eor, rev, ext, shl) are all in the DIT list of the Arm ARM or data-independent by definition; the timing run is repeated on the M4 with it set (§8.3).

6. The tag and the associated data for lengths that are not a multiple of 16

GCM pads the associated data and the ciphertext each to a multiple of 16 with zeros and hashes them one after the other, then the length block. @cancho_gh_absorb does one of them: whole groups of eight go straight from the caller’s slice; the rest (under 128 bytes) is read from the slice when its length is a multiple of 16 and otherwise copied into a 128-byte stack buffer by a copy of one shape whatever the length: eight 16-byte loads, each from the data when its block lies before the partial one and from a block of zeros otherwise, then the partial block’s sixteen bytes the same way, the address of each chosen by a select on a comparison with the length, so nothing is read outside the data and nothing is called. Either way the group routines see whole 16-byte blocks and the block count is ceil(len mod 128 / 16), up to eight. The length block is built in registers (text bits in the low lane, associated-data bits in the high one: the reflected form of the big-endian aad_bits || text_bits), so there is no buffer for it. An empty aad or text hashes nothing. The 32-bit counter of aes_ctr32 starts at 2 for the data and AES(K, nonce || 1) is the tag mask, as before; the largest message under one nonce stays 2^32 - 2 blocks (gcm.max_text()), so the counter never wraps for a correct caller, and the wrap is nevertheless defined (§3.1) and tested.

7. How it is checked

  • Known answers and a differential on every length, in the compiler’s own suite (crates/cancho/tests/conformance/crypto_builtins.rs, through tests/programs/crypto_builtins_driver.cho): FIPS 197, NIST’s test case 2 from the builtins, and references written from FIPS 197 and SP 800-38D in the test: aes_ctr32 on every length 0 to 300 and with counters about to wrap; the eight powers; gcm_tag on every text length 0 to 300 with associated data of lengths 0 to 70, every associated-data length 0 to 300, and a spread up to 16 KiB; gcm_tag_diff equal and with one bit changed; every wrong length, counter or round count a trap. Each output is followed by 32 guard bytes that must stay as they were.
  • std.gcm’s vectors (FIPS 197, CAVP, Wycheproof; conformance/gcm.rs) on both paths and both backends, as before: the hardware path now runs them through the new builtins.
  • scripts/gcm_differential.py against OpenSSL (pyca/cryptography) and the software path: 10,000 random cases and a sweep of every message length 0 to 600, every associated-data length 0 to 300, and the lengths around 4 KiB and 16 KiB, both directions, a flipped bit refused, 60,950 checks.
  • scripts/gcm_ctgrind.sh (Valgrind’s Memcheck, aarch64 Linux): the key and the message marked undefined, a seal and an open for AES-128 and AES-256 at 0, 64, 1,000 and 16,384 bytes: no report from the preparation or the seal, and exactly one from the open, the branch on whether the tag matched (§5). Then a sweep of every length in buffers of exactly that size for an Invalid read or Invalid write: an input read past its end, or an output written past it, which no answer shows.
  • scripts/gcm_mutants.py (the cancho of std/gcm.cho and std/aes.cho: the hardware path’s mutants follow the new code) and scripts/gcm_wide_mutants.py (the Rust that writes the IR: 61 mutants of the counter blocks, the cipher, the reduction, the powers, the tag, the tail and the call sites’ checks, run on each instruction set).
  • scripts/gcm_timing.py, the dudect test, at 10^6 measurements a test on x86-64 (|t| below 4.5), and scripts/gcm_branches.py, the object-code audit.
  • scripts/aead_differential.py, scripts/tls_record_differential.py, the lying server’s 84 recordings, and the TLS suites that negotiate AES-GCM (the interop matrix, the differential beside openssl s_client).
  • cargo fmt --all --check, cargo clippy --all-targets -- -D warnings, cargo test --workspace, every .cho file checked by the new and the old compiler with the same answer (but the driver that uses aes_ctr32), the package stores republished (scripts/publish_packages.py --check), the selfhost tables regenerated.

8. Results

8.1 What a record costs, before and after, beside OpenSSL

scripts/gcm_cost.py: tests/programs/gcm_cost.cho built by the compiler before this change (origin/main at 7bf3628) and after it, and openssl speed -evp aes-128-gcm aes-256-gcm; a seal with a prepared key and 13 bytes of associated data. Each of gcm_cost’s runs lasts at least a second (its clock is clock_ms; corrected after review: the first measurements here used runs of about 20 ms, ±5% on that clock), three runs of each program in turn, and the best is kept. MB/s are 10^6 bytes a second. The raw outputs, with the commit, machine and load, are in docs/assets/gcm-wide/.

The 64-byte column is not a comparison with OpenSSL. openssl speed sets up an EVP context for every message on some builds (the Linux x86-64 one: 19 cycles a byte at 64 bytes, against 2 at 1 KiB) and not on others, so its 64-byte figure measures a different thing. It is given, and not divided.

Apple M4 Max, macOS 26, load average 9 (other sessions’ work, and this branch’s mutants in a Docker VM), OpenSSL 3.6.4:

AES-128-GCM sealbeforeafterafter / beforeOpenSSLafter / OpenSSL
64 B98 MB/s1,739 MB/s17.7(595)
1,024 B6626,0049.15,5531.08
16,384 B1,0286,6806.510,3050.65

AES-256-GCM: 97 to 1,746 MB/s, 635 to 5,469, 972 to 5,970; OpenSSL 5,029 at 1 KiB (1.09) and 8,735 at 16 KiB (0.68).

aarch64 Linux, the same M4 Max in Docker (lexsys-hooks-env, 6 cores, load average 3), OpenSSL 3.0.13:

AES-128-GCM sealbeforeafterafter / beforeOpenSSLafter / OpenSSL
64 B560 MB/s1,681 MB/s3.0(3,993)
1,024 B1,0776,0715.67,5360.81
16,384 B1,1406,7335.98,0620.84

AES-256-GCM: 533 to 1,615, 998 to 5,494, 1,060 to 6,041; OpenSSL 7,353 at 1 KiB (0.75) and 8,093 at 16 KiB (0.75).

Linux x86-64, ssh gram: Intel Core i7-1260P, one P-core thread (taskset -c 7), OpenSSL 3.5.5. During this run another user’s compiler jobs were pinned to the same core pair (6 and 7) and the load average was 10, so the MB/s it printed are not worth quoting (cost-linux-x86_64-gram.txt has them). Cycles a byte (perf stat, user cycles, divided by every byte sealed and opened; the best of three) do not depend on how much of the core the program got:

cycles a bytebeforeafterafter / beforeOpenSSLOpenSSL’s cycles / ours
AES-128, 64 B20.94.175.0(19.0)
AES-128, 1,024 B9.041.088.42.091.9
AES-128, 16,384 B8.200.948.70.890.94
AES-256, 1,024 B9.721.237.92.261.8
AES-256, 16,384 B8.991.098.21.020.94

(A quieter session before review, with the same code but for §3.2’s reduction, gave 0.68 cycles a byte at 16 KiB against OpenSSL’s 0.55 in the same minute; the two have kept a ratio of 0.8 to 0.94 across every session.)

What these say:

  • 16 KiB: 5.9 to 8.7 times faster (the issue expected 2 to 4), 0.94 cycles a byte on x86-64 where today’s was 8.2: 94% of OpenSSL’s speed on x86-64 (by cycles), 84% on aarch64 Linux and 65% on the M4 under macOS.
  • 1 KiB: 5.6 to 9.1 times. Against OpenSSL’s speed: 1.08 on the M4 under macOS, 1.9 on x86-64 (by cycles), and 0.81 on aarch64 Linux, below it. (Corrected after review: the first version of this section said “at or above OpenSSL’s” at 1 KiB while its own aarch64 Linux figure was 89%.)
  • 64 B: 3.0 to 17.7 times faster than before. A small record now costs about 270 cycles on x86-64 and about 37 ns on the M4, in two builtin calls with nothing allocated. The fixed-shape tail copy of §6 (after review) costs some of that: the aarch64 Linux figure was 2,236 MB/s with memcpy and is 1,681 without.
  • Predicted (§3.5): 5 to 8 times at 16 KiB, 0.7 to 1.2 cycles a byte; 5 to 6 times at 1 KiB; about 4 times at 64 B on x86-64 and about 15 on the M4. Measured: 5.9 to 8.7, 5.6 to 9.1, 5.0 and 17.7. The prediction held.
  • Where the rest of the gap to OpenSSL is. On the M4 a sampling profile of a 16 KiB seal is 60% GHASH and 40% counter mode. Both are two passes over the record, not the one stitched loop OpenSSL’s aes-gcm-armv8 and aesni-gcm run, in which the multiplier and the AES units work at the same time. Removing the reduction altogether on x86-64 (a measurement, not a build) gave 0.45 cycles a byte against 0.68, so the multiplies and the loads are most of what GHASH costs. Karatsuba and stitching are the next steps and each is its own change (§9).

8.2 The record layer, before and after

tests/programs/record_cost.cho, tls_record.seal and open (TLS_AES_128_GCM_SHA256, a prepared key), runs of a second, ns a record, before this change / after it (docs/assets/gcm-wide/record-macos-m4.txt; the x86-64 row is from a session before review, with runs of 20 to 50 ms):

64 B1,024 B16,384 B
Apple M4 Max, seal1,204 / 5922,102 / 74616,057 / 3,293
Apple M4 Max, open1,209 / 5852,086 / 72215,894 / 3,002
gram (x86-64), seal377 / 1503,723 / 39653,710 / 4,394

A TLS record seals 2 to 12 times faster. What dominates it now is the record layer, not the cipher (§2.3): on the M4 a 64-byte record is 592 ns of which the cipher is 37, and a 16 KiB one 3.3 microseconds of which it is 2.5. The cost that remains is a region (a 64 KiB malloc and free, slow on Darwin), the byte-at-a-time copy of the content into the inner plaintext, and the nonce. Removing them needs an AEAD that seals in place (the builtin writing the ciphertext over the plaintext), which is a change to packages/tls/record.cho and to the builtins’ contract, and is §9’s first item.

8.3 The gates

After review the code changed (ghash_powers takes the round keys, the GHASH tail copy has a fixed shape), so every gate below was run again on that code (the compiler of commit 12f343e6, which later commits change only in tests, scripts and documents).

  • Known answers and the differential (§7): the compiler’s suite on this Mac (aarch64; cargo test --workspace: 1,238 passed and 1 failed, backends::spawn_and_join_run_concurrently_not_sequentially, a timing test that fails the same way on origin/main on this loaded machine) and on CI’s x86-64 and aarch64 runners. scripts/gcm_differential.py, every case under both key sizes: 121,900 checks, 0 differences on x86-64 (OpenSSL 3.5.5) and on aarch64 (OpenSSL 4.0.3), the software path included. Before review, and not repeated since the TLS code did not change: aead_differential.py 15,000 checks and tls_record_differential.py 3,000 rounds on each machine, 0 differences; the lying server’s 84 cases, its recording byte-for-byte the committed one; the interop matrix (tls_interop.py: Go, rustls, wolfSSL, mbedTLS, Botan, BoringSSL, nginx, GnuTLS) 115 rows ok, 0 failed on aarch64 Linux; tls_differential.py: 21 handshakes agree, 0 differ; 59 agree, 17 differ in the alert only, 8 as documented, 0 otherwise. CI’s tls-assurance job runs all of these again.

  • Timing (scripts/gcm_timing.py, x86-64, gram, taskset -c 7, 10^6 measurements a test, 13 bytes of associated data). Added after review: --prepared, which times seal_with and open_with on a key prepared once, so that what is timed is the two builtin calls and nothing else (the one-shot seal and open time the key preparation and a 64 KiB region too, and the new code is a small part of them). Max |t|, all under 4.5:

    seal, dataopen, dataopen, tag
    prepared, 64 B2.811.902.71
    prepared, 1,000 B1.174.072.60
    prepared, 16,384 B1.723.213.62
    one-shot, 64 B (and “seal, key”: 1.25)0.952.282.08

    The raw outputs, with commit, machine and load, are docs/assets/gcm-wide/timing-*.txt. Another user’s jobs shared the core during these runs (load average 6 to 10), which inflates the medians (the 16 KiB seal’s median was 92,950 cycles in the 10^6 run and 13,770 to 14,428 in two runs of 30,000 a little later) and not the classes’ difference. On the M4 under macOS, prepared, 20,000 a test: 16 KiB seal 8.77 in one run and 2.20 and 2.19 in the two after it, 4 KiB 1.02, 1 KiB 1.67; the M4 was loaded by other sessions and one run of 20,000 is not the gate. Before review, one-shot at 1,000 and 16,384 bytes and on the M4: 1.54 to 2.79.

  • Object-code audit (scripts/gcm_branches.py, x86-64): 70 conditional jumps that do not go to a trap in seal_hardware, open_hardware and the five builtin functions (12, 11, 23, 9, 5, 5, 5), each after a comparison of registers and constants, never of memory: lengths, counts made from them, loop counters, the round count. No call from any of them but to the builtins’ own functions (added after review).

  • ctgrind (aarch64, Valgrind’s Memcheck): 0 reports from the preparation and the seal in 8 cases (AES-128 and AES-256 at 0, 64, 1,000 and 16,384 bytes), 1 from each open (the tag branch); 0 invalid reads or writes over every length in exact-size buffers.

  • Mutants: gcm_mutants.py: 34 of 34 killed on aarch64 and on x86-64 (the hardware path’s 8 among them, one of them now “H made with AES-128’s round count”). gcm_wide_mutants.py: 63 mutants of the generated code (60 run on each instruction set: 57 shared and 3 of each), 58 killed and 2 argued equivalent on each, aarch64 Linux and x86-64. New after review: H stored in memory (killed by the unit test that counts the stores), and three of the fixed-shape copy (the partial block’s bytes taken one past the length, a whole block of the tail taken from the zeros, the partial block written at the wrong place). The two equivalent ones are the tail’s whole-block loop counted short and the tail’s byte loop started early: the byte loop finishes every byte the first leaves, with the same keystream, so only the work moves between them. One that survived the conformance test before, the 64-byte group counted for the 128-byte one (a wrong answer nowhere, a read and a write past the buffer), is what the guard bytes and Memcheck’s sweep were added for.

8.4 The TLS primitives’ guard, re-recorded

scripts/tls_perf.py check (docs/tls-performance.md §6, #391) counts the instructions a call of each TLS primitive executes, under callgrind, against benches/tls_baseline.json, two-sided at 1.3 times. With this change 27 of its 33 rows stayed within it and the six AES-GCM ones (aes128gcm_seal and _open at 64, 1,024 and 16,384 bytes) fell below 1/1.3, which is the guard working: §1.3 of that document asks a change that makes a primitive cheaper to re-record its baseline in the same change. Those six rows, and only those, were re-recorded, each on the machine its entry names (linux-x86_64 on gram with valgrind 3.26, linux-aarch64 in the lexsys-hooks-env container on the M4 with valgrind 3.22); aes128gcm_prepare and the other 26 rows were within 1.01 of the recorded values and are untouched. Instructions a call, recorded before / after:

rowlinux-x86_64linux-aarch64
seal, 64 B3,620 / 7382,989 / 766
seal, 1,024 B25,700 / 3,14121,349 / 3,461
seal, 16,384 B378,980 / 43,463315,109 / 48,545
open, 64 B3,756 / 7463,129 / 771
open, 1,024 B25,836 / 3,14921,489 / 3,459
open, 16,384 B379,116 / 43,471315,249 / 48,441

(The time quotients the guard falls back to without valgrind were re-recorded for the same rows on the same machines.)

9. Not done, and what fell short

  1. The record layer is now the cost of a TLS record (§8.2). A change to packages/tls/record.cho that removes the region and the byte-at-a-time copy of the content wants the cipher to seal in place: aes_ctr32 with the input and the output one slice (a contract the borrow rules cannot express as two arguments, so a builtin of its own or a gcm.seal_in_place), and the content type written after the content in the output buffer. It is a design of its own, in a package other work is changing, so it is not made here. Until it is, a 64-byte record costs 600 ns on Darwin whatever the cipher does, and https_hello and tls_echo (the 83 to 245 MB/s of the issue) are not remeasured here: they would show the record layer.
  2. Stitching AES and GHASH, and Karatsuba. The two passes over a record leave the carry-less multiplier idle while the AES units work and the reverse; OpenSSL runs them in one loop. That is the 6% (x86-64, 16 KiB, by cycles) to 35% (M4 under macOS) that separates this from OpenSSL, and §8.1 says where the M4’s time goes (60% GHASH). Three multiplies a block for four, with the sums of the halves of each power in the table, would cut GHASH’s multiplies by a quarter; both are measured options.
  3. ChaCha20-Poly1305 for machines without AES instructions (the issue’s second bullet) needs vector arithmetic; noted in §1, not decided.
  4. x86-64 under Valgrind. gram has no Valgrind, so the ctgrind and addressability checks ran on aarch64 Linux only, with the same IR generator for the shared parts; x86-64’s evidence is the dudect test (10^6 measurements a test), the object-code audit, and the conformance suite on a real x86-64 CPU (gram, and the CI runner).
  5. A prepared key is 224 bytes larger a connection (hw_len() 256 to 368 bytes, in each of two directions; packages/tls/slot.cho’s offsets moved and the package stores were republished). Other changes to the slot will conflict with it mechanically; the offsets are k_read_hw(), k_write_hw() and keys_len().
  6. Numbers on shared machines. The M4 and gram were loaded by other work throughout (load averages 17 and 4 to 8) and gram’s governor moved the clock between 1.9 and 4.5 GHz; a quiet machine would show higher absolute figures. The cycles a byte on x86-64 are the steadier measure, and the ratio to OpenSSL’s, taken in the same minute, stayed within 0.8 to 0.94 at 16 KiB.
  7. The compiler’s cargo test --workspace on Linux ran on CI’s x86-64 runner, which passed; the aarch64 Linux container this work used had a full disk and a host-mounted work directory, on which 13 filesystem tests fail (11 of them also on origin/main in the same container; the other two write /tmp, which was full). Nothing in them is near this change.

SHA-256 on the CPU’s instructions, and HMAC with the key’s pads kept

Status: built. Issues #445 (lever D3 of tls-performance.md §10.8: a SHA-256 builtin) and #446 (lever D4: HMAC with the key pads kept), part of #378 (TLS performance). §2 is what was measured before the design was fixed; §3 to §6 are the design; §7 is how it is tested; §8 is what it measured as built. The HMAC half is hkdf.md §9, which owns std.hmac and std.hkdf; this document says only how the two halves meet. Where a later change finds a claim here false, it corrects it here, in place.


1. What is asked, and why

std.crypto’s SHA-256 is scalar code: tls-performance.md §10.2 measured it at 4.5 to 9.4 times OpenSSL’s cycles on x86-64 (SHA-NI) and 5.9 to 11.3 times its time on arm64 (ARMv8 SHA-2), and §10.4 found its compression function at 7.3% of a full server handshake, 11.6% of a resumed one and 3.8% of a client’s (self time, x86-64), because the transcript and every HMAC of the key schedule run through it. §10.8 ranked a builtin at about 6.5 points of a full server handshake and 10.3 of a resumed one (arithmetic, at OpenSSL’s speed a block), and HMAC’s per-call pad hashing at most 1.8 and 3.7 points.

Both CPUs this repository runs on have SHA-256 instructions: x86-64’s SHA extensions (sha256rnds2, sha256msg1, sha256msg2; sha_ni in /proc/cpuinfo on the i7-1260P of tls-performance.md §2.1) and aarch64’s (sha256h, sha256h2, sha256su0, sha256su1; hw.optional.arm.FEAT_SHA256 reads 1 on the M4 Max). As with AES (crypto-builtins.md §1), the language cannot name them: its only vector-shaped data is a slice.

Not asked for, and not done: SHA-384 and SHA-512 (aarch64 has instructions for them with FEAT_SHA512, which the M4 reports; the i7 of tls-performance.md §2.1 has none; and SHA-384 is 1.4 to 3 times OpenSSL already, §10.2), a vector type (§3.3), any call into C (the builtins are pure, [], as the AES ones are).

2. What was measured before the design was fixed

LLVM emits the instructions from intrinsics, only with the feature on the function. llvm.x86.sha256rnds2, llvm.x86.sha256msg1 and llvm.x86.sha256msg2 with "target-features"="+sha,..." on x86-64, and llvm.aarch64.crypto.sha256h, sha256h2, sha256su0 and sha256su1 with +sha2 on aarch64, as the AES builtins do: compiled by clang 21.1.8 on the i7, Apple clang 21.0.0 on the M4 and clang 18.1.3 in the arm64 VM, and the first test program (FIPS 180-4’s abc and two-block examples through both builtins) gave the standard’s digests on all three on the first run.

Cranelift cannot. cranelift-codegen 0.121.2 has no SHA instruction, as it has no AES one (crypto-builtins.md §2).

Valgrind does not have SHA-NI on x86-64, and does have the aarch64 instructions. Measured with a C program that asks the CPU and then runs the instruction: under Valgrind 3.26 on the i7, cpuid leaf 7’s ebx bit 29 reads 0 (natively 1), and a sha256rnds2 reached anyway is an illegal instruction; under Valgrind 3.22 in the arm64 VM, getauxval(AT_HWCAP)’s HWCAP_SHA2 reads 1 and sha256h runs and answers what it does natively. Two consequences, both used below:

  • The regression guard counts instructions under Valgrind (tls-performance.md §6), so on x86-64 it counts SHA-256’s software path and on aarch64 its hardware path. Its linux-x86_64 SHA rows can only move with the software code; its linux-aarch64 ones move with the builtin (§8.3).
  • Valgrind is a CPU without SHA-NI for an x86-64 program: the whole of std runs its software path on LLVM there, with no switch in the program (§7).

Where a handshake’s compressions come from (counted by reading std.hmac and std.hkdf): an HKDF-Expand-Label of one digest is four compressions, two of them the key’s padded blocks and two the message and the inner digest, each a block that std.crypto assembles in its partial-block buffer, not a block of the caller’s bytes. The transcript hash ends the same way (sha256_final on a copy). So the handshake’s common case is one block from the buffer, and a design that only took bytes would have to copy the buffer out into a byte slice first (§3.2).

3. The builtins

hw_sha256() -> [] bool
sha256_blocks(state: &![int], data: &[byte]) -> [] int
sha256_block_words(state: &![int], block: &[int]) -> [] int

Edition 9, the latest open one (editions.md §6.4: edition 7 is closed, and sha256_blocks is a name a program may already declare for itself).

hw_sha256() answers whether this CPU has every instruction the other two use: on x86-64, SHA (cpuid leaf 7, sub-leaf 0, ebx bit 29, asked only when leaf 0 says leaf 7 exists), SSSE3 and SSE4.1 (leaf 1, ecx bits 9 and 19: the shuffles and blends around the rounds); on aarch64 Linux HWCAP_SHA2 (getauxval(AT_HWCAP) bit 6), on aarch64 Darwin sysctlbyname("hw.optional.arm.FEAT_SHA256"). Read once and cached in a module global with monotonic atomics, as hw_aes_gcm() is (crypto-builtins.md §4). It is public information: branching on it is not a timing leak.

sha256_blocks(state, data) is FIPS 180-4 §6.2.2’s compression of every 64-byte block of data, in order, into the eight 32-bit words state holds, one to an int: the low 32 bits of each are read, and the result is written zero-extended, so a state of words in 0..2^32 stays one. state is exactly 8 words and data a whole number of blocks, none allowed, or it traps.

sha256_block_words(state, block) is the same compression of one block given as its sixteen big-endian words, the low 32 bits of each of sixteen ints: the form std.crypto’s partial block is kept in (§5). state 8 words, block 16, or it traps.

Both answer 0, and both trap on a target or a backend without the instructions; a program reaches them only after hw_sha256() said true (§5).

3.1 Why a compression over a state and N blocks

  • It is the primitive every user here needs, and nothing more. std.crypto’s streaming state, std.hmac’s keyed states (a key’s padded block hashed once and resumed from its eight words, hkdf.md §9) and the TLS transcript (copied and finished, packages/tls/slot.cho) all hold a state between calls; a builtin that hashed a whole message could serve none of them. The padding, the length and the buffering are lengths, which are public: they stay in cancho, where they are tested as they were.
  • N blocks a call, not one. The state is loaded into two vector registers once a call and stays there across every block; a one-block builtin would load and store it each block and add a call. On a 16 KiB message that is 256 calls against one.
  • The state is eight ints, not 32 bytes, because std.crypto’s states are [int] and are copied and resumed as such; the conversion is a truncation and a zero extension of a vector, inside the call.
  • A wrong length traps, as an index out of bounds does and as crypto-builtins.md §9 Q1 decided for AES: the lengths are std.crypto’s, never the network’s.

3.2 Why a second form for one block of words

std.crypto’s state is an [int] and cannot hold bytes, so a partial block lives in the state as words. With only sha256_blocks, each buffered block (§2: the handshake’s common case) would have to be written out into a byte slice, which needs a region a call, and #446 is about removing regions a call. With sha256_block_words the buffered block is compressed where it lies: sha256_block_words(state[0..8], state[8..24]). The two forms share one compression function in the backend (@cancho_sha256_rounds), so the second costs a load of sixteen words and nothing else.

3.3 Not a vector type

crypto-builtins.md §9 Q3 answered “not now” for AES, and #445 asked to decide SHA-256 with the vector-type question for ChaCha20 (tls-performance.md §4.2, §7 Q7). The answer is the same, for the same reason: these builtins go straight at a measured cost with a small, testable change, and need nothing from the checker. A vector type is what ChaCha20’s 6 to 11.5 times would need (§10.2), it would not make SHA-256 any faster than an intrinsic does, and it is its own design: the checker, both backends, and how a vector meets checked arithmetic.

4. How the LLVM backend lowers them

crates/cancho-codegen-llvm/src/crypto/sha256.rs, beside the AES and GHASH code, with the same rules (crypto-builtins.md §4):

  • Out-of-line functions carry the features ("+sha,+sse2,+ssse3,+sse4.1" on x86-64, "+sha2,+neon" on aarch64), and LLVM will not inline them into a caller at baseline, so the rest of the program runs on a CPU without them. They are defined only in a module that calls them.
  • The call sites check the lengths (body/crypto.rs) with the backend’s trap instruction, then call @cancho_sha256_blocks(state, data, n) or @cancho_sha256_block_words(state, block).
  • One block’s rounds are @cancho_sha256_rounds, inlined into both: sixteen groups of four rounds. On x86-64 the state is held as ABEF and CDGH (the instruction’s order: the first word in the highest lane), each group two sha256rnds2 on the group’s words plus constants (the second on the high two lanes, shuffled down), and the schedule’s group g is msg2(msg1(W[g-4], W[g-3]) + (W[g-2] || W[g-1]) >> 4 bytes, W[g-1]). On aarch64 the state is ABCD and EFGH, each group sha256h and sha256h2 on the saved ABCD, and the schedule su1(su0(W[g-4], W[g-3]), W[g-2], W[g-1]). Then the feed-forward addition. Every operation is on a register; LLVM schedules them.
  • Loads and stores: a block of bytes is four unaligned 16-byte loads and a byte swap in each 32-bit lane; a block of words is one load of sixteen i64, truncated. The state is one load of eight i64, truncated and shuffled into the ISA’s form, and one store, shuffled back and zero-extended. That store is the only one either function makes (a unit test checks the text), so no word of a key’s hash is written anywhere but the caller’s state.

5. Cranelift, other targets, and std

  • Cranelift: hw_sha256() is the constant false and the two builtins trap, as the AES ones do (crypto-builtins.md §5).
  • WebAssembly, and LLVM on any target but x86-64 and aarch64 Linux or Darwin: the same, from the LLVM backend. The builtins are on WASI’s side of the line (docs/wasm.md): they need no host, and a WASI program that calls hw_sha256() gets false.
  • std.crypto branches on hw_sha256() where it compresses: compress_bytes (whole blocks straight from the caller’s data, one sha256_blocks call for all of them) and compress_block (the partial block, sha256_block_words). Otherwise the software compression, unchanged. Which path runs is a question about the CPU, not the data.
  • The state’s layout changed and its length did not. The partial block was 64 ints, one byte each; it is now sixteen big-endian words at state[8..24], every byte past the waiting ones zero (a byte is OR-ed into its word as it arrives, and the block is cleared after each compression). The count and the waiting bytes moved to state[24] and state[25]; state[26..74] is unused; the software schedule stays at state[74..138]. sha256_state_len() is still 138, so nothing laid out around a state moved (the TLS slot keeps two transcripts in its ints). The padding is a byte OR-ed in and two words written. Nothing outside std.crypto reads the layout.
  • Four new functions, for std.hmac: sha256_save(state, words) and sha256_resume(state, words, bytes), the eight words of a state at a block boundary and a state that goes on from them; sha256_finish(state), the padding with the digest left as the running words; and sha256_update_words(state, words), big-endian words fed at a word boundary. And the sha512_ four, for SHA-384 (hkdf.md §9).

6. Constant time

SHA-256’s input is often secret here: an HMAC key, a PSK, every traffic secret of the key schedule, and the messages hashed under them. The rule of chacha20.md §3 applies: no branch and no memory index may depend on a secret.

  • The builtins: every loop bound is a block count and every branch is on a length; the state and the message are in registers from load to store; the instructions are not table lookups. Whether their timing depends on their operands is the CPU’s. On aarch64 the runtime sets PSTATE.DIT where the operating system reports FEAT_DIT, as for AES (crypto-builtins.md §6); nothing is claimed here from either vendor’s documentation. It is measured (§8.4).
  • std.crypto’s software path is the code hkdf.md §3 audited, unchanged; the new buffer code shifts by have, which is a byte count, public.
  • std.hmac branches on the key’s length and the hash, as before. A prepared key is as secret as the key: hmac.forget clears one, final clears the outer words and the inner digest it kept, and every region these functions open is cleared when it ends (region-chunk-cache.md §3). Best effort, for chacha20.md §3.2’s reasons.

7. How it is tested

  • Known answers, and a differential, on the builtins (crates/cancho/tests/conformance/sha256_builtins.rs, through tests/programs/sha256_builtins_driver.cho): FIPS 180-4’s abc and two-block examples padded by hand, through both forms; 400 random states (one in three with bits above the 32nd set, which must be ignored) with 0 to 20 blocks each, 400 single blocks of words with high bits set, and a 16 KiB run, against the compression function written from FIPS 180-4 §6.2.2 in the test.
  • hw_sha256() must say what the CPU says: true on aarch64; on x86-64 Linux, what /proc/cpuinfo’s sha_ni says. A silent fall back to software cannot hide, and neither can a probe that answers yes on a CPU that says no.
  • A wrong length traps before any instruction runs: a state of 0, 7 or 9 words, data of 32, 63, 65 or 96 bytes, a block of 15 or 17 words; and Cranelift answers false and traps.
  • std on both paths, without a switch: the CAVP files (SHA256ShortMsg, SHA256LongMsg, SHA256Monte and the SHA-384 and SHA-512 ones), the streamed-against-one-shot cases at every length to 300 in pieces of 1 to 129 bytes, and the 64 KiB and 200 KiB messages now run on both backends in cargo test (conformance/kdf.rs): LLVM on the instructions, Cranelift in software. The same files, RFC 4231 and the KDF differential on LLVM with the CPU’s SHA-NI hidden, under Valgrind on x86-64 (§2), are §8.1’s.
  • Mutants: scripts/sha256_mutants.py mutates the Rust that writes the IR (the rounds, the schedule, the constants, the lanes, the loads and stores, the probe, the call sites’ checks) and scripts/kdf_mutants.py the three std files, each on both backends (§8.2).
  • The TLS suites: RFC 8448’s traces, the recorded handshakes, the lying peers, tickets, the pool’s tls.residue and the interop scripts, unchanged.

8. As built

8.1 Correctness

  • cargo test (the whole workspace on the M4 under macOS; its sha256_builtins and kdf tests also on the i7 under Linux, where the builtins are SHA-NI): the builtins’ known answers and 800-case differential, the traps, hw_sha256() against the CPU (true on both), and conformance/kdf.rs (CAVP, RFC 4231, RFC 5869, the key schedules, Wycheproof, the prepared-key rows) on both backends. The TLS conformance tests (RFC 8448’s traces, every recorded handshake replayed byte for byte on both backends, the lying servers and clients, tickets, the pool’s tls.residue 0) pass unchanged.
  • The KDF differential, scripts/kdf_differential.py, twelve checks a round: 100,000 rounds, 1,200,000 checks, 0 differences on x86-64 (LLVM, SHA-NI); 20,000 rounds (240,000 checks) on arm64 Linux (LLVM, ARMv8 SHA-2) and 1,000 on Cranelift there, 0 differences.
  • With SHA-NI hidden (Valgrind on the i7, §2): scripts/kdf_valgrind.py runs kdf_mutants.py’s evidence (the vector table and its prepared rows, the CAVP short-message and Monte Carlo files, every Wycheproof HMAC and HKDF case, the streamed cases, the refusals) and the CAVP long-message files through the LLVM driver: 8,373 cases, 0 wrong; and 300 differential rounds (3,600 checks), 0 differences. (raw)
  • The TLS scripts, in the arm64 VM against the final code: tls_liar.py 84 cases and --streams 64, tls_tickets.py 20, tls_server_interop.py openssl 23 rows and tls_server_tickets_interop.py openssl 9, all as expected; tls_differential.py 59 agree, 17 differ in the alert only, 8 as documented, 0 otherwise, and tls_server_differential.py 65, 7, 6: the counts they had (raw: assets/sha256-builtin/arm64/).

8.2 Mutants

  • scripts/kdf_mutants.py: 34 of 34 killed on arm64 Linux, each mutant run on both backends (raw). Two of the software path’s (a round adding temp1 twice, the partial block’s words not copied to the schedule) are killed only by the Cranelift build, which is why the script now builds both: on a CPU with the instructions the LLVM build never runs that code.
  • scripts/sha256_mutants.py (the IR): §8.6 has the tally of the run on the final tree.

8.3 The regression guard

scripts/tls_perf.py check counts instructions under Valgrind, so (§2) its linux-x86_64 rows count the software path and its linux-aarch64 rows the hardware path:

  • linux-aarch64 failed nine rows as cheaper than 1/1.3 (SHA-256 at 0.02 to 0.07 of its count, HMAC 0.02 to 0.12, HKDF 0.16 and 0.18) and the new row hkdf_expand_label_prepared_32; re-recorded in the VM, as §1.3 says, and then 34 of 34.
  • linux-x86_64 passed 33 of 34 rows with the software path’s counts 0.86 to 1.00 of the baseline (the partial-block words and D4) and lacked only the new row; it is re-recorded on the i7 for that row (§8.6).
  • The CI runner’s guard therefore cannot see the x86-64 builtin: a regression in it would show on the arm64 entry, and in the cycles of §8.5, not in the x86-64 entry.

8.4 Timing

scripts/hmac_timing.py (dudect’s fixed-against-random test, scripts/gcm_timing.py’s statistic) over tests/programs/hmac_timing.cho, on the i7 pinned to CPU 4, 10^6 measurements a test, the fixed class an all-zero key or message: hmac.sha256 with a secret key and with a secret message, hmac.prepare, hmac.mac_prepared with a secret prepared key and with a secret message, crypto.sha256, and hkdf.expand_label with a secret secret. Two runs, and between them the same tests with both classes random (--control), raw in assets/sha256-builtin/x86-64/dudect-hmac-1e6.txt:

testrun 1 max |t|run 2control
hmac, key1.932.121.29
hmac, message1.241.981.49
prepare, key2.671.921.77
prepared, key1.761.641.43
prepared, message1.922.831.82
sha256, message3.241.881.86
expand-label, secret2.541.752.22

All under 4.5. Two findings on the way, both in this change:

  1. hmac.final first used the caller’s out as scratch (the inner digest written there and hashed back from it). With a fixed message, mac_prepared failed: |t| 11 to 58 at 2 x 10^5 to 10^6. Bisected: init_prepared alone and with update passed, adding final failed; a fixed non-zero message failed as the all-zero one did (|t| 29) and random against random passed; and clearing out before each measurement, outside the clock, made it pass. So the time depended on whether the caller’s buffer already held the bytes being written: the CPU’s, not the code’s, but the code chose to write the inner digest there. final now keeps the inner digest as words in the state and writes nothing to out but the tag (hkdf.md §9.2).
  2. The harness itself measured where a record lies. With each class’s records in its own half of the pool, prepared, key still failed (|t| 17 to 32), and so did a control with both classes random (|t| up to 11): a difference of under one cycle in 780, with a sign that changed from run to run. Interleaving the classes did not cure it (control |t| 6 to 8.7); copying each measurement’s record into the same three buffers before the clock starts did (control 1.3 to 2.6). Every figure in the table is from that harness. scripts/gcm_timing.py’s pooled mode lays its classes out the old way; its results were not re-examined here (#468).

Not measured: a timing test on aarch64 at 10^6 (a run of 2 x 10^4 a test on the M4 under macOS, with the first harness, gave max |t| 1.1 to 2.4), the Cranelift build, and anything under DOITM (Intel’s data-operand-independent-timing mode, which this shared machine does not set).

8.5 Cost

Before is origin/main at 3d7e8e9d (A1 built), after is this change, built from the same commit beside each other and run alternately on the machines of tls-performance.md §10.1: x86-64 the i7-1260P (gram), pinned to CPUs 4 and 5 (one P-core’s two threads; load average 4 to 7 from other work on the other cores), cycles and instructions from perf stat; arm64 the M4 Max’s Docker VM, CPU time. OpenSSL beside each, on the same core: 3.5.5 on x86-64, 3.0.13 in the VM. Raw output: assets/sha256-builtin/ (x86-64/measure-final.txt and arm64/perf-before-after.txt are the final code’s; x86-64/primitives-before-after.txt, handshakes-before-after.txt and arm64/perf-first-run.txt an earlier run with the first hmac.final of §8.4, which agree with them to within a few percent on every SHA-256 row and handshake but the client’s; OpenSSL’s arm64 figures are from that first run, in the same VM).

Each primitive (scripts/tls_perf.py table, the least of two passes; scripts/tls_perf.py openssl):

operationbytesx86-64 cycles: before → afterinstructionsOpenSSLratio before → afterarm64 ns: before → afterOpenSSLratio
SHA-256642,565 → 44015,039 → 9775624.56 → 0.78557 → 14196.35.78 → 1.46
SHA-2561,02420,829 → 2,406121,884 → 3,4372,5368.21 → 0.954,425 → 455480.59.21 → 0.95
SHA-25616,384332,622 → 33,8571,831,404 → 42,79734,0019.78 → 1.0066,284 → 6,5765,52612.0 → 1.19
HMAC-SHA-256 (not like for like, tls-performance.md §3.1)646,627 → 1,15739,759 → 3,7297171,445 → 202125.3
HKDF-Expand-Label, 32-byte secret5,641 → 1,31433,504 → 4,8811,205 → 232
the same, secret prepared (new row)6772,589155
HKDF-Extract5,504 → 1,10733,014 → 4,0991,186 → 211
HKDF-Expand-Label, SHA-384 (D4 only)6,079 → 5,82533,643 → 32,1781,277 → 1,434
ECDSA P-256 sign (RFC 6979’s HMAC)320,668 → 297,5491,632,016 → 1,461,81453,222 → 46,997
  • SHA-256 is at OpenSSL’s cost on x86-64 (0.78 to 1.00 of its cycles) and 0.95 to 1.46 of its time on arm64: 6 to 10 times cheaper than before. At 64 bytes what is left is std.crypto’s byte loop into the partial block and the padding; at 16 KiB it is the instructions.
  • An Expand-Label is 4.3 times cheaper (x86-64), and 8.3 times with its secret prepared. The prepared form saves the two pad compressions and two regions; what is left of its 2,589 instructions is its region’s clearing, the byte loops that build HkdfLabel and fill the partial block, and the length checks.
  • SHA-384 does not move (no instruction is used for it, §1); its Expand-Label is 4% cheaper in instructions (the arm64 time moved the other way, inside the VM’s spread). The ECDSA signature is 7 to 12% cheaper because RFC 6979’s nonce is an HMAC-DRBG.

Handshakes (scripts/tls_handshake_cost.py <bins> --seconds 8 --rounds 2 --cpu 4 --peer-cpu 5 --sudo-perf, two passes of each tree alternating, the least of four rounds; arm64 the same with --cpu 0 --peer-cpu 1, CPU a handshake). OpenSSL is the least of its eight rounds; it did not move between the trees.

x86-64 cycles: before → afterinstructionschangeratio to OpenSSLarm64 CPU: before → afterchangeratio
server, full1.848M → 1.636M8.09M → 6.97M−11.5%1.86 → 1.65 s_server (oserv 1.75)0.297 → 0.262 ms−11.8%1.59 → 1.40
server, resumed0.889M → 0.644M3.31M → 2.14M−27.6%1.04 → 0.760.152 → 0.111 ms−27.0%1.10 → 0.80
client, full2.660M → 2.540M10.80M → 10.00M−4.5%1.64 → 1.57 tconn0.423 → 0.389 ms−8.0%1.49 → 1.37
client, resumed0.954M → 0.827M3.18M → 2.28M−13.3%1.15 → 0.990.172 → 0.151 ms−12.2%1.40 → 1.23
  • A resumed handshake is now cheaper than OpenSSL’s on the server (0.76 of its cycles on x86-64, 0.80 of its time on arm64), and level with it on the x86-64 client.
  • The client rows are the difference of two tls_many runs and move most between rounds (2.54 to 3.36M); the earlier run of §8.4’s first final measured the client’s resumed handshake 23.6% cheaper, this one 13.3%. Instructions do not move with the clock: −7.4% and −28.1%.

Against the projection (tls-performance.md §10.8, arithmetic on the profile before A1): D3 and D4 together were to save 6.5 + 1.8 = 8.3 points of a 2.83M-cycle full server handshake (0.235M cycles) and 10.3 + 3.7 = 14.0 points of a 2.00M resumed one (0.280M). Measured on the handshake A1 left: 0.212M and 0.245M cycles, 90% and 88% of the projected cycles, and a larger share (11.5% and 27.6%) because A1 removed a third of what they were a share of.

8.6 The guard re-recorded, and the mutants of the IR

  • linux-aarch64, re-recorded in the VM on the final code: 34 of 34; scripts/tls_perf_mutant.py fails exactly its four rows. linux-x86_64, re-recorded on the i7 for the new row (every other row within 0.86 to 1.00 of the old entry, §8.3): 34 of 34, and the guard’s mutant fails exactly its four rows.
  • scripts/sha256_mutants.py: on the M4 (macOS), 23 of 23 run there killed (the rounds, the schedule, the constants, the aarch64 lanes, the loads and stores, the Darwin probe, the cache, every length check) (raw); in the arm64 Linux VM the HWCAP probe’s mutant, killed (an earlier form of it, HWCAP_AES for HWCAP_SHA2, survived there because that CPU has both, and was replaced by one the CPU does not have); on the i7 the x86-64 ones, 10 of 10: eight killed (the schedule’s msg1 and msg2, the rounds’ operands and lanes, the ABEF and CDGH lanes both ways, the SHA bit of the probe) and two argued equivalent and run all the same (SSE4.1 not asked for, and leaf 7 asked whatever the highest leaf: no CPU with SHA lacks either) (raw). The mutants common to both instruction sets were run on the M4.

9. Not done

  • SHA-384 and SHA-512 on instructions (§1).
  • std.crypto’s state could shrink from 138 words to 90 (the 48 unused words), which would make every transcript copy a third cheaper; it would move the TLS slot’s layout, so it is left for a change that measures it.
  • Cranelift keeps the software path (§5).

Compile-time evaluation

Status: settled and built.

The question behind this document is “is there a way to be faster than C without giving anything up?”, and the first honest answer is that we are not yet as fast as C at the easiest thing there is.

2 + 3 * 4 - 14 is zero. C -O2 compiles it to xor %eax,%eax. cancho compiles it to a multiply, an add, a subtract, three overflow checks and a load from a constant pool. §2 is the disassembly and §2.1 is why — and the why turns out to be overflow-cost.md §3.2’s mechanism, sighted a second time in a place that document did not look.


1. The census

purity.md’s mistake was worth not repeating: it argued for a transformation for a page and a half before checking whether any program could exhibit it. So this is the count first — and then the count again, after the pass existed, because the first one was wrong.

1.1 The estimate, and why it was not the answer

Walking the lowered IR of 60 programs and marking every operator whose operands were literals, and every call to a pure function whose arguments were all constant:

count
Expressions9456
Operators that fold to a literal77
Calls to a pure function with every argument constant65

That is 1.5% of the expressions in the repository, none of it in an inner loop. On the strength of the estimate alone the answer would have been “not worth it”, and §2 is why it was worth it anyway.

1.2 What actually folds

scripts/folded.py asks the compiler instead of re-deriving the answer — cancho authority --output json reports what the pass did — over 64 programs:

64 programs
  operators evaluated   91
  calls evaluated       15
  provably pure         163 of 529

Both numbers moved, in opposite directions, and the second one moved a long way:

  • Operators: 77 estimated, 91 actual. Folding is recursive, so folding a child exposes a parent the estimate never saw.
  • Calls: 65 estimated, 15 actual. This is the one worth reading twice. “The function is pure and its arguments are constants” is necessary and not sufficient — the body also has to be something the evaluator can run, and §3.1 keeps memory out of it. Fifty of the sixty-five call a pure function that builds a struct, matches an enum, indexes a slice or opens a region, and the evaluator declines every one of them.

So the estimate over-counted the interesting half by more than four times, in exactly the direction that would have made this look like a better idea than it is. The number to quote is 15.


2. What we emit today

fn main(..) -> [] int { return 2 + 3 * 4 - 14; }
mov    $0x3,%eax
imulq  0x48(%rip)          # a constant pool entry, for the literal 4
seto   %sil                # did the multiply overflow?
test   %sil,%sil
jne    <ud2>
mov    $0x2,%eax
add    %r9,%rax
seto   %dil                # did the add overflow?
test   %dil,%dil
jne    <ud2>
sub    $0xe,%rax
seto   %cl                 # did the subtract overflow?
test   %cl,%cl
jne    <ud2>

Fifteen instructions and a memory load to compute a constant the parser already had. C -O2 emits one:

xor    %eax,%eax
ret

2.1 And the reason is the trap, again

Cranelift runs at opt_level = "speed", and its egraph pass does constant-fold arithmetic. It does not fold this arithmetic, because these are not imul and iadd — they are smul_overflow and sadd_overflow followed by trapnz, and the folding rules are written for the plain forms.

overflow-cost.md §3.2 found that the trap’s real cost is not the never-taken branch but that a trapping operation is opaque to the optimiser; there it blocked the vectoriser. This is the same sentence in a second place, and it is worth recording because §3.2 presented the vectoriser as the case and it was one case.

This one is entirely ours, and it is free. The optimiser cannot fold a * b because it does not know whether it traps. The compiler front end can, because it has the literals: evaluate it, and either it fits — emit the answer, with no check, because a constant needs no guard — or it does not, and §4 says what happens then.


3. The rule

A call may be evaluated at compile time when the callee is pure and every argument is a constant. An operator may be folded when its operands are.

Purity is not a new judgement. Func::is_pure already exists and is already checked (purity.md §2): the performs row is empty and no parameter reaches a unique reference. 35% of the functions in this repository qualify, and nothing needs annotating.

That is the difference from the two languages next door. C has nothing — C23’s constexpr applies to objects, not functions, and __attribute__((const)) is a promise nothing verifies. Rust has const fn, which is exactly this, but it is opt-in and restricted: the programmer writes const, and the body is limited to what const-eval supports. Here the fact is already computed for every function, so the set of candidates is not a subset someone remembered to mark.

3.1 What is deliberately not in it

  • No const keyword, and no request to evaluate. There is nothing to write. A program that becomes foldable stops emitting the work, and one that does not, does not. Adding a way to demand it would make the fuel budget in §5 part of the language.

  • Nothing impure, at any cost. A function performing io_write is not evaluated even with constant arguments, and not even if the compiler could buffer the output. The row is the gate.

    Worth noticing: in this language that guard can almost never fire. Every effect needs a capability, a capability is always a parameter, and a parameter holding a capability is never a literal — so “impure and every argument constant” is close to unreachable by construction. The check stays because the second half of the purity predicate (purity.md §2) is the half that does fire: a function may declare [] and still write through a unique reference.

  • No compile-time allocation, so no compile-time data — §7.


4. A trap at compile time is a compile error

This is the part that is not about speed.

let x = 9223372036854775807 + 1;

Today that compiles cleanly and the program dies with SIGILL when it runs. The overflow is certain, the value is in the source, and the compiler had everything it needed to say so.

So: when an evaluation traps, the program is refused, with the operation and the reason. defined-behaviour.md §2.1 says an operation with no right answer stops rather than inventing one; this moves the stop from run time to compile time for the cases where it is inevitable, which is strictly better and costs nothing.

It applies to every trap the language has, not just overflow: division by zero, an out-of-range shift, truncate of a NaN, an index past the end of a literal slice. If the answer is a certain trap, the answer is a diagnostic.

The converse is the important half: a trap that is merely possible is untouched. a / b with a runtime b still emits the check. Nothing here weakens a guarantee; it only moves guarantees that were already decided.

4.1 Where it is written, not where it runs

One case decides the shape of the rule:

if condition { let x = 1 / 0; }     // condition is a runtime value

The trap is certain if the branch is taken, and the branch may never be taken. Refusing this refuses a program that would have run.

It is refused anyway. An expression with no value is malformed where it is written, in the same way a type error is — its position in the control flow is not what makes it wrong, and a compiler that accepted 1 / 0 in one place and rejected it in another would be asking the programmer to reason about which branches the optimiser thinks are reachable.

This is a real narrowing, it rejects programs that ran before, and saying so is better than discovering it. The alternative — refuse only where the expression is definitely reached — makes the diagnostic depend on the precision of a reachability analysis, which is exactly the kind of thing defined-behaviour.md exists to keep out of the language.


5. Fuel, and what running out means

Evaluation is bounded by a step budget. Two rules:

  1. Running out is never an error. The compiler stops, discards the partial evaluation, and emits the call as ordinary runtime code. The program behaves identically either way, which is exactly why the budget can be a compiler tuning parameter and not a language rule.
  2. The budget is per call site, not per program, so a build’s behaviour does not depend on the order in which functions were reached.

This is the difference between a fuel budget and a language feature. If running out were a compile error, every program would carry an invisible dependency on the compiler’s tuning, and moving a computation into a helper could break a build. Silent fallback has neither problem — the only observable effect is how fast the result runs.

5.1 Where the dial is set, and what it costs

The budget is one million steps, and it was chosen from a measurement rather than from taste. A naive recursive fib is the convenient yardstick because its cost is 2·F(n+1) − 1 calls:

calls evaluatedadded build time
fib(20)21 8910.02s
fib(23)92 7350.08s — the last one that folds
fib(24)150 049— budget exhausted, the call is emitted
fib(26)392 8350.24s, if the budget allowed it
fib(30)2 692 5371.69s, if the budget allowed it

The interpreter runs at about 1.6 million calls a second, linearly, so a million steps is roughly 65 ms of build time as a worst case per call site. fib(30) would fold, and it would cost 1.7 seconds to save a call that takes about five milliseconds to run — which is a good trade for something built once and run often, and a bad one for a program built once and run once. A compiler cannot tell the difference, so it takes the cautious side.

The budget is counted in steps rather than seconds on purpose. A time limit would make the same source produce different binaries on different machines; a step limit makes the build reproducible, which is the property canonical-ast.md cares about and a stopwatch would quietly break.


6. Why this is sound here and shaky in C

A compiler that evaluates at compile time is running the program on the host and shipping the answer to the target. That is only legitimate if the two agree, and C’s do not have to:

Ccancho
Integer widthimplementation-defined64-bit, fixed
Signed overflowundefinedtraps (§4)
Float formatnot required to be IEEE-754IEEE-754 binary64, stated
Reassociationpermitted under -ffast-math, and contraction by defaultnever (floating-point.md §3)
a * b + c → fmapermitted (FP_CONTRACT), and changes the resultnot emitted — verified in the disassembly
A NaN’s sign and payloadleft to the hardwareone pattern, 0x7ff8000000000000, wherever bits_of can see it (differential.md §4)

Every row of the right-hand column was decided for a different reason, and together they say something this document did not have to argue for: an expression has one value, and it is the same value on every host and every target. That is what makes running it early a compilation step rather than a gamble.

Correction (#76). Until the last row was added, this sentence was false for one builtin. IEEE-754 does not fix a generated NaN’s sign, and the two supported targets disagree about it, so bits_of(0.0 / 0.0) printed -2251799813685248 on x86-64 and 9221120237041090560 on aarch64. The folder was never wrong, because it always runs on the target. The claim was. bits_of now answers one pattern for every NaN, and it is the only operation in the language that could see the difference, so the sentence is true again. differential.md §4 has the measurement.

The float row is the one worth checking rather than asserting, and it was checked: a * b + c compiles to mulsd then addsd, not to vfmadd. Contraction is not reassociation and floating-point.md §3 does not mention it, so it was a real hole in the argument until the disassembly closed it. If a backend ever does contract, compile-time evaluation must be turned off for floats or the backend told not to — and this row is why someone will know to look.

There is one more, quieter reason: the AST is canonical and content-addressed (canonical-ast.md), so an evaluation is keyed by the hash of what was evaluated. Two identical calls are one computation, and a cache across builds is a later possibility rather than a redesign.


7. What this is worth against C, honestly

C was measured rather than assumed, because “the compiler will not do it” is the kind of claim that is thirty years out of date. At -O2:

ShapeC -O2Verdict
2 + 3 * 4 - 14xor %eax,%eaxC wins today. This is catch-up, not an edge
poly(5) - 47, small non-recursive calleexor %eax,%eax — inlined, then foldedParity. We would match, not beat
sum_to(1000000)movabs $0x746a4ae6e0Parity, and not for the reason it looks. clang did not run the loop; scalar evolution solved it in closed form
fib(23)a runtime callThe edge. clang gives up on recursion whatever the depth, and emits the call. cancho folds it (§5.1)

So the honest claim is narrow and it is not the headline anyone wants:

Compile-time evaluation brings constant arithmetic up to C, matches C on the calls C already inlines, and beats C exactly where C’s analysis gives up — recursion, bodies too large to inline, and computations with no closed form for the optimiser to find.

purity.md §4.2 said the row’s advantage needs a compilation boundary LLVM cannot see past, and demoted it. That argument was right about CSE and hoisting and does not apply here: evaluation needs no boundary, only purity and constant arguments. This is the axis §4.2 did not consider, and it is narrower than the one §4.2 gave up on.


8. The shape where it would be worth real money

Corrected. This section called examples/base64/’s decode table “the one that pays” and said the feature that reaches it was the next document. The feature was built (compile-time-data.md) and the first thing the measurement found was that this section overclaimed: the table is worth 5.7×, and the table was already writable without any language change. §1 of that document is the correction, and what is below is the original observation, which was right about the table.

examples/base64/base64.cho, as it was:

fn value_of(c: int) -> [] int {
    let table = alphabet();     // 64 bytes
    var i = 0;
    while i < len(table) { if int_of(table[i]) == c { return i; } i = i + 1; }
    return 0 - 1;
}

A linear scan of 64 entries, once per decoded character. GNU base64 uses a 256-entry lookup table, and the file’s own comment said why this one did not: “64 entries of a 256-entry table is a lot of source for something a loop settles.”

That is the real-world instance of “the compiler gives up”, and it is everywhere in systems code: CRC tables, decode tables, bit-count tables, sine tables. C programmers hand-write them or generate them with a script, precisely because the compiler will not.

Compile-time evaluation as designed above does not reach it. Every call to value_of has a runtime argument, so nothing folds. What reaches it is compile-time data: a pure function that returns a slice, evaluated once at compile time and emitted as static bytes.

compile-time-data.md is that, and it is now built — a static item, and examples/base64/ decodes 6.1× faster than it did. What this section got wrong is who the 5.7× belonged to.


9. Open

QuestionWhy it waits
Compile-time data§8. The one that pays. Needs allocation during evaluation and static emission, which is a milestone rather than a pass
Reporting what was foldedDone in this slice. cancho authority prints evaluated at compile time and --output json carries folded_operators and folded_calls. It is also the only portable way to test the pass: CI builds on two platforms and a disassembler is not among the things they share
A trapping call is not reported§4 refuses a trapping operator where it is written, because lowering still has the span. The call pass runs afterwards, over an IR that carries no spans, so a pure call that traps on its constant arguments is left alone and traps at run time as it did before. The asymmetry is real and this is the note admitting it; closing it means spans in the IR
Caching across builds§6’s hash makes it possible. Nothing has asked, and a cache that is wrong is worse than no cache
Folding through if on a constantDead-branch elimination is the same fact applied to control flow. Cranelift does this one already, so it is redundant until it is not

10. The suite

FixtureRule§
constant_overflow.choA certain overflow is refused where it is written4
constant_division_by_zero.choEvery trap kind, not just overflow4
constant_shift_past_the_width.cho…including bitwise.md §3’s range4
constant_trap_on_a_dead_branch.choThe narrowing §4.1 chose, recorded as deliberate4.1
TestRule§
constants_are_evaluated_at_compile_timeOperators and a recursive call both fold, read back from the report2, 3
running_out_of_fuel_leaves_a_working_programfib(23) folds, fib(24) does not, and both print the same answers5, 5.1
a_possible_trap_still_trapsThe same three operators on a runtime value still stop the process4
a_folded_float_is_the_same_floatThe folded and unfolded spelling of one expression print identically6
a_shift_past_the_width_trapsRewritten: the amount is a var, because a literal is now a diagnostic4
AcceptingShows
compile_time.choArithmetic, a nested call, a recursive call, bit operators — and the same fib on a runtime value, so the fixture says the cost changed and the answer did not
Script
scripts/folded.py§1.2’s census, asked of the compiler rather than estimated

Compile-time data

Status: settled and built.

compile-time.md §8 called this “the one that pays” and pointed at examples/base64/, which scans 64 entries per decoded character where GNU uses a 256-entry table.

§8 was right about the table and wrong about what needed building. The table is worth 5.7× end to end, measured (§1) — and it was already writable, in 36 lines, without any language change. So this document has a smaller job than §8 gave it, and §1 is the correction before §2 is the design.


1. The correction: the table was already writable

examples/base64/ decoding 5.4 MB, best of five:

timeagainst coreutils
The scan, as shipped293 ms21× slower
A 256-entry table, built at run time in a region51 ms3.6× slower
GNU coreutils base64 -d14 ms—

So the scan was costing 5.7×, and the fix was a region in main, a build_table loop, and a &t [int] parameter on the two functions between them — a 36-line diff, all of it expressible before this slice. Both binaries agree with coreutils byte for byte on the same input.

That is the number, and compile-time data does not deliver it. A reader of §8 would have expected this document to report 5.7×; the 5.7× belongs to the table, and the table belongs to anyone who writes it.

1.1 So what is left for the feature

Four things, and they are worth stating precisely because they are smaller than §8 implied:

  1. The plumbing. 36 lines and a region parameter threaded through every function between the table and its use. In base64 that is two signatures; in a program where the table is used five levels down it is five.
  2. A table an arena cannot hold. An arena is one 64 KiB chunk and exhausting it traps (defined-behaviour.md §4). A 65 536-entry [int] table — the shape a CRC or a 16-bit codec uses — is 512 KB and traps, measured. So it needs Heap, and a program that released heap cannot have one at all. examples/base64/ and examples/newton.cho both release heap in their first three lines.
  3. Read-only pages. A table in the binary is mapped from the file and shared; a table built at startup is dirtied anonymous memory, per process.
  4. Tables that cannot be written as literals. strings.md §4 refuses \x escapes, so a 256-byte table with arbitrary values is not expressible as a string literal either. Without this feature the loop is not a preference, it is the only option.

Number 2 is the one that is a capability argument rather than a convenience, and it is why this was built rather than closed as “write the loop”.


2. The static item

static decode_table: [int] {
    let table = alloc_slice[static](256, 0 - 1);
    let alpha = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/";
    var i = 0;
    while i < len(alpha) {
        table[int_of(alpha[i])] = i;
        i = i + 1;
    }
    return table;
}

A static item is a function body with no parameters, no effect row and no run time. It is evaluated once, during compilation, and its result becomes read-only data in the binary. Elsewhere the name is a &static [int], exactly like a string literal:

fn value_of(c: int) -> [] int {
    return decode_table[c];          // one index, bounds-checked as ever
}

Written as a body rather than as an expression because that is the shape the language already has: let, var, while, return and the linearity checker all apply unchanged, and there was no block-expression form to borrow. A static is a fn that cannot be called and does not need to be.

No effect row is written and none may be. A static performs nothing by construction — every effect needs a capability, a capability is a parameter, and this has no parameters. The row is [] and saying so would be decoration (linearity-and-effects.md §7.3).

Identity. A static has two identities like a function: a sig over its name and referent type, and a body. The name is in the sig for the reason a function’s is: a static has no parameters, so its type alone is a shape that every static of that type shares, and vcs publish keys its manifest by sig. Until this was fixed, a module with two [int] tables could not be published (the second was “already published at a different body”). A static and a function of one name in one module are refused (duplicate-declaration), since a lock is keyed by name. See hash-stability.md §1 for the identities this moved.

static has always been a region: it is what a string literal lives in, it outlives everything, and strings.md §4 reserves it as a binder nobody may declare. This slice adds the one way to put something new there:

alloc_slice[static](count, fill)       // and alloc[static](value)

Legal only inside a static item’s body, and refused anywhere else. The rule is lexical rather than a reachability analysis, for the reason §4.1 of compile-time.md gives about diagnostics: a rule a reader can check by looking at one function beats a rule that depends on what the whole program reaches.

The escape check needs nothing new. It already answers “does this outlive that?” with true for Region::Static against everything (strings.md §4 predicted exactly this: “the escape check needs no change at all”), so a slice allocated in static may be returned, stored, and read from anywhere — which is the whole point.

&!static stays refused. There is no unique reference into read-only data, which was already the rule for literals and is now the rule for one more reason: the data is in the binary’s read-only pages and writing through it would fault.


3. Failure is a compile error, not a fallback

compile-time.md §5 made a point of the opposite: running out of fuel while folding a call is never an error, because the call can simply be emitted and run instead.

A static has no such fallback. Nothing runs at program start, there is nowhere to put a runtime initialiser, and alloc_slice[static] has no runtime meaning at all. So when a static cannot be evaluated — the fuel runs out, or its body reaches something the evaluator does not implement — the program is refused, and the diagnostic says which.

That is lex-os-resolver’s rule in a compiler: refuse, don’t downgrade. The alternative — quietly computing the table at startup — would mean a program’s authority and its startup cost depended on how clever the evaluator was feeling, which is exactly the invisible dependency §5 was protecting against.

It also means the fuel budget is observable here, unlike in §5. A static that needs more than the budget does not compile. That is a real cost and the honest mitigation is that the budget for a static is larger than the one for a folded call: a table is built once and read for the life of the program, so it is worth more compile time than an expression that merely saves a few instructions.


4. What the evaluator had to learn

compile-time.md’s evaluator declined everything to do with memory — §3.1 said so, and a static is nothing but memory. So it gained one thing: a store, holding the slices allocated in the static region.

alloc_slice[static](n, fill)Appends n copies of fill to the store; the value is a slice handle
s[i]Reads, with the same bounds check the runtime does — and out of range is a compile error, because a certain trap is one (compile-time.md §4)
s[i] = vWrites through the handle
len(s)The length
s[a..b]A handle into the same run

Elements may be int, bool, byte or float. Structs, enums and nested slices are not supported — §6 — and a static holding one is refused rather than half-evaluated.

Everything else about the evaluator is unchanged, and that is the part worth noting: a static body may call any pure function, and those calls go through the same Machine that folds factorial(5). The table is built by ordinary cancho code.


5. What it is worth, and what it is not

examples/base64/ rewritten on a static table, same 5.4 MB of input, same method as §1, best of five:

timeagainst the scan
The scan, before294 ms—
A run-time table in a region, hand-plumbed52 ms5.7×
A static table48 ms6.1×
GNU coreutils base64 -d14 ms21×

All three agree with coreutils byte for byte.

The honest reading is the one §1 gives: the table is the win, and this feature is the way to have it without the plumbing or the arena. The gap between the last two rows is 4 ms on 5.4 MB — a 256-iteration loop and one malloc at startup — and the instruction in the inner loop is the same mov either way. Anyone reporting 6.1× as this feature’s number would be taking credit for the 5.7× that a region and four extra arguments already buy.

What it did buy in that program, exactly:

  • value_of keeps its signature, so it is still pure and cancho authority still says so. The plumbed version’s value_of takes a &t [int], which is also pure — but decode’s signature grows a region parameter, and main grows a region block.
  • The table is in .rodata, 2 KB mapped from the file rather than dirtied at startup.
  • Nothing is built at run time, so the program does the same work on a five-byte input as the plumbed one does on five megabytes minus 256 iterations.

Where it is not merely convenience is §1.1’s second row: 65 536 entries does not fit in an arena, traps if you try, and needs Heap otherwise. A static has no such ceiling, because the data is in the file rather than in a chunk.


6. Open

QuestionWhy it waits
Structs and enums in a static§4. The evaluator’s store holds scalars; an aggregate needs the layout rules the backend has and the evaluator does not. A table of pairs is the first thing anyone will want
A static that is a scalarstatic LIMIT: int { return 4 * 1024; } is not allowed — the item is for data, and a scalar constant is what §3 of compile-time.md already folds. Worth revisiting only if a reader finds the asymmetry surprising
Nested slicesA [[int]] needs handles inside the store to survive emission as relocations. Real, and not needed by anything yet
Deduplicating identical staticsTwo statics with the same bytes are two symbols today. The canonical AST makes the check cheap; nothing has asked
A bigger budget, or a stated one§3 makes the fuel budget observable. It is a compiler constant; whether it should be a flag is a question about builds rather than about the language

7. The suite

FixtureRule§
static_alloc_outside_a_static.choalloc_slice[static] is lexical to a static item2.1
unique_reference_into_a_static.cho&!static stays refused2.1
static_that_cannot_be_evaluated.choRefuse, don’t downgrade3
static_index_out_of_range.choA certain trap while evaluating is a diagnostic4
TestRule§
a_static_becomes_read_only_dataThe table is in the binary, and the program never builds it2
a_static_table_decodes_what_coreutils_decodes§5’s rewrite agrees byte for byte5
AcceptingShows
static_data.choA table built by a loop, one built by calling a pure function, and both read back
examples/base64/§1’s 5.7×, without the plumbing §1 needed

How often does a hash actually move? Two rates, and they disagree

Status: measured, and the instrument turns out never to have been tested.

canonical-ast.md §8 keeps three things off the contract list and says why the list might never empty:

“The point is the rate: the question of whether this section can ever be emptied is a question about how often these actually move, and nothing was measuring that.”

ROADMAP.md’s lex-vcs row is gated on the same number — “the design doc is worth writing when that number exists”. This is the number, and there are two of them.

The encoder has moved 20 times and the golden fixtures have observed none of them, because they landed after the movement stopped. The language has moved enough that 71% of this repository’s own .cho history no longer type-checks — and the single largest cause is one effect label being split in two.

A content-addressed VCS keyed on these hashes inherits the second rate, not the first.

Current reading, as of #163: 39%, not 71%, up from 37% at #142 (§2’s “Re-measured (#163)” below, appended rather than correcting the “growth has stopped” sentence two rounds up — that sentence was true when written and is not true now, and the record keeps both). The io-split debt itself has not grown — --migrate/--alias still recover exactly 23/0, unchanged across all three measurements — but the corpus gained 28 revisions since #142 and 15 joined the unreadable pile, not 3. One cause is confirmed (a harness gap: scripts/history.py never learned to replay a file that imports a vcs-fetched package); the rest is measured, not yet explained.


1. The encoder rate: zero, out of zero observations

crates/cancho-id/tests/golden.rs pins 35 fixtures, one per node family. §8 introduced them for exactly this purpose: “It is not a freeze and a failure is not a bug report — it asks which of two things happened.”

commits touching crates/cancho-id/src/20
…of those, since the goldens landed0
commits since the goldens landed14
golden hashes that moved in them0

So the fixtures have been green for fourteen commits, and that is worth less than it looks: none of those commits changed the encoder. The instrument was installed after the thing it measures stopped happening.

One commit since did touch crates/cancho-syntax/src/ast.rs — file handles (#65), which added three prelude type names. It added no node kind, so no tag moved, and the goldens were right to stay still. That is one observation of the right kind and it passed.

The honest reading: the encoder rate is unmeasured, not low. It will become measurable the first time a milestone adds a node, and §8’s sentence about “every milestone from M2 on adds nodes” is the reason to expect that to happen rather than not.

A second observation, same kind (#70). character-literals.md adds a spelling — 'a' is the integer 97 — and rewrote 119 sites across std/, examples/ and tests/accept/ onto it. No golden hash moved, and a_character_literal_hashes_as_its_integer checks the two spellings against each other. Two observations, two passes, and the encoder rate is still unmeasured: a change designed to move no tag cannot show that a moved tag would be caught.

A third observation, and the first that moved a hash (static signatures). A static’s sig hashed only its referent type, so two statics of one type shared an identity and vcs publish, which keys its manifest by sig, refused the second. The fix puts the name in the sig (behind a new tag, STATIC_DECL, appended as 0x75), as a function’s already was. Every static’s sig moved; no body, no function, type or extern hash did, because a body refers to a static by name and not by sig. What pinned them: one golden row (static-item), and the committed packages/x509 store, the only static in packages/ (oid_table), plus the requires locks of the five dependent modules (x509_names, x509_verify, tls_message, tls_slot, tls_client12); scripts/publish_packages.py regenerated them and --check passes. Nothing else quoted a static’s hash. Why not a narrower fix (the publish layer keying a static by (name, sig) and leaving identities alone): the manifest is keyed by sig on purpose, a lock records sig_id as the identity a consumer pins, and a sig that two declarations share would still be wrong for every other reader of it (resolve, fetch, a future diff). The price of the wide fix is one regenerated package store, paid once, in a language that moves faster than this (§2); the price of the narrow one is a non-identifying identity. Also closed: an unused static (one nothing reachable reads, so cancho-ir drops it) failed to publish as “internal: … no lowered function, extern or static”; the publish layer now asks the source.


2. The language rate: 71% of its own past is unreadable

The other rate needs no new instrument, because git has it. Every distinct revision of every .cho file under std/ and examples/ across all 67 commits, compiled by today’s binary — one compiler over every revision, so anything that fails is the language having moved and never the encoder:

distinct file revisions117
today’s compiler still reads34 (29%)
no longer parses or checks83 (71%)

And the causes are not spread out. Classified by first error:

revisionswhy
35performs io_write, which its row [io] does not declare
8no effect row at all — written before rows existed
8a type or function declared twice (files later split apart)
5needs a sibling file, or a name that moved
4an unknown type
3a builtin’s arity changed

Re-measured (#85). editions.md §2 replays the same history with a harness that is in the repository (scripts/history.py) and gives each file the program it belonged to: 58 of 141 revisions (41%) no longer check, and the io split is still the largest class, at 45. §2 there also shows that an alias cannot absorb it, because rows are exact.

Re-measured (#142): 37%, and the growth almost stopped adding to it. scripts/history.py again, against 24 more commits’ worth of history: 165 revisions, 104 (63%) read, 61 (37%) do not. The io split is still the whole story — --alias still recovers zero (a row is exact, so aliasing just moves which half is missing, exactly as editions.md §3 found), and --migrate’s two mechanical steps still recover exactly the same 23. What changed is the rate: the corpus grew by 24 revisions since #85 and only 3 joined the unreadable pile. Not because the vocabulary stopped growing — §3 below found real growth in the same window — but because everything added since (null_ptr, spawn/join, Thread) is edition-gated and additive by construction, the property editions.md exists to guarantee. The 61 unreadable revisions are a closed debt from one pre-editions rename, not a target still moving.

Re-measured (#163): 39%, and the growth resumed. scripts/history.py again, against 8 more commits touching std/examples (20 total since #143’s measurement commit): 193 revisions, 117 (61%) read, 76 (39%) do not. --migrate still recovers exactly 23 and --alias still 0 — the io-split debt itself has not grown by one revision — but the corpus gained 28 revisions this time and 15 joined the unreadable pile, not 3. The “growth has stopped adding to it” sentence two entries up is false as of this measurement and is kept above anyway, uncorrected in place, so the record shows what was believed and when.

One new cause is confirmed, not guessed: examples/tls_client/socket.cho (docs/next-phase.md §4.1’s own migration onto net.connect) is unreadable by this harness for a reason that has nothing to do with the language moving. scripts/history.py’s own lay_out passes --std or a file’s same-folder siblings and nothing else — it has never known how to hand a replayed file the packages/ source a vcs-fetched import needs. Every other package-importing example (fetch.cho, report.cho, serve.cho, collect.cho, vsock.cho, agent_guest.cho, agent_supervisor.cho, results_stub.cho) was already unreadable by this same gap before this measurement, already inside the 61 — confirmed directly, cancho check <file> --std on each reproduces the identical no module 'net.sockets' refusal socket.cho now also gets. socket.cho joining that list is the harness missing an argument, not a program whose identity moved.

The other roughly 14 of the 15 newly unreadable revisions are not individually diagnosed here. Attributing each one would mean replaying every new revision by hand and reading its refusal, which this entry has not done — and next-phase.md §4.1’s own lesson applies just as much to a number as to a duplicate: a check that measures correctly can still be wrong to explain without doing the reading. What is measured and certain: the full current corpus builds and passes cargo test --workspace at 100% (that is a different, stronger guarantee than this historical replay — it says today’s code is right, not that yesterday’s stays readable), the io-split debt is unchanged, and the harness gap above accounts for exactly one of the fifteen.

One label rename accounts for 42% of the unreadable past. When standard-input.md §2 split io into io_read and io_write — correctly, and for reasons that document argues well — it invalidated thirty-five file revisions’ worth of identity in a single commit.

Nothing about that is a bug. It is what a language doing its growing in public looks like, and every one of those changes was the right call at the time. The point is only that it is the rate that a hash-keyed tool would actually experience.


3. What this means for lex-vcs

ROADMAP.md’s row says lex-lang’s crates/lex-vcs is “81% language-agnostic already” and that the gate is “this side: §8”. The two rates say which side of §8 matters.

  • The tag values (§8’s first bullet) are stable in practice and unmeasured in principle. Freezing them is a table nobody has had to write yet.
  • What moves is the vocabulary a body is written in. A body’s hash is a function of its AST, and its AST mentions effect labels, builtin names and arities. Those changed 46 times in 67 commits.

So an op DAG built on these hashes would not mostly record edits a programmer made. It would record the language changing underneath programs nobody touched — thirty-five of them, at once, for one rename.

That is not an argument against the design. It is an argument about when: the row’s instinct to wait was right, and the thing to wait for is not a number but a plateau — a stretch of commits in which the effect vocabulary and the builtin surface do not move. At the time this was written, this repository had not had one yet: the last four slices before it added err_write, file_read, three prelude types and three builtins.

Corrected (ROADMAP.md’s lex-vcs row, current as of #119). That was true when written and is not any more. builtin.rs and defs.rs — every builtin, every effect label, every prelude type’s own row — last changed at #92, the third Net slice. The 27 commits since (#93 through #119, an entire second backend designed and built start to finish) touch none of crates/cancho-ir/, crates/cancho-types/ or crates/cancho-syntax/ at all — git diff 52daddb..2999a7d -- crates/cancho-ir/ crates/cancho-types/ crates/cancho-syntax/ is empty. The plateau this section said had not happened has now happened, measured the same way §1 and §2 above were: by reading the commit range rather than assuming it. Whether 27 commits is long enough — long enough that a lex-vcs design written against it would not immediately need revising — is a judgement call this section leaves open rather than answers for it.

Corrected again (current as of #142): the plateau did not hold, and that turns out to be the wrong thing to have measured. null_ptr (#137, opaque-pointers.md §3) and spawn/ join/Thread (threads.md) all landed after #120, touching builtin.rs/defs.rs/crates/cancho-types exactly the way this section’s own reasoning predicted growth would. But every one of them is edition-gated and additive by its own stated design (opaque-pointers.md §4, threads.md §4): an edition-1 or -2 file cannot be broken by a builtin or type that only edition 3 or 4 grants. That is the actual property a hash-keyed tool needs, and it is not “no growth” — it is “no growth that reaches backward.” §2’s fresh measurement above is the evidence, not an assumption: the corpus grew by 24 revisions in this same window and only 3 joined the unreadable pile. The vocabulary moved. The past it could break did not.

Held mechanically (current at head). The number this section kept having to re-state is now a checked property rather than a paragraph. scripts/history.py --explain replays every distinct revision of every .cho under std/ and examples/ through today’s compiler and refuses, exit 1, on any failing rule not listed with a reason in stability-exemptions.md; a listed rule that stops failing fails the run as stale, so the list only ever shrinks, and the CI job stability-plateau runs it on every push with the full history fetched. The failing count stays honest — an exemption does not remove a revision from the number — so the 37%→39% climb this section could only correct in prose is, from here, either a red build or a new line in a committed file with a reason beside it. Each exempted rule carries a revision count, and the check fails on any increase past it, on an unlisted rule, and on a listed rule that no longer fails (the counts only ratchet down).

Corrected in place: 65% (now 67%) versus 39% are different corpora, not a regression. The exemption file’s baseline was first measured at 203 revisions, 133 failing (65%), and is 210 revisions, 140 failing (67%) at the time of the latest merge of main; the 39% of #163 was 193 revisions, 76 failing. Re-run today, scripts/history.py gives the same 140/210 on the default backend and on --backend cranelift, so the backend is not the cause (checked). What differs is what is replayed: revisions() keeps only paths ending .cho, and since the source extension was renamed from .ls (#347) it no longer sees the 382 distinct .ls file revisions older than the rename (counted by blob and path over the history of std/ and examples/; 210 .cho ones remain). The rename also made every file a new (blob, path) at the rename commit, so what is replayed is mostly each file’s state from #347 on. The 39% is therefore not superseded: it describes a corpus the instrument can no longer replay, and 67% is the figure for the post-rename corpus only. Replaying the .ls revisions (laying each out as .cho) is the open follow-up; until then read the two numbers as separate series.


4. What this does not say

  • Not that the hashes are unstable. Within one build they are exact, and 61 relational tests plus 35 golden fixtures say so. The instability measured here is across language versions, which §8’s third bullet already refuses to make a claim about — this puts a number on the refusal.
  • Not that 71% is a defect rate. Every one of those revisions compiled when it was written. The figure measures how far the language has travelled, and a young language travelling is the intended behaviour.
  • Not a measurement of the encoder. §1 is explicit that the encoder rate has zero observations. Anyone reading §1 as “the encoder is stable” has read it backwards.

Editions: what one can absorb, measured on this repository’s past

Status: edition 2 is real. (Corrected: the marker shipped ahead of Net, not with it — #87, after the parser.rs split (CONTRIBUTING.md) this paragraph originally said it needed.) §7 turned out to need more than the marker: nothing in the compiler consumed Ast::edition_of outside the parser’s own unit tests, so every place a written name resolves — a signature’s parameters and return type, a let that destructures a struct, a struct literal, a variant constructor — needed the same “which declarations can this file see” question resolve_type_at’s lookup now answers, not only that one function (connect.md §9). Edition 2 is Net’s first slice: the capability, its net_out label, and a connect that takes an address a caller already has. Net’s remaining two slices — name resolution, then inbound — are additions of the same shape and need nothing further here.

The audit’s L2 asked for an edition marker, a label-alias path so a rename is a warning for one edition, and a vocabulary freeze once Net and Exec have added their labels. The measurement below says the second part cannot work as asked. This language has no warnings, and more to the point its rows are exact. Reading an old file’s io label as io_read, io_write, the two labels that replaced it, recovers 0 of the 45 old revisions that label split broke.

What editions can do is narrower and still worth having. A change that adds something (a capability, a label, a builtin, a field on Split) can simply be absent from an older edition, which is exact and costs old files nothing. A change that refines something needs a migration tool. A change that tightens soundness needs neither, and should stay a refusal. Net is purely additive, so the 210 files that destructure Split need no edit at all when it lands.


1. What was asked

The audit, L2:

Language churn: 71% of historical .cho revisions no longer check, and 42% of that comes from one label rename (io → io_read/io_write). Introduce a language edition marker (edition 0.1; per file or per program), and a label-alias / deprecation path so a rename is a warning for one edition instead of a break.

The freeze has to follow Net and Exec, since both add labels. And net.md §4.1’s decision adds a sixth field to Split, which every main destructures completely:

error: `Split` has 5 fields, but this pattern names 4; destructuring takes the whole value apart

210 files in this repository have that pattern. Adding net to Split with no edition breaks all of them. That makes this document the prerequisite for Net, not an afterthought to it.


2. The past, replayed

scripts/history.py checks every distinct revision of every .cho file under std/ and examples/ with today’s compiler. A library file is checked beside today’s other library files and a main that does nothing. An example in a directory is checked beside its siblings as they were in the commit that introduced it.

distinct file revisions141
today’s compiler still reads83 (59%)
no longer checks58 (41%)

Classified by the first refusal:

RevisionsFirst refusalWhat changed
45effect-not-declaredio split into io_read and io_write (standard-input.md §2)
8expected [written before every signature declared a row
3arity-mismatchputchar(c) became putchar(io, c)
2linear-value-unconsumedlinearity got stricter

hash-stability.md §2 measured the same history earlier and found 71% unreadable, over 117 revisions. It does not say how a library file without a main, or one file of a multi-file example, was checked, so the two figures cannot be reconciled exactly. This harness gives each the program it belonged to. The class that matters is the same in both: one label split is most of the unreadable past.

Running this replay also found that check --output json answered invalid JSON for every parse error, 20 of the 214 reject fixtures (agent-errors.md §5, fixed in #84).


3. An alias cannot absorb a split label

The audit’s alias is the obvious mechanism: an edition-1 file’s io means io_read, io_write. scripts/history.py --alias applies exactly that rewrite to every row of every broken revision and checks again.

broken revisions58
recovered by reading io as both halves0
now refused with effect-declared-not-performed41

The reason is one rule: a row is exact (linearity-and-effects.md §7.3). A function that only writes, and declared [io] when io meant both, now declares io_read, which it never performs, and is refused. The old io has no fixed translation. Its correct translation is whichever half each body performs, and working that out is inference over the body.

So an alias could only work in one of two ways, and each costs something this language decided not to pay:

  • Relax exactness for old files. A row in an old file would be an upper bound, while a row in a new file is exact. That gives one program two meanings of the same syntax, and the authority report would over-state what old code does.
  • Infer the halves. A compiler that decides what io meant in each body is inferring rows, which §7.2 refuses across a boundary and this would do silently, on every build.

4. What a tool can absorb

The same inference is harmless when it runs once, in a tool, and writes its answer into the source, where the checker then checks it like any other row. scripts/history.py --migrate simulates such a tool. It is driven by the checker’s own refusals and applied only to the file under test, and it knows two steps:

  1. Row repair: add a label the body performs, drop one it does not, and drop the old io once either half is added.
  2. Split repair: name the capabilities the pattern leaves out, and release each at once.
Revisions
recovered by the two steps23
the same row repair, needed in a sibling file the simulation does not edit7
a builtin gained a capability parameter (putchar(c) → putchar(io, c))11
a linear value the old program never consumed17

So 30 of the 58 (52%) are mechanical: the 23 plus the 7 a real tool would reach by editing every file. Peeling back the row changes exposed a second layer. 15 revisions break only because Split grew: it gained heap in #22 and args in #24, and each time every main that destructured it stopped checking. That is exactly what adding net would do today.

The other two classes are not mechanical, and should not be:

  • A capability parameter changes the signature of every function between main and the call. A tool could thread one through, but where authority enters a call graph is the programmer’s decision. Having to write it was the point of the change.
  • Linearity getting stricter means the old program leaked a value the language now refuses to leak. Migrating it automatically would mean choosing, for someone else, how to consume it.

5. Three kinds of change

KindExampleWhat absorbs it
Additivea field on Split, a new label, a new builtinthe edition: the new thing is absent in older editions. Exact, with no tool and no second meaning. Old files compile unchanged
Refiningio → io_read, io_write; a builtin gains a capability parametera migration tool, run once at an edition boundary. Old files are refused with the edition named and the tool as the fix
Tighteninga linearity rule that closes a leaknothing. It stays a refusal, because the old program was wrong

Additive changes are cheaper than §2 suggests, and they are also the ones that have collided before. file-handles.md §4.2 recorded that three new prelude names cost 31 fixtures and one extern fn in examples/serve/, because the new names were ones programs were already using. Under an edition, the new names would not exist in edition-1 files.


6. The decisions

6.1 The marker

edition 2;
  • Per file, as the first item, before module if both are present, and at most once. Per file because Split is destructured in one file, and because a file is what one author writes at one time. A per-program edition would have to be agreed across files that are compiled together but written separately (many-files.md).
  • Absent means edition 1, the language as it is today. No existing file changes, and the default can never move: a file with no marker means edition 1 forever.
  • An unknown edition is refused, with a rule tag of its own. A compiler that guessed would be the silent downgrade the rest of this repository refuses.

6.2 What an edition may change

  • Only additions are held open across editions. Every supported edition is today’s language minus some additions, so the checker has one meaning of every construct. It asks which additions are visible in this file, and nothing else.
  • A refining change ends support for every earlier edition. Files in those editions are refused with the edition named and cancho migrate as the fix. The supported window is therefore “since the last refining change”, and it is visible in one table rather than implied by a pile of aliases.
  • A tightening is not an edition matter. It lands as a refusal in every edition, as it does today.

6.3 Identity

A file’s edition changes what its code means (edition 2’s split returns a different Split), so it belongs in the identity of what that file declares. Edition 1 contributes no bytes. Every hash that exists today stays exactly where it is, and moving a file to a later edition is a deliberate, recorded step that moves its hashes once. That is the plateau hash-stability.md §3 asked for: declared, not waited for.

6.4 The freeze

Once Net and Exec have added their labels, the vocabulary of that edition is closed. New labels, builtins and capabilities go into the next edition, which costs old files nothing (§5). A refinement happens only at an edition boundary, with a migration step to go with it.

6.5 The tool

cancho migrate is designed here and not built. §4’s two steps are its first two. It is worth building when the first refining change is planned, and none is: Net is additive.


7. What Net needs from this

Edition 2 is edition 1 plus Net: the net field on Split, the net_in and net_out labels, and the socket builtins. Built so far (slice 1, connect.md §9): the net field, net_out, and connect. net_in and the rest of the socket builtins are slice 3. An edition-1 file’s split returns today’s five fields, and its programs cannot reach Net, which they never could. The 210 files that destructure Split stay as they are. A program that wants the network writes edition 2; in the file whose main calls split.

Split is two declarations of one source name rather than a sixth field appended to the existing one, and that is the part §6.2 did not anticipate: Split is res by inference, so a sixth field nothing edition-gated could unname would still have to be consumed by every caller, which is exactly the break an addition must not cause. Name resolution now asks two questions where it asked one — is this declaration visible at all, and (new) is it the latest one this file’s edition can see — everywhere a written name resolves to a declaration, not only in resolve_type_at.

A program can mix editions. Library files written in edition 1 are used by an edition-2 main unchanged, since nothing in them mentions Net. What an edition-1 file cannot do is name something only edition 2 has, and that is refused like any other unknown name.

Edition 5 is edition 4 plus the socket handles (native-sockets.md): the types Listener, Conn, Listening, Accepted, Received and Sent, the builtins tcp_listen, tcp_accept, conn_read, conn_write, the two *_nonblocking and the two *_close, and the conn_accept, conn_read and conn_write labels. Purely additive, so no Split changes: an edition-5 file’s split() is edition 2’s, with net.

One rule this edition tightened rather than added: a user type named like a prelude type used to be refused whatever the file’s edition (BuiltinRedeclared), so a file that declared its own Net or Thread was refused even at an edition that cannot name them. The check now asks the file’s edition, which Conn – a name a great many programs want – would otherwise have broken (tests/accept/socket_names_are_edition_five.cho).

Edition 6 is edition 5 plus the signal capability (signals.md): the types Signals, SignalWatch and Watching, the builtins signals_watch, signals_pending, poller_add_signals and signals_close, the labels signals("...") and signals_read, and an eighth field on Split. That last is the additive change this document’s §5 names, and it is why this is an edition and not a fifth slice of edition 5: slice 4 of native-sockets.md added clock to edition 5’s Split because no edition-5 file destructured it yet, and that argument expired when 48 .cho files in this repository (and the programs embedded in three conformance modules, and every service built on them) came to write the seven fields. Split is now four declarations of one name; an edition-5 file’s split() still answers seven.

Edition 6 also has the directory handle (directory-handles.md): the types Dir and DirOpened, the builtins open_dir, dir_enter, dir_open_read, dir_close, dir_open_new, dir_open_append, dir_rename, dir_remove and dir_sync, and the labels dir_read and dir_write. Purely additive, like edition 5’s later slices: no edition-6 file in this repository declared any of the names. The same holds for directory listing (directory-listing.md): the types DirList, Listing, Listed and DirStat and the builtins dir_list, dir_next, dir_list_close and dir_stat, under the existing dir_read.

And it has value_barrier (value-barrier.md), a builtin for constant-time code. Purely additive in the same way: no edition-6 file declared the name. std/bigmod.cho and std/ecdh.cho are the first std modules to declare edition 6;, to use it.

And it has f32 (f32.md): the type, the f32 literal suffix, and the builtins f32_of, float_of32, bits_of32 and f32_of_bits. Purely additive, and counted first as file-handles.md §4.2 asks: f32 or any of the four names appears in 0 of the 547 .cho files under tests/, examples/, std/, packages/ and benches/, so it joined edition 6 rather than opening a seventh, as value_barrier did. An earlier file still owns every one of those names (tests/accept/f32_names_are_edition_five.cho).

Edition 7 is edition 6 plus the capability to start a program (processes.md): the types Exec, Child, Pipe, ChildEnd, Stdio, Piped, Spawned and Exited, the builtins pipe_open, exec_spawn, exec_spawn_in, child_wait, child_kill, pipe_read, pipe_write, pipe_nonblocking, pipe_close, child_end_close, poller_add_pipe and poller_add_child, the labels exec("..."), child_signal, pipe_read and pipe_write, and a ninth field on Split, exec. The ninth field is why this is an edition and not a later slice of edition 6, for the reason the eighth was: edition-6 files already destructure eight fields. Split is now five declarations of one name; an edition-6 file’s split() still answers eight.

Edition 7 also has float_of_bits (floating-point.md §4.1), bits_of’s inverse, a bitcast and a name an older file may already declare (tests/reject/float_of_bits_is_edition_seven.cho), and dir_rename_new (directory-handles.md §3, slice 4), the rename that never replaces a name, and dir_mode and dir_own_mode (directory-listing.md §3.5). Each is a builtin under an existing label and a name an older file may already declare, so it is absent before edition 7 (tests/reject/dir_rename_new_is_edition_seven.cho) and nothing else about edition 6 changes.

Edition 9 is edition 8 plus the word-at-a-time scan (word-scan.md): the builtins load_le64, byte_mask64, trailing_zeros, leading_zeros and popcount. It exists because §6.4 closes edition 7 and edition 8 is the serial-port capability (the freeze: a name added to a closed edition could shadow a user’s own), and these are exactly the names a program may declare for itself: popcount and trailing_zeros are what anyone writes by hand before the language has them (tests/accept/word_scan_beside_own_names.cho declares all five at edition 7 and means its own). Split is unchanged, so an edition-9 file destructures the ten fields edition 8 does. A file that declares edition 9 hashes differently from its edition-8 text (§6.3), which touches only the files that adopt it; no other file’s hash moves (word-scan.md §6 counts it).

And read_bytes (standard-input.md §7), standard input in bulk under the existing io_read label: read_bytes(io, into) -> [io_read] Read, with file_read’s own Read. A builtin under an existing label and a name a program may already declare (it is a natural name for a user function), so it is absent before edition 9 (tests/reject/read_bytes_is_edition_nine.cho) and nothing else about edition 8 changes. It adds no type, no label and no Split field. It was written against edition 7 and moved to edition 9 when that edition was found closed (§6.4) with word-scan opening 9. Adding it moved no identity: cancho ids --std over a program that imports every std module gives the same hashes before and after (counts in the PR), and the repository’s programs give the same list (std.json.parse_with, which came in the same change, is a new std name, so it adds a body and a sig line and changes none; std modules are not editions).

Edition 7 also has ghash_update (crypto-builtins.md §3), which #382 retired but kept. #382 added aes_ctr32, ghash_powers, gcm_tag and gcm_tag_diff (gcm-wide.md §3.4) to edition 9, the latest, for the reason the word-scan builtins are there: edition 7 is closed (§6.4) and these are names a program may declare for itself (std/gcm.cho declares edition 9). (Corrected before merge: the first version of #382 put them in edition 7, written while edition 7 was still open.) std.gcm stopped calling ghash_update, but the builtin still reads and still answers what it did (a block at a time, now computed with the new reduction), so a program written against it before #382 still compiles and runs. (Corrected before merge: the first version of #382 removed it within the edition, and scripts/history.py --explain (stability-exemptions.md) showed the cost: std/gcm.cho as it stood before #382 stopped reading, a not-a-function past the recorded count. Keeping it costs one small function, emitted only in a module that calls it.) A retired builtin is documented as such where it is declared, and nothing in this repository calls it.

Edition 9 also has tcp_listen_on (native-sockets.md §12, #498): tcp_listen bound to one IPv4 address named by a string literal, under the existing net_in label, with the inbound bound reading as a port, a host:port or a host. A builtin under an existing label and a name a program may declare for itself, so it is absent before edition 9 (tests/reject/tcp_listen_on_is_edition_nine.cho). It adds no type, no label and no Split field, and tcp_listen is unchanged.

Edition 9 also has hw_sha256, sha256_blocks and sha256_block_words (sha256-builtin.md §3, #445), for the same reason as #382’s: edition 7 is closed and these are names a program may declare for itself. std/crypto.cho declares edition 9 to call them, which moves its own hashes and no other file’s (§6.3).

Edition 9 also has exec_spawn_detached, child_release and child_pid (processes.md §4.11, #524), and with them the label exec_detached: names a program may declare for itself, so absent before edition 9 (tests/reject/child_release_is_edition_nine.cho). The label is reachable only through those builtins, so no older file can name it, perform it or be broken by it; it adds no type and no Split field.

Edition 9 also has child_kill_group (processes.md §4.12, #533): child_kill’s signals to the process group a detached child leads, a name a program may declare for itself, so absent before edition 9 (tests/reject/child_kill_group_is_edition_nine.cho). It is a second builtin and not a change to child_kill because edition 7 is closed (§6.4). It adds no type, no label (child_signal) and no Split field; std/process.cho declares edition 9 to call it, which moves its own hash and no other file’s (§6.3).


8. What this does not do

  • The marker built nothing on its own. (Corrected: it shipped ahead of Net, not with it.) edition N; parses and is stored per item alongside docs/modules.md’s module side table; an unknown edition is refused under its own tag (unknown-edition). That slice landed unobservable from any program a file could write, since nothing yet named anything edition 2 added. Net’s slice 1 is what makes edition 2 observable for the first time.
  • It does not migrate the past. §2’s 58 revisions stay unreadable, and nothing in this repository needs them.
  • It does not replace refusals with warnings. A diagnostic here is still a refusal (AGENTS.md §8). A later edition’s convenience is never paid for with a softer earlier one.
  • It does not settle Exec. Exec is expected to be additive in the same way as Net, but that is a prediction until its design exists.

9. The suite

WhatPins
scripts/history.py§2’s table: 141 revisions, 58 unreadable, by first refusal
scripts/history.py --alias§3: 0 of 58 recovered by reading io as both halves
scripts/history.py --migrate§4: 23 recovered by two mechanical steps, and what stops the rest

These are measurements of a history that only grows, so they are scripts rather than tests: a test would fail every time a slice changed the language, which is exactly the rate being measured.

Content-addressed VCS: what cancho would need, and what it would not

Status: §7’s plateau question is answered, and the gate, op log, attestation and signing are built. crates/cancho-vcs exists: the Operation vocabulary (§4’s scoped-down AddFunction/ RemoveFunction/ModifyBody, no budget_cost), the edition tag (§6), canonical BLAKE3 identity (checked against real cancho-id hashes, not invented strings), a gate.rs that type-checks a candidate program with no code changed at the boundary from lex-vcs (§3’s own claim, now checkable), a loose-file op log, and a hash-chained attestation log sealed with Ed25519 (ed25519-dalek, the same crate lex-os-audit uses — not std.ed25519, docs/ed25519.md’s own module for a different consumer). What remains from §3’s “largely unmodified” list — merge, merge sessions, issues — is not built yet; §8 names what is next.

ROADMAP.md’s own “lex-vcs” row measured a plateau in the effect vocabulary and the builtin surface (hash-stability.md) and ended on an open question: “whether 27 commits is enough of a plateau… is the open question this row now asks, not whether one has happened at all.” §7 below answers it. Everything before that is the design the answer unblocked.


1. What this is answering

README.md’s design commitments table lists the AST as “canonical, content-addressable, stable per-unit identity — designed in, never retrofitted,” and cancho ids prints exactly that per declaration. What cancho does not have is anywhere to put those identities: a program here is still a set of files under ordinary git, diffed and merged line by line, the one part of lexlang.org/manifesto’s own argument (§IV, “discard line-based version control… content- addressed AST nodes, stable identities across reformats”) this repository has stated the identity half of and never finished the other half of.

agent-errors.md §2 found the concrete cost of that gap already: lex-lang’s rule_tag/suggested_transform pair is the structured repair hint an agent loop reads instead of re-deriving a fix from prose, and “the rule_tag half transfers whole, and suggested_ transform does not — its own guidelines page shows it beside rule_tag in the checker’s JSON, and in the source it lives in lex-store/lex-vcs/lex-lsp as an attestation against an op id, which is machinery cancho deliberately does not have.” A store is not a nice-to-have layered on top of the checker; it is what the other half of that repair loop is made of.

lex-lang’s crates/lex-vcs already built this once. The question this document answers is not whether to have an op-log VCS — that argument is agent-errors.md §2’s, made before this document existed — it is how much of lex-vcs cancho can reuse, and what has to be native, so that building it is a scoped slice rather than a second multi-crate project.


2. What lex-vcs already is, read from the source rather than the pitch

ROADMAP.md’s #55 row estimated 81% language-agnostic by reading crates/lex-vcs/src/. Reading the same source for this document finds the same number a different way — by naming exactly which files earn it and which do not, rather than a percentage of the whole crate.

Generic already, keyed on String/BTreeSet<String>, and said to be on purpose:

FileWhat it isWhy it is already generic
operation.rs (1046 ll)Operation, OperationKind, OpId, SigId, StageId, EffectSet, BlobId, ModuleRefEvery one of these types is a type alias to String or BTreeSet<String> — read straight from the source: “we keep it as String here so this crate has no dependency on lex-store’s internals” (SigId), “kept as a string so this crate doesn’t pull in lex-syntax’s parser” (ModuleRef). The decoupling is stated, not incidental
apply.rs + gate.rsApplies an Operation to a store state; wraps that with a type-check passgate.rs’s own header: the gate runs lex_types::check_program against the candidate program and rejects the op if it does not typecheck. Nothing about the gate’s own logic is Lex-specific — it calls out to a checker and reads back a verdict
attestation.rs, signing.rs, merge.rs, merge_session.rs, issue.rs, predicate.rs, op_log.rs, history_index.rs, migrate.rsAttestation log, Ed25519 signing (Keypair/verify_message), whole-function merge, multi-file merge sessions, typed issues, predicate branches, the op log itself, and format migrationAll key on OpId/SigId/StageId/String, none import lex_ast. lex_vcs::signing is, incidentally, exactly the Ed25519 verification code docs/sha512.md §5 named as unstarted for cancho’s own runtime — a second, independent reason the two initiatives will eventually meet

Not generic, and named exactly because they are the exception:

FileLinesWhat it walks
compute_diff.rs449lex_ast::{CExpr, FnDecl, TypeDecl, Effect, ...} — structural diff between two function bodies, node by node
diff_to_ops.rs617The same CExpr-shaped diff, turned into OperationKind values
body_merge.rs411Three-way structural merge of two CExprs against a common base

449 + 617 + 411 = 1477 — ROADMAP.md’s “only ~1,500 lines” was not a rounded guess, it is these three files exactly, and nothing else in the crate names lex_ast at all.


3. What ports as-is, unmodified, and why that is more than it sounds

Operation/OperationKind/OpId/SigId/StageId/EffectSet need no cancho-specific type at all: cancho ids already produces the String content hashes SigId/StageId want, and an EffectSet is already how cancho authority reports a row — docs/authority.md’s own JSON output is a sorted list of effect-label strings. A AddFunction { sig_id, stage_id, effects, budget_cost, in_file } op is legible to cancho with no field renamed:

  • sig_id/stage_id: cancho-id’s own hashes, unchanged.
  • effects: the row cancho authority/check --output json already emits as strings.
  • in_file: many-files.md’s own model — identity by content rather than location — is exactly what this field records for lex-lang’s multi-module packages, and cancho already has multi-file programs with the same property.
  • budget_cost (and ModifyBody’s from_budget/to_budget): the one field with no cancho equivalent, because budget.md settled [budget] as a language feature with a documented no — “none of those is a property of a program’s text.” cancho-sourced ops simply never populate it, and the field’s own Option + skip_serializing_if discipline (already built so a pre-#247 op keeps its OpId unchanged) is exactly the mechanism that makes “some producers never fill this in” cost nothing — it was built for a different reason and reads as though it were built for this one.

The apply→gate pipeline (apply.rs, gate.rs) ports as an idea with no code changed even at the boundary: gate.rs asks one question of the candidate program — does it typecheck — and cancho already answers that question, at the same total, per-declaration granularity agent-errors.md §1 measured. Swapping lex_types::check_program for whatever calls into cancho-ir’s own checker is the entire seam.

Attestation, signing, merge sessions, issues, predicates, the op log and history index: no seam at all, because none of them read an AST — they read OpIds, SigIds, and the Strings a SigId and an EffectSet already are.


4. What has to be native, and what changes about it here

compute_diff, diff_to_ops and body_merge cannot be reused; they have to be re-written, against cancho-ir’s own Expr/FnDecl rather than lex_ast::CExpr. That is real, new work at roughly the scale the existing files are — not a wrapper, not a trait parameterization, because the node shapes genuinely differ (no closures, no pattern-match arms shaped like Lex’s, region/Box where Lex has none of either) — but it is bounded work with a precedent to check every case against: fold.rs’s own exhaustive, no-wildcard walks over every Expr variant (built for static reachability, crypto.md §6, and reused unmodified as the discipline for this) are the shape diff_expr’s own match should take, so a future Expr variant that goes unhandled is a build failure here the same way it already is in cancho-codegen-llvm’s body/expr.rs.

One real difference from lex-lang, and it does not need a new invariant — body_merge.rs already built the thing that resolves it. ROADMAP.md’s own #55 row raised the concern first: “in a linear language two edits that each typecheck can together move a value twice, so it will conflict far more often here than it does there.” Reading body_merge.rs finds the answer was already written into the design before cancho asked the question: “It makes no type judgement — that’s the caller’s job (the store type-checks the merged body through the same gate every other write goes through); a body that merges structurally but not by type is still a conflict.” A merge that is structurally clean but consumes a linear value twice is exactly the case §3’s gate already exists to catch — nothing about gate.rs’s design needs to change for cancho, because it was never “trust the merge,” it was “verify the candidate” from the start.

What does change is the rate, and that is worth measuring rather than assuming, the same way hash-stability.md measured the plateau rather than assuming one had happened. In a non-linear language, two edits to disjoint subtrees that each typecheck are very likely to also typecheck together — there is no shared resource for them to race over. In cancho, “disjoint subtrees, each typechecks alone” is a strictly weaker guarantee, because a linear value can be consumed once in each of two structurally-disjoint edits and the merge will look clean node-by-node right up until the gate runs. So: expect a body merge here to report Merged and then fail the gate more often than the same shape does in lex-lang, and expect the failure to land at the merge site as GateError::TypeError rather than at a friendlier, more specific “these two edits both move x” diagnostic — a real UX gap against lex-lang’s own experience, worth its own future slice (teaching body_merge’s recursive case to recognize a linear binding consumed on both sides before declaring the merge clean, rather than after), and explicitly not attempted in this document.

diff_to_ops’s OperationKind vocabulary is narrower for cancho than for lex-lang, in the same direction budget_cost already is: no generics-with-trait-bounds to diff (cancho has [T: val], not lex-lang’s trait system), no closures, no comptime macros to expand before diffing (README.md’s own non-goal: “no textual or proc macros — macros break stable content-addressing”, which if anything makes cancho’s diff simpler to get right than a macro-expanding language’s would be).


5. Where the code should live

Not a Cargo.toml dependency on lex-lang’s lex-vcs crate. The lex-os/cancho relationship already set this precedent and said why out loud: “It shares the idea and no code: lex-os takes its grant from lex-lang and checks .lex, and does not depend on this repository at all.” The same reasoning applies here in the other direction — a new cancho-vcs crate, built against this repository’s own types, sharing the scheme (canonical JSON, a content-addressed OpId, String-keyed SigId/StageId/EffectSet) with no shared code and no cross-repo Cargo.toml edge. Built, and one detail changed on contact: OpId is BLAKE3, not lex-vcs’s SHA-256 — docs/canonical-ast.md already chose BLAKE3 for cancho-id’s own hashes and said why, and nothing in that reasoning is specific to an AST node rather than an operation, so matching lex-vcs’s algorithm would have meant a second hash dependency for no reason but appearance. “Shares the idea and no code” turns out to mean sharing it down to which hash function, not just which crate.

The honest tradeoff, stated rather than assumed away: two independently maintained copies of operation.rs/attestation.rs/signing.rs/etc. can drift, and a bug fixed in one will not automatically reach the other. That is worth reconsidering — pulling the ~81% into a shared lex-vcs-core crate both repositories depend on — only if the two implementations turn out to need the same fix often enough that the duplication is expensive to carry, not on the strength of “it would be nice to share.” Nothing in this repository’s own history suggests that yet: lex-os and cancho have shared zero lines of code since that relationship was stated, and it has cost nothing measurable.


6. What edition-tagging buys, given what the plateau measurement found

hash-stability.md did not find one number, it found two that disagree: the encoder has moved 20 times with the golden fixtures observing none of it, while the language moved enough that 71% of this repository’s own historical .cho revisions no longer typecheck, almost half of that from one label rename (io → io_read/ io_write). An op DAG keyed on content hashes that predate a label rename does not become wrong when the rename lands — the hashes are still correct hashes of what they were computed from — but a consumer walking the DAG needs to know it is reading pre-rename history, or a query like “every function whose row includes io” silently misses everything written before the split.

editions.md already has the answer for the checker side of exactly this (edition markers hold a vocabulary fixed per program), and the op DAG should use the same marker rather than invent a second one: every Operation this design writes should carry the edition its EffectSet was computed under, from the day the DAG is born, not added defensively later once a second edition already exists. This is cheap now (one field, Option-skipped for the current edition the way intent_id already is, so it costs nothing until there is a second edition to distinguish) and expensive to retrofit (every historical OpId would need to be reinterpreted rather than just read).


7. The plateau question, answered

ROADMAP.md’s own row asks whether 27 commits (#93–#119) touching zero files that declare a builtin, an effect label, or a prelude type — while an entire second backend was designed and built behind that stillness — is enough of a plateau to stop waiting.

Yes, with one condition this document already built in: tag the edition from the start (§6), so the thing a plateau is actually insurance against — the vocabulary moving again later — costs a reinterpretation rather than a rewrite if it happens. The alternative to starting now is waiting for a second plateau to confirm the first one generalizes, which is not a stronger falsification than the one hash-stability.md already ran — it is the same measurement, taken twice, on the belief that 27 is not enough of a sample size on its own. That belief is not supportable from what actually moved in those 27 commits: an entire LLVM backend, Net’s outbound and inbound halves, and Ffi/extern fn’s foreign-return-width fix — the kind of large, invasive, cross-cutting change most likely to have needed a new effect label or a new builtin if the vocabulary were still unsettled, and none of it did. Waiting for a longer plateau to feel safer is exactly the “assumed, not measured” failure mode this repository’s own documents keep catching in each other.


8. What is next

Built (vcs sync, #403): the store rides inside git. cancho vcs sync [--dir <dir>] [--std] [--check] walks every .cho under a directory, recomputes every declaration’s identity with cancho-id (one file at a time, because a directory is not one program), and writes two things into .cancho/ as plain text: ids.txt, one sorted line per declaration — fn <module> <name> <sig> <body> — and the op records for what moved against the committed index, in the same loose-file OpLog shape vcs publish writes. Because the index is line-oriented and key-sorted, an ordinary git merge of two branches that touched different functions resolves as text, and a conflict lands on exactly the declarations that genuinely collided — no custom driver, no new store to visit. --check writes nothing and exits 1 on any semantic change: added, body-changed (ModifyBody, SigId untouched), signature-changed (callers by SigId affected), or removed (RemoveFunction) — the CI shape, so a PR that changes what a caller depends on is a red diff rather than a silent rewrite of the index. A file that does not check is reported as a warning and indexed for what it does declare — the reject fixtures under tests/ are sources the walk sees, by design, and a history that cannot compile is still a history with identities. The CI job semantic-index runs --check once .cancho/ids.txt is committed.

Built: the foundation (Operation/OperationKind/OpId/SigId/ StageId/EffectSet, canonical BLAKE3 identity, the edition tag from §6, checked against real cancho-id output); the apply→gate pipeline (gate.rs, checking §3’s claim directly against real code — a candidate program either typechecks or is refused with the same rule tag cancho check would report, no main-shape check, since an operation can be about a library declaration); a loose-file op log (op_log.rs, one JSON file per OpId, idempotent, refusing a record whose claimed identity disagrees with its own payload); and a hash-chained attestation log (attestation.rs) that records the gate’s own verdict, sealable with Ed25519 via ed25519-dalek — the same crate lex-os-audit uses, deliberately not std.ed25519 (docs/ed25519.md’s own module, built for a different consumer, §1 there). This is the meeting point §8’s previous revision named: this initiative and docs/sha512.md §5’s deferred Ed25519 slice, closed by the same crate on the Rust side and a purpose-built module on the .cho side, each serving the consumer that actually needs it rather than one serving both.

Found while scoping what came after this list, not by anything this section itself named: none of the built pieces above can be reached from a real .cho file yet. Every one of them is exercised only by this crate’s own tests, hand-constructing an Operation literal — there is no walk from real source to an Operation, no way to list what a store already contains without already knowing its OpIds, and no CLI command at all. vcs-publish.md scopes that gap as its own slice, ahead of everything below, since none of it has anything to operate on until a first publish exists.

What is left from §3’s “largely unmodified” list — whole-function merge (lex-vcs::merge), multi-file merge sessions, typed issues, predicate branches, the op log’s own history index — has no asker in this repository yet (AGENTS.md §7), and is not started. The linearity-aware body merge named in §4 (recognizing a linear binding consumed on both sides of a merge before declaring it clean, rather than after) is the one item on that list with a design already written down, should a consumer arrive before the others do.

cancho-vcs: the first publish, before transport

Status: design, not yet built. Written on contact with the actual code, which found the premise this document was going to start from — “the local store works, so the next slice is HTTP push/pull” — false. There is no head, no way to enumerate what is already logged, and no way to turn real .cho source into Operations at all: crates/cancho-vcs is a library with no caller anywhere in this repository outside its own tests. Transport has nothing to move until this slice exists. This document scopes that slice, and only that slice.

1. What this is answering

docs/ROADMAP.md’s corrected row (#132) says what §8 says is unbuilt: whole-function merge, merge sessions, typed issues, predicate branches, the op log’s own history index. Reading past that list rather than stopping at it finds something none of those five items name, because all five assume the thing this document is about already works.

crates/cancho-vcs has no CLI command anywhere in crates/cancho/src/main.rs — the file’s only top-level match arms are check, agent-guidelines, docsync, repo-stats, print, ids, authority, layout, build, run. Every one of the crate’s own capabilities — Operation::new, gate::check_candidate, OpLog::put/get, Chain::append — is exercised today only by crates/cancho-vcs/tests/, hand-constructing an OperationKind::AddFunction { sig_id, stage_id, effects, in_file } literal per test case. Nobody, human or agent, can run a .cho file through this crate from a terminal. That is a smaller gap than “no transport”; it is “no first user.”

2. What exists, read from the source rather than assumed

CapabilityExists?Where
Operation/OperationKind/OpId, content-addressedYesoperation.rs
A candidate program type-checks before its op is acceptedYesgate::check_candidate(files: &[(&str, &str)])
Durable storage, one JSON file per OpIdYesOpLog::open/put/get/contains
A hash-chained, Ed25519-sealable attestation logYesattestation::Chain<E>
Turning real source into an OperationNoNothing calls Operation::new outside a test. vcs.md §4 named this as real, ~1,500-line native work (compute_diff+diff_to_ops+body_merge, rewritten against cancho-ir rather than lex_ast) and it has not been started
A “what does the log currently contain” readNoOpLog has get(op_id) and contains(op_id) — both need the OpId already in hand. There is no directory listing, no iteration, no way to ask “what functions does this store know about” without already knowing every hash to ask for
A head, a branch, a manifest — any notion of “current state”NoGrepped vcs.md for head/branch: the one hit is lex-vcs’s own generic table, describing what it has, not what cancho-vcs does. Nothing in this repository has ever named this gap, because nothing has tried to publish a second revision yet
A CLI commandNoConfirmed above

The gate, op log and attestation log are real and correctly built for what they do. What they do is smaller than “a working store” — they are the accept and record halves of a pipeline with no propose half.

3. The smallest real first slice: publish with an empty log

vcs.md §4’s hard, deferred work — compute_diff/diff_to_ops, detecting that this declaration changed since that revision — is only needed to publish a second revision. A first publish, against an empty log, needs no diff at all: every declaration in the source is new, by definition, so every one of them is an AddFunction. This is exactly the asymmetry lex-lang’s own lex publish does not have (a Store always already has a previous head to diff against) and cancho-vcs gets for free from not having built branches yet.

Concretely, reusing what cancho ids already computes rather than adding a second hashing path:

#![allow(unused)]
fn main() {
let identities = cancho_id::identify(&ast); // already built, main.rs:1054
for func in &identities.functions {
    let op = Operation::new(
        OperationKind::AddFunction {
            sig_id: func.sig.clone(),        // already cancho-id's own hash
            stage_id: func.body.clone(),     // already cancho-id's own hash
            effects: effects_of(&decl),      // straight off the FnDecl's own row —
                                              // `authority.md`'s own source, not new
            in_file: module_ref_of(&decl),   // many-files.md's existing ModuleRef
        },
        edition,   // Ast::edition_of, same as #124
        [],        // no parents: nothing preceded this in an empty log
    );
    gate::check_candidate(&assembled_files)?;  // already built
    op_log.put(&OperationRecord::new(op))?;    // already built
}
}

No new hashing, no new gate logic, no new storage format. The only new code is the walk from Identities/Ast to OperationKind::AddFunction values and the wiring to call it — genuinely small, and it is the piece every other numbered gap in this document depends on existing first.

4. Why a head cannot be deferred even for slice 1

A first publish only needs to write. The moment anyone runs publish a second time — including by accident, including a CI job re-running the same command — something has to answer “is this AddFunction already logged,” or the store double-writes the identical, harmless-looking op forever, silently, because OpLog::put has no idempotence check of its own beyond OperationRecord’s own content hash (two writes of the same op collide safely; two writes of what is logically the same declaration, republished, do not — nothing today makes that comparison at all).

The minimal answer is not lex-vcs’s full branch-and-merge model — that is exactly the “largely unmodified, not started” list vcs.md §8 already tracks, and building it now would be building ahead of an asker the same way AGENTS.md §7 already argues against. The minimal answer is a manifest: SigId -> StageId for every declaration the last publish from this working copy logged, written once per publish, read at the start of the next one. It is not a head in lex-vcs’s sense (no branches, no merge, no advancing a shared pointer two agents could race on) — it is the one fact a single working copy’s own next publish needs to not repeat itself, and it is the seam a real head would attach to later without this slice’s own format needing to change: a manifest with a documented, migratable shape at the same discipline editions.md already applies to Operation itself.

5. Deliberately not in this slice

  • Incremental diffing — detecting that an existing declaration’s body changed (ModifyBody) or that one was removed (RemoveFunction) needs comparing two revisions structurally, which is vcs.md §4’s own ~600-line compute_diff+diff_to_ops rewrite against cancho-ir’s Expr/FnDecl (not lex_ast::CExpr). Real work, correctly deferred: it has no asker until slice 1 makes a second publish possible at all, and testing it needs two real revisions of a real program to diff, which do not exist as fixtures yet either.
  • HTTP transport (push/pull) — nothing to move until slice 1 writes a local op log worth moving. When it is built, the shape to mirror is lex-hub’s own — a thin gateway, JWT or none depending on whether a second machine or a second person is the actual asker — but that is a question this document declines to answer ahead of slice 1 existing, the same restraint vcs.md §5 already applied to a shared lex-vcs-core crate: worth deciding when the duplication (or, here, the lack of a second party) actually costs something, not on the strength of “it would be nice to have.”
  • Whole-function merge, merge sessions, typed issues, predicate branches, the op log’s own history index — vcs.md §8’s own list, unchanged by this document, and further downstream than any of the above.

6. What “built” would look like

  • cancho vcs publish [--store <dir>] [--std] <inputs...> — new top-level match arm in crates/cancho/src/main.rs, next to build/run/ids; parses and lowers exactly as ids/check already do, then runs §3’s walk. Refuses (not panics) on: a gate rejection (reports the rejected declaration’s rule tag, not a bare 422-shaped opaque failure); a --store directory that exists but is not a cancho-vcs store.
  • cancho vcs log [--store <dir>] — reads the manifest and lists what is known, by name and SigId, the read-side counterpart with no analogue in the crate today (§2’s second row).
  • A fixture pair: a real multi-function .cho file published against an empty store, checked against the store’s own on-disk OperationRecord files afterward — the same “checked against real cancho ids output, not invented strings” discipline #124 already set for this crate’s golden tests.

7. Sequencing

Slice 1 (this document) → incremental diffing, once slice 1 has a second real revision to test it against → transport, once there is a second machine or person actually asking to sync with one. Each gated on the next one existing to ask for it, the same order vcs.md §8 already committed to and this document is not changing, only extending one slice earlier than “gate, op log, attestation” turned out to reach.

A package system for cancho

Status: design, not yet built — except §4.2’s own recheck step, which is: cancho vcs resolve <store-dir> re-parses and re-typechecks the source behind every pin in a real second .cancho-vcs store under today’s compiler, and refuses if a pin no longer matches its own source, its source no longer type-checks, or a source blob is missing. This is §6’s own proposed smallest slice, done, and it needed a real prerequisite this document had not named: neither OpLog nor Manifest store a declaration’s actual source, only its hash — Blobs (crates/cancho-vcs/src/blobs.rs), a small content-addressed store for exactly that, is the piece that was missing. §4.5’s naming half is built too: cancho vcs lock --store <dep-store> -o <file> <name>... looks a name up in a dependency’s manifest and pins it by hash into a lock file, refusing if the name is unpublished or ambiguous; vcs resolve --lock <file> <store-dir> scopes resolution to just those pins instead of everything the store has ever published. The lock-driven, recursive, multi-package resolution §4.2’s first paragraph describes is built too, as of §4.6: a store may carry its own requires/*.json, written by vcs publish --requires <lock>:<dep-store>, and vcs resolve/vcs fetch walk it however deep, detecting a cycle and catching a diamond conflict rather than guessing. §4.7’s first fetch step is built too: cancho vcs fetch --lock <file> --store <dep-store> -o <dir> re-verifies a lock the same way vcs resolve --lock does, then writes each distinct verified source to -o as <source_hash>.cho. It needed no compiler change at all — modules.md §4.2 already means a program is the set of files named on the command line and import already resolves a name against whichever of them declares it, so a fetched file is import-able the moment it exists on disk; see §6. modules.md §7 named “a package and version story” as open and explicitly out of scope for naming (“distribution, not naming”); standard-library.md §2.1 named it “the decision to revisit first when a package story exists.” Both waited on two things that did not exist yet and now do: cancho-vcs (vcs-publish.md, #123–#134 — a real content-addressed store with a gate) and editions (editions.md, #85–#92 — a way for a file to stay compatible with an older compiler without a version number). This document is otherwise still the first slice: not a manifest format, a full CLI, or code for the rest of it — what the pieces already built here mean for the two or three decisions a package system cannot avoid, and which of them this project has effectively already made.

A first real package now exists: net.sockets (packages/net-sockets/sockets.cho, #141). Not a fixture built to exercise the pipeline — examples/serve/serve.cho and examples/results_stub/results_stub.cho had independently declared the same eight extern fns against libc and the same two byte-writing helpers since #129, an organic duplication this project’s own rule (standard-library.md: “a feature earns its way in when a program asks”) already justified extracting. Publishing it found a real gap vcs publish had never hit: every store published here before now held only ordinary functions with bodies, and cmd_publish looked up a lowered IR function to read its effects, which an extern fn declaration — a signature and nothing else, per cancho-id’s own identify() — does not have. Fixed by reading effects from Program::externs instead when no lowered function matches, the row the declaration itself carries, not one a lowering pass computed. Both consumer programs now lock net.sockets and import it rather than duplicating it, verified end to end: built together, run, and hit with a real HTTP request on both backends, the same as before the extraction.

A second real package, net.connect (packages/net-connect/ connect.cho), and the first program with two real dependencies at once. Six more examples turned out to duplicate net.sockets’ declarations independently – examples/fetch/, examples/report/, examples/collect/, examples/vsock/, examples/agent_guest/, examples/agent_supervisor/ – and they split cleanly on net.md §1’s own line, “the two directions do not meet”: the inbound ones (collect/agent_supervisor, joining serve/results_stub) want net.sockets alone, the outbound ones want net.sockets plus connect, which is now its own package rather than joining net.sockets, for the same reason. examples/fetch/fetch.cho migrated onto both, proving what §6 flagged as unbuilt is not the same question as “does more than one dependency work at all”: it already does, with no new tooling – two independent vcs lock/vcs fetch pairs, composed at the same build command line, each re-verified separately. What §6 still means by “not built” is narrower: a program that depends on a package which itself has dependencies (a true closure), and conflict detection when two dependencies disagree about a third. Composing N direct, independent dependencies was never the open question; it just had not been tried against a real program before now.

Found and fixed a real, separate compiler gap, not a package-system one: cancho authority’s foreign_symbols field was not reachability-pruned the way effects and Program::funcs are – it listed every extern fn declared in the compiled unit, called or not, so importing a package that declares more than a program calls made the report over-name what the program reaches. Fixed in cancho-ir (fold::collect_extern_refs/reachable_externs), read-only over Program::externs so Callee::Extern’s index and codegen are untouched. docs/authority.md §3 and docs/under-a-grant.md §6 have the detail.

The other five of those six duplicating examples migrated too. examples/report/, examples/vsock/, and examples/agent_guest/ now import net.sockets/net.connect, the same shape as fetch.cho; examples/collect/ and examples/agent_supervisor/ now import net.sockets alone, the same shape as serve.cho/results_stub.cho. Nothing in this section’s own “not built yet” gap moved: this was nine already-real files converging on the two already-real packages, not a new question about composition.

A third package, agent.wire (packages/agent-wire/wire.cho), with no extern fn in it. examples/vsock/vsock.cho and examples/agent_guest/agent_guest.cho duplicated the same five pure functions – an AgentViewMsg decoder, byte-for-byte, the same “two real askers” bar net.sockets cleared first. Confirms a package is just a cancho-vcs store, indifferent to whether what it publishes declares against libc or is ordinary Lex with a body. §6 has the detail, including the one real publish-time constraint it found (--std was not available to vcs publish – corrected: §4.8 built it) and why the JSON escaper on the encoding side of the same wire protocol stays unextracted.

Closure resolution, built (§4.6): a package can depend on a package. Found from a real fourth duplication — examples/collect/ collect.cho and examples/agent_supervisor/agent_supervisor.cho duplicate content_length_of/read_request byte-for-byte, but the package that would hold them needs net.sockets itself, and vcs publish refused that outright until now. vcs publish --requires <lock>:<dep-store> (repeatable) writes a store-carried requires/*.json; vcs resolve/vcs fetch walk it recursively, refusing a cycle or a diamond conflict (two paths pinning the same store’s same name to two different heads) rather than guessing. Found one real thing wrong with this section’s own first draft: cancho-id::identify() is not resolution-independent the way it claimed — a call’s contribution to its own caller’s body hash is the resolved callee’s signature when the callee resolves, so computing identity without the dependency present recorded the wrong hash, refusing on the very first vcs resolve with a body that had not moved at all. §4.6 has the correction and the fix. Proven against synthetic packages built for exactly that, not yet against http.request itself, which is the next slice.

packages/http-request/ built too, the real case §4.6 was found from. content_length_of/read_request move into request.cho, published with --requires <net.lock>:packages/net-sockets/ .cancho-vcs; collect.cho and agent_supervisor.cho both import http.request instead, and drop their separate net.sockets lock entirely — one fetch of http.request transitively materializes the whole net-sockets.cho file too, since a store is always exactly one file. Found a second real thing wrong with this section’s own first draft: Requirement.store, read back as a bare path, was silently interpreted against whoever runs vcs resolve/vcs fetch next’s own working directory rather than the one it was recorded relative to — invisible against the synthetic tests above (their stores are always absolute paths under one scratch directory), but real against http.request+net.sockets, fetched from a working directory other than the repo root. Fixed by recording it relative to the depending store’s own directory instead, and joining rather than reading it bare on the way back. §4.6 has the correction and the fix.

net.connect extended, and a fifth package, http.response. Hashing every function body across examples/, packages/, std/ and benches/ and grouping the ones that collide – rather than rereading files by eye – found a fifth duplicated cluster: fetch.cho, report.cho and agent_guest.cho each declare the same six functions. octets_of/port_of/address/connect_to are generic to any outbound program and joined net.connect itself (growing an already-published store is the same act as its first publish, just a second vcs publish against the same --store); send_all/ status_of are HTTP-specific and became http.response, the client-side mirror of http.request. Both now import net.sockets too – its third and fourth real consumer, closure resolution doing the same work again with no changes needed. Found one thing the design had not foreseen: two sibling packages transitively requiring the same dependency means fetching each into its own output directory – every earlier multi-package example’s own shape – now fetches that shared dependency twice, under two different paths, which build/check correctly refuse as a duplicate declaration. Not a resolver gap (vcs resolve on either store alone is silent), only in how a consumer composes two fetches: one shared output directory is enough, since vcs fetch writes each file as <source_hash>.cho and a second fetch of the same content overwrites the same path rather than writing a new one.

1. What asked for it

The direct trigger is README.md’s own “not a usable language yet” paragraph. docs/foreign-linking.md closed one of its three reasons; this document is the design half of the second (the effect-vocabulary instability hash-stability.md measures is the third, and it is not a feature to build — see §5). “No package system” has sat in that sentence since the README was rewritten with nothing behind it beyond the two pointers above.

2. What this should not reinvent

Everything here already has a real answer sitting in this ecosystem, built for a reason that had nothing to do with packages. Reading the actual code rather than assuming a greenfield design is the point of this section.

PieceWhereWhat it already gives a package system
Content-addressed declarationscancho-id::identify(), SigId/BodyIdThe identity a dependency pin should be made of — already exists, per declaration, today
“A call encodes the callee’s hash, not its spelling”modules.md §2The single fact this whole design rests on (§4.5)
A content-addressed store with a gatecrates/cancho-vcs — Operation/OpId/StageId/OpLog, gate::check_candidate“A package is a repository of hashed declarations that refuses what does not type-check” is already built, for one repository
A publish/log CLIcancho vcs publish/vcs log (vcs-publish.md)The shape a cancho pkg publish would take, reusing the same crate
Per-file, additive compatibilityeditions.mdThe answer to “does an old dependency still compile,” already solved without a version number
A fail-closed authority reportcancho authority --output json (authority.md, reach.md §5)What a dependency’s public surface is allowed to be checked against, already machine-readable
Deriving/diffing the least authority a program needslex-os-authority (lex-os repo)The mechanical “did this upgrade widen what I’m exposed to” answer — built for one program’s own revisions, the same shape a dependency bump needs
A signed capability contract, consumer’s grant as the ceilinglex-os-capsule (lex-os repo)“Refuse, don’t downgrade”: installing something never silently grants more than was already allowed
A real, working package manager for the sibling languagelex-lang’s lex pkg — crates/lex-store (Store, content-addressed “stages” under <root>/stages/<SigId>/), crates/lex-store/src/deps.rs (lock-driven recursive resolution), crates/lex-syntax/src/registry.rs (a hosted index over stores)Proof this design is not speculative — lex pkg’s Store is the same idea cancho-vcs already ported once (vcs.md §7, “shares the idea and no code”)
Machine-readable refusalsagent-errors.md — rule tags, check --output jsonThe vocabulary a resolution failure should speak, not a bespoke error format

The load-bearing row is the fourth: lex-lang already has a real, working, hosted package manager, and its core data structure is a content-addressed store keyed by declaration hash, resolved by pinning a (store, head) — which is not a coincidence, it is cancho-vcs’s own architecture, described from the other side. cancho-vcs was built by porting lex-vcs’s idea with no shared code (vcs.md §7); this document proposes doing the same thing one layer up — porting lex pkg‘s idea, not its code — because the two languages’ packages should relate exactly the way their VCS crates already do.

3. The philosophy this has to fit

Four of this project’s standing commitments bear directly on a package system, and each one rules something out before any format gets designed:

  • No implicit anything. CLAUDE.md/AGENTS.md: no implicit conversions, no ambient authority. A package resolver that runs arbitrary code to fetch, build, or “prepare” a dependency — npm’s postinstall, Cargo’s build.rs — is exactly the ambient authority this project refuses everywhere else. §4.3 makes this a stated design commitment, not an oversight to fix later.
  • No macros, because they break stable identity. The same reason applies one level up: a dependency has to be data (source files and hashes), never a program that runs during resolution and could produce different bytes on two machines.
  • Refuse, don’t downgrade. lex-os’s repo-wide rule, and lex-os-capsule’s own phrasing of it for distribution: “the consumer’s grant — not the publisher’s declaration — is the ceiling.” A cancho package has no runtime grant to check against (cancho itself has no sandbox — that is lex-os’s job), so the translation is a build-time one: what a dependency’s authority report already says is allowed to be shown and re-checked, never silently exceeded by an upgrade. §4.4.
  • Design before code, and the two-asker bar. CONTRIBUTING.md rules 1 and 3. This document is step one. Nothing here is scoped as a slice to build yet — §6 says what the first askable slice probably is.

4. The shape

4.1 A package is a cancho-vcs store at a pinned head

No new identity system. crates/cancho-vcs’s Operation/OpId/ SigId/StageId/OpLog are already, word for word, what lex-store’s own module doc calls itself: “a content-addressed code repository.” A package is nothing more than an OpLog someone else published, and a version string, if one exists at all, is a human label pointing at one state of it — a tag over hashes, the way a git tag names a commit without being the commit’s identity.

4.2 Resolution is lock-driven, recursive, and never trusted without a recheck

Borrow lex-store/src/deps.rs’s actual algorithm, not just its idea: each dependency’s own dependencies resolve against that dependency’s committed lock at its own pinned head, never the root’s — “a package is checked against exactly the pins it was published with.” A (store, head) visited set turns a cycle into a diagnostic instead of unbounded recursion. The same package pinned to two different heads anywhere in the closure is a conflict, refused with a rule tag, never silently resolved by picking one.

Where this has to diverge from lex pkg, on purpose: cancho-vcs’s own gate (gate::check_candidate) re-parses and re-typechecks a candidate before accepting it into a log at all (vcs-publish.md §3) — this project already refuses to trust a hash’s claimed shape without recomputing it once. A resolver should hold a dependency to the same standard the store holds a publish to: fetch the source behind a pin, not only its hash, and run it through the consumer’s own cancho check before it is usable. A lock file’s SigId is not proof the code still type-checks under today’s compiler — hash-stability.md already measured that 71% of this repository’s own history stops type-checking under today’s build. A dependency pinned a year ago is exactly this repository a year ago, and a lock file does not get to assert on its behalf that it still compiles.

4.3 No code runs to resolve a dependency

Stated as a design commitment, not left implicit: fetching, unpacking, and locking a dependency is pure data movement — read files, hash them, compare to a pin — and never executes anything the dependency contains. This project’s own “no textual or proc macros” rule already rules out the mechanism (a build script) that gives npm/Cargo/pip their worst agent-safety failure mode; this section says the resolver itself inherits the same rule rather than merely benefiting from it by accident. An agent wiring up a dependency graph unattended should never need to sandbox cancho pkg the way it has to sandbox npm install.

4.4 What a pin buys an agent, mechanically, before it is trusted

Every package publishes its own cancho authority --output json report for its public (pub fn) surface, computed by the publisher’s compiler — but per §3’s “refuse, don’t downgrade,” this is transparency, not trust. lex-os-capsule already built the rule this needs, for a different kind of artifact: “the consumer’s grant — not the publisher’s declaration — is the ceiling.” For cancho, with no runtime grant to check against, the ceiling is the consumer’s own rebuild:

  • Adding a dependency for the first time computes the union of what the whole closure’s public surface performs (cancho authority, already fails closed: "bounded": false for anything reaching foreign code) and shows it before a lock entry is written — an agent decides once, against real data, not a publisher’s promise.
  • Bumping a pin diffs the old and new authority reports — the exact shape lex-os-authority already computes for one program’s own revisions (widening / narrowing / unchanged) — and a widening refuses the bump until re-approved the same way the first install was; narrowing or unchanged applies without asking again. This is a mechanical, non-promise-based answer to “is this upgrade safe,” and it needs no new machinery: lex-os-authority’s diff already exists, one repository over, built for exactly this shape of question.

4.5 Naming: a label is chosen once, resolved by hash forever after

modules.md §2’s own fact — “a call already encodes the callee’s hash rather than its spelling” — extends past one program into a dependency graph for free. Once import lex-nt; is written and its lock entry pinned to a StageId, every later build resolves that name through the lock, never by asking anything “what is lex-nt today.” A name is chosen exactly once, by whoever ran the add command; nothing downstream can silently substitute a different package under the same name later — the mutable name-resolves-to-latest model that is dependency confusion and typosquatting’s entire attack surface in a registry that works that way.

Built as cancho vcs lock, and one prediction corrected on contact. This paragraph originally said re-locking an existing name to a different hash should be a refusal; building it found that too strict. The attack this section is about is automatic substitution — a build silently re-resolving a name against a live registry, with nothing in the loop to notice. Running vcs lock a second time, by hand or by an agent that decided to, is not that: it is the same deliberate act as cargo update or git tag -f, and there is nothing to protect a consumer from in their own explicit choice to re-pin. So a second vcs lock <name> overwrites the lock entry, and what actually implements this section’s guarantee is narrower and correct as stated: nothing other than that explicit command ever changes what a name resolves to, and vcs resolve --lock only ever reads the lock, never re-derives a name from anything live.

4.6 Closure resolution: a package that depends on a package

The real motivating case, found rather than invented: examples/collect/ collect.cho and examples/agent_supervisor/agent_supervisor.cho — both already import net.sockets — duplicate a second pair of functions byte-for-byte, content_length_of and read_request (63 lines together), an HTTP request reader built on top of sockets.read. That is the same “two real askers” bar every package here has cleared, but extracting it hits a wall none of the first three packages did: the new package’s own source would need import net.sockets too, and confirmed directly (vcs publish on a file importing net.sockets today) —

error: no module `net.sockets` in this program; a module exists where a
file declares it

— because vcs publish only ever sees the one file it is publishing (§4.1), and vcs resolve --lock/vcs fetch --lock’s own re-check (verify_selected in crates/cancho/src/vcs_cli.rs) type-checks each pinned source blob alone, the same restriction. Both are real, not incidental: a package with no dependency of its own (all three built so far) never needed either to change.

One prediction this section made turned out wrong, and building the fix found it rather than assuming it. The first draft here claimed cancho-id::identify() is purely structural — a hash of a function’s own written signature and body, never of what an imported callee’s declaration actually says — and that only the soundness gate below needed a dependency’s source in scope. Wiring vcs publish --requires against a real dependency and then re-verifying it with vcs resolve found that false on the very first try: identity computed with the dependency absent and identity computed with it present disagreed, vcs resolve refusing a body that had, in truth, not moved at all. cancho-id::qualified_name (crates/cancho-id/src/lib.rs) is why — a call’s contribution to its caller’s body hash is the resolved callee’s own signature hash (tag::FREE) when the callee resolves, and only the bare written name (tag::NONE) when it does not; a callee’s signature changing is meant to count as a body change for its caller, which is sound, but it means identity is not resolution-independent after all. Fixed by computing identify() from the same merged, dependency-resolved Ast the soundness gate below builds, keeping only the names a first pass over the primary file alone declared (reliable regardless of resolution, since it is asking “does this function exist,” never “what does its call hash to”) to filter a dependency’s own declarations back out before publishing.

What does not need to change, once identity itself is computed correctly: resolving import net.sockets is cancho-ir’s job (defs.rs’s module-scope pass, run during lower_all), not a second identity pass — the “no module” refusal above comes from there, and the soundness gate — confirming a pinned declaration still type-checks under today’s compiler, the actual point of re-verifying rather than trusting a hash (hash-stability.md’s whole finding) — is a lower_all call already made from an Ast built by merging named texts (parse_program’s own loop, crates/cancho/src/main.rs); nothing about multi-file lowering itself is new, net.connect+net.sockets compose that way at build time already. What is new is doing it from already-verified text in memory rather than files a caller named on a command line — vcs publish/verify_selected hold dependency source as trusted strings, not paths, so the shared helper this needs (parse_texts, a text-only version of parse_program’s inner loop) has to accept (name, text) pairs directly.

A second prediction this section made turned out wrong too, and again building the real package (packages/http-request/, not the synthetic stores the mechanism was first proven against) found it, not a review of the design. The first draft’s Requirement.store bullet, below, said a repo-relative path recorded at publish time “stays valid for every consumer… the same way every existing example’s own command already assumes running from the repo root” — true of every hand-typed cargo run command in this document, but not of cargo test’s own integration binaries, which run with their crate directory as their working directory, not the repo root. Reading req.store as a bare path and letting the OS interpret it against whoever calls resolve/fetch next’s own working directory means the exact same closure walk succeeds from one working directory and fails from another — reproduced directly: vcs fetch --store packages/http-request/.cancho-vcs ... from the repo root fetches net.sockets transitively without complaint; the identical command, with both --lock/--store made absolute first, run from crates/cancho/ instead, refuses with put/read ... is no longer published at packages/net-sockets/.cancho-vcs — the store’s own --store argument was absolute and correct, but the string inside requires/0.json was still read bare and resolved against the wrong base. Fixed the same way import itself is never working-directory- sensitive: Requirement.store is now recorded relative to the depending store’s own directory, computed lexically at publish time (relative_from, comparing path components, never touching the filesystem, since the store being published to may not exist yet), and resolved by joining it against that store’s own already-resolved path (store.join(&req.store)) rather than reading it as a stray path of its own — so the walk composes correctly however deep, and however its own top-level --store argument was spelled, with no dependence on any process’s working directory at all.

The shape, concretely:

  • A store may carry requires/*.json alongside manifest.json/ ops//sources/ — each file a Requirement { store: String, lock: Lock }: store is a relative path from this store’s own directory to the dependency’s, computed once at publish time and joined against the depending store’s own path whenever it is read back — never interpreted against a process’s own working directory, the correction above. lock is an ordinary Lock (§4.5’s format, unchanged) — the package’s own pin into its dependency, chosen once at publish time exactly the way a program’s own net.lock is chosen today. One file per distinct dependency store, the same “one lock per store” shape a program with two direct dependencies already uses (examples/fetch/net.lock + connect.lock).
  • vcs publish grows --requires <lock-file>:<dep-store> (repeatable). For each one: resolve and verify it exactly the way vcs resolve --lock already does (reusing select_locked/ verify_selected unchanged) to get real, trusted dependency source; feed that alongside the primary file into the new soundness gate; on success, publish exactly as today (identify() on the primary file alone, unaffected by any of this) and write requires/ from the --requires pairs given. Requirements are whole-store, last-publish- wins metadata, not diffed or versioned per declaration — consistent with every package here so far having exactly one publish, ever (vcs-publish.md §5: incremental publish is not built yet either).
  • vcs resolve/vcs fetch walk requires/ recursively before verifying. A new resolve_requirements gathers every transitively required source, bottoming out at a leaf store (no requires/ at all, today’s exact behaviour, unchanged) and threading two things through the whole walk rather than resetting per level, because either violation can appear between siblings as easily as between a level and its own ancestor:
    • a cycle — the same store’s canonicalized path already on the current path — refused, naming the chain;
    • a diamond conflict — the same (store, name) pair pinned to two different sig_ids by two different paths through the graph — refused, naming both pins. Two paths agreeing on the same head is not a conflict and is not refused; only disagreement is. verify_selected gains an extra_context: &BTreeMap<source_hash, text> parameter — the closure’s gathered text, merged into every group’s lower_all call so an import in the group’s own blob resolves — everything else about it, including the per-entry SigId/StageId check, is unchanged. vcs fetch writes the whole closure’s text to -o, not just the directly-locked store’s own, so one fetch of http.request hands back net.sockets’ source too — a consumer of a package that has a dependency never has to know that, let alone fetch it themselves.

What this still does not solve, on purpose: conflict resolution beyond refusal. Two disagreeing pins are refused, never reconciled (no “pick the newer one,” no semver range) — the same reason §5 defers semantic versioning: a hash is the only thing actually depended on, and there is no rule here for choosing between two different hashes a human did not choose between explicitly.

4.7 Distribution: no hub required to start

lex pkg’s hub (crates/lex-syntax/src/registry.rs, hosted at vcs.lexlang.org) is real, useful infrastructure, but nothing here needs it first. A DepLocator-shaped abstraction — lex-store/src/ deps.rs’s own phrase, “what a pin points at is context-specific… [is] abstracted” — can resolve a dependency from a local path or a plain git clone of someone else’s .cancho-vcs store before any hosted service exists, the same way this repository’s own --std shipped by embedding the library’s source rather than waiting on a package host at all (standard-library.md §2). A hub, when one is worth building, is only a name-to-pin index and an archive cache in front of the same content-addressed stores — never a second source of truth, and never a place authority is decided (that stays local, per §4.4, on every machine that resolves, agent or human).

The first fetch step is built as cancho vcs fetch. It re-verifies a lock exactly the way vcs resolve --lock does, then materializes each distinct verified source as <out-dir>/<source_hash>.cho — nothing is written unless every pin verifies. This is the DepLocator-shaped “local path” case above, made concrete: a plain directory of files is already a valid resolution target, because §6 found that import needs no new mechanism to consume one.

4.8 A package that imports std

Built. Until this, vcs publish parsed its input alone, so a package could not import std.math: the import resolved against nothing and the publish was refused. Every package so far worked around it by re-declaring the few functions it needed (net.sockets’s put/put_nat), which is the duplication std exists to end.

  • vcs publish --std merges the bundled library (the same bytes build --std uses) into the publish’s own parse, lowering, identity computation and soundness gate. It is a flag, not automatic: a publish that imports std without it, directly or through a --requires package, is refused and the refusal names the flag. --std is the same consent build asks for, and a package quietly widening its own dependencies would be one a consumer cannot read off its command line.
  • Attribution is by (module, name). FunctionId gained a dotted module (not part of either hash – a hash never mentions a module). With std merged in, a package’s abs and std.math’s abs are two functions; attributing by name alone published the library’s under the package’s store (measured by mutation: the own-functions test fails with the name-only rule). Effects are looked up the same way.
  • resolve, lock and fetch need nothing from the user. verify_selected brings the library in whenever the blob it is checking, or anything in its requires/ closure, imports std. That is read off the parsed imports, never off ManifestEntry.uses_std: the flag is informational (it marks vcs log rows and is #[serde(default)], so older manifests still load), and a test forges it to false and shows resolve still verifies against the library.
  • fetch does not write std into the output directory. The library belongs to the compiler, not to the package; fetch prints a note that the consumer must build with --std, and a consumer built without it is refused rather than compiled against a library that is not there.

The first real consumer is http.server (packages/http-server/, http-server.md): 730 lines that import std.http, std.json, std.buffer and std.conns, published with --std, locked and fetched by examples/api like any other package.

No pin on std itself. The store records no hash of the library it was published against. A package function’s body hash includes the signature hash of each callee it resolved (identify), so a std function whose signature changed moves the dependent’s body hash and resolve reports “body moved” – loudly, as it does for any drift. A std function whose body changed but whose signature did not leaves the package’s identity alone, which is the right answer: the consumer gets the library its own compiler carries. That follows from how identify treats callees; it is not separately tested here, because std cannot be varied under a test. If the library ever needs to be pinned (reproducibility across compiler versions), the place is a std identity in the store’s requires/ metadata, not a manifest flag.

5. What this does not solve

  • Editions across a dependency boundary. editions.md solved “a file predates a feature” for one program’s own files. Whether a dependency pinned at an older edition composes with a consumer on a newer one — does the newer compiler simply read the pinned file at its own declared edition, the way it already does within one program — needs its own reading of editions.md §7’s open question, not assumed here.
  • Semantic versioning, or a human-readable version string at all. Deliberately deferred: §4.5 makes a hash the only thing actually depended on, and a "1.2.3" string, if it exists, is a tag a publisher attaches for a human reader, never consulted by the resolver itself.
  • A hosted hub, a search index, anything like lex pkg search. lex-cli/src/pkg_search.rs is real prior art for when this is worth building; §4.7 argues it is not the first thing needed.
  • Signing, or trust-of-signer. lex-os-capsule’s own stated gap — “verifies a signature is valid for a given key, not that the key is trusted” — is inherited here unresolved, not solved by anything above.
  • The effect-vocabulary plateau itself. A package system makes the 71% figure hash-stability.md measures visible and mechanically checked (§4.2, §4.4) rather than silently assumed away. It does not make the vocabulary stop moving, and should not try to — that is a question about the language, not about distributing it.

6. What would make this real

Done: cancho vcs resolve — the minimum resolver in §4.1–§4.2 against a real, genuinely separate .cancho-vcs store, exactly as proposed here. The answer to whether the recheck step is as cheap in practice as this document assumed: yes, for a store this size — a re-parse and a re-typecheck per distinct source file, deduplicated across the declarations that share one, is the same cost cancho check already pays for an ordinary program. What it found that this document had not named: source itself had nowhere to live (Blobs, now built).

Done too: cancho vcs lock / vcs resolve --lock — §4.5’s naming half now has a lock file format (crates/cancho-vcs/src/lock.rs, name-keyed rather than Manifest’s SigId-keyed, because a consumer looks a dependency up by the name it wrote down, not by a hash it does not have yet) and a command that writes one, plus resolve’s own --lock flag to check only what was pinned rather than a whole store. One prediction this section made turned out wrong and is corrected at §4.5 itself: re-locking a name is an overwrite, not a refusal, because re-running the command by hand is a deliberate choice, not the automatic substitution the guarantee is actually about.

Done too: cancho vcs fetch — a lock’s pins, re-verified and materialized on disk as <source_hash>.cho files, one per distinct source. This was scoped, in the previous revision of this section, as needing import to learn to read a lock at compile time. Building it found that premise wrong: modules.md §4.2 already means a program is the set of files named on the command line and import already resolves a name against whichever of them declares it — there is no search path, no manifest lookup, nothing in the compiler that only knows about locally-written files. A fetched file is exactly such a file the moment it lands on disk. So the real next slice needed zero compiler changes; it needed only a place to fetch to, which is what vcs fetch is. This is confirmed end to end by a test that publishes a dependency, locks it, fetches it, and then builds and runs a second, separate source file that imports it — the fetched file, unmodified by anything package-specific in the compiler.

Done too, and the first time any of this ran on a real program rather than a fixture: net.sockets (packages/net-sockets/sockets.cho) — examples/serve/serve.cho’s and examples/results_stub/results_stub.cho’s own duplicated extern fns and byte helpers, published once, locked by each consumer, and imported rather than copy-pasted. This is what found vcs publish’s only real gap so far: it had never been asked to publish a foreign declaration, which has a signature and no lowered body, and the publish path read effects off a lowered body unconditionally. Fixed by reading Program::externs when no lowered function matches (§4’s own status header has the detail). Two real, already-tested example programs still build, still run, and still answer identically on both backends after the extraction — the strongest evidence yet that a fetched package composes with the rest of the toolchain exactly the way an ordinary file does, because it is one.

Done too: net.connect (packages/net-connect/connect.cho) and a real program with two dependencies at once. examples/fetch/fetch.cho locks and fetches net.sockets and net.connect independently — two vcs lock/vcs fetch pairs, composed at the same build command line — and builds, runs, and answers a real request exactly as before either package existed. This answers a question this section’s previous revision had conflated with the one below: composing N direct, independent dependencies needed no new tooling at all, only a second real package to try it against. (It also found a real compiler gap, unrelated to packages: authority.md §3.)

Done too: every other duplicated consumer moved onto both packages. examples/report/, examples/vsock/, and examples/agent_guest/ – named above as candidates for the net.connect move and left there – now import net.sockets and net.connect the same way fetch.cho does; examples/collect/ and examples/agent_supervisor/ now import net.sockets alone, the same way serve.cho/results_stub.cho do. Every one of the nine files that used to declare its own copy of these extern fns now imports the package instead, verified the same way each earlier one was: locked, fetched fresh, and built. docs/net.md §5 has the recount.

Done too: agent.wire (packages/agent-wire/wire.cho), the third real package and the first with no extern fn in it at all. examples/vsock/vsock.cho and examples/agent_guest/agent_guest.cho duplicated the same five pure functions – find_after/end_of_quoted/ goal_start_of/goal_end_of/step_of, a decoder for one field of a real lex-os-proto AgentViewMsg line – byte-for-byte, exactly the “two real askers” bar net.sockets itself cleared first. Everything about publishing and fetching a package works the same whether its declarations are extern fn against libc or ordinary fn with a body: vcs publish type-checks and hashes either kind identically, and the one real constraint this slice found – vcs publish never makes --std available (cmd_publish parsed with with_std: false unconditionally, unlike build/check; no longer true – §4.8) – was already true for net.sockets’s own put/put_nat, which is why neither package imports anything from std. The escaper on the other side of this same wire protocol (append_json_escaped in vsock.cho, put_escaped in agent_supervisor.cho) stays unextracted: same escaping rule, but two different signatures (one writes into a std.buffer.Buffer, the other into a fixed slice with a cursor, because agent_supervisor.cho has no Heap to spend), so bundling them would be guessing at a shape neither file asked for rather than naming one that is already duplicated.

Done: closure resolution (§4.6), against a real motivating case rather than the hypothetical one this section used to point at. examples/collect/collect.cho and examples/agent_supervisor/ agent_supervisor.cho duplicate a fourth byte-for-byte pair, content_length_of/read_request, and extracting it needs a package that itself needs net.sockets — a true closure of stores, not just several direct ones, the gap this section used to say was not built. vcs publish grows --requires <lock-file>:<dep-store> (repeatable), writing a store-carried requires/*.json; vcs resolve/vcs fetch walk it recursively, refusing a cycle (a store’s own canonicalized path already on the current walk) or a diamond conflict (the same (store, name) pinned to two different heads by two different paths) rather than guessing. Building it found identify() is not resolution-independent, contrary to this section’s own first draft — §4.6 has the correction and the fix. packages/http-request/ itself — the package this gap was found from — is the next slice, not this one: this PR proves the mechanism against synthetic packages built for exactly that, before trusting it with a real one.

Done too: packages/http-request/ (request.cho), the real motivating case §4.6 was found from, and the first package that itself depends on another. content_length_of/read_request move out of collect.cho and agent_supervisor.cho into one file, published with --requires <net.lock>:packages/net-sockets/.cancho-vcs; both examples now import http.request instead of duplicating the pair, calling request.content_length_of/request.read_request. A store is always exactly one file (vcs publish refuses more than one input), so fetching any of http.request’s own declarations transitively materializes the whole net-sockets.cho file too — both examples drop their own separate direct net.sockets lock entirely, since the one http.request fetch already brings in every sockets.* declaration they call directly. Publishing it also found the second wrong prediction §4.6 now carries its own correction for — Requirement.store read as a bare, working-directory-relative path, rather than resolved against the depending store’s own directory — surfaced only once a real, non-synthetic dependency pair was fetched from a working directory other than the repo root.

Done too: net.connect extended, and a fifth package, packages/http-response/ (response.cho). examples/fetch/fetch.cho, examples/report/report.cho and examples/agent_guest/agent_guest.cho turned out to duplicate a fifth cluster, six functions byte-for-byte this time — octets_of/port_of/address/connect_to and send_all/status_of — three real askers each, found by hashing every function body across examples/, packages/, std/ and benches/ and grouping the ones that collide, rather than by rereading files by eye the way every earlier slice here found its own duplication. The first four are generic to any outbound program, not HTTP-specific, and joined net.connect itself — growing an already-published store with more declarations turns out to be the same act as its first publish, just a second vcs publish against the same --store: unchanged entries (connect itself, here) are skipped, only the new ones log. send_all/status_of are HTTP-specific and became http.response, a new, fifth package and the client-side mirror of http.request: that one reads a request head server-side, this one writes a request fully and reads a response’s status line client-side. connect_to needs socket/close and send_all needs write, so both now import net.sockets too — the third and fourth real consumers of that package alongside http.request, docs/package-system.md §4.6’s own closure resolution doing the same work a third and fourth time with no changes needed. One thing the design doc had not foreseen: two sibling packages (net.connect, http.response) transitively requiring the same dependency (net.sockets) means fetching each into its own output directory — the pattern every earlier multi-package example used, back when no two packages shared a transitive dependency — now fetches net.sockets twice, under two different paths, which build/check correctly refuse as a duplicate declaration. Not a gap in the resolver itself (vcs resolve on either store alone is silent), only in how a consumer composes two fetches: fetching both locks into one shared output directory is enough, since vcs fetch writes each file as <source_hash>.cho and two fetches of the same content overwrite the same path rather than writing two different ones. examples/README.md and the conformance test harness (fetch_net_dependencies) both moved to that shape.

7. Packages across repositories

Status: steps 1 and 2 are built, and §7.7 records what building them showed. Everything from §7.5 on is named and not built.

7.1 What asked for it

Sections 4 to 6 built a package system that works inside one checkout: vcs fetch takes --store <dir>, a directory on the same disk, and the only example of a package used from another program is examples/api consuming packages/http-server from the same repository. The first programs written outside this repository did not use it. cancho-hooks (a webhook service, four sibling repositories) builds like this:

git clone cancho; git clone cancho-log; git clone cancho-hooks
(cd cancho && git checkout $CANCHO_REV && cargo build --release)    # the compiler
(cd cancho-log && git checkout $LOG_REV)                              # a library
scripts/build.sh   # names every file of http-server and of the log by relative path

Two SHAs in CI, three clones, a file list written by hand, and no check that what was cloned is what the author tested except the SHA. That is exactly the ritual a lock file exists to remove, and cancho-web, cancho-pg and cancho-cache each repeat part of it (cancho-web needs cancho-schema and http-server). The two-asker bar of CONTRIBUTING.md is cleared by a count, not an argument.

Trying to use the existing machinery for cancho-log found three things, each reproduced rather than assumed:

  1. A lock says what, not where. Lock holds name -> (sig_id, stage_id, source_hash); vcs fetch takes a local directory. The only way to get the store is to clone its repository by hand.
  2. cancho-log cannot be published. vcs publish crc.cho fails with internal: `crc_table` has an identity but no lowered function or extern. cancho-id::identify gives a static two identities (its name and type, and its body, compile-time-data.md §2) and cmd_publish reads a declaration’s effects from a lowered function or an extern, which a static is neither. This is the same shape as the extern fn gap §6 records for net.sockets, one declaration kind further along.
  3. One file per store. cancho-log is four modules (crc, record, segment, log), each importing the one before. Published one file at a time that is four vcs publish runs in dependency order, each --requires taking a lock file for the one before; correct, and a ritual nobody will perform by hand on every commit.

7.2 What lex-lang already learned about this

lex pkg is a working package manager that fetches from git and from a hosted registry, and it has paid for lessons this design would otherwise repeat. Read from crates/lex-syntax/src/workspace.rs, crates/lex-syntax/src/lock.rs and crates/lex-cli/src/pkg.rs:

lex-langWhat happenedWhat this design does with it
A git dependency is { git, rev | tag | branch } in lex.toml; the default is the branch head (“not reproducible — pin for releases”)A moving ref was cached under the package’s name, so a checkout made once was read for months: a rename upstream was invisible and lex check reported unknown_variant against an interface that no longer existed (#1005)A lock records a full commit hash and never a ref. A ref is resolved once, by the command that writes the lock, and the cache is keyed by the hash, so staleness cannot happen rather than being detected
Resolving a moving ref ran git ls-remote once per import site13 ls-remotes of one repository for one small file, about two minutes (#1015); then a per-process answer cache, because CI running lex check per file went from 55 s to about 10 minutesNothing on the build path asks the network a question. fetch asks for a hash it already has, which is a directory lookup, and only goes to the network when the directory is missing
rev could not be shallow-cloned: a full clone, then checkoutCost grows with the dependency’s historygit fetch --depth 1 <url> <hash> into a fresh repository (hosts that serve a commit by hash, which GitHub does), falling back to a full fetch only if that is refused
Git dependencies carry no lock entry; “hosted verification resolves dependencies only through lex.lock pins into registry stores (it never fetches git)” (#944)Two tiers: pinned-and-checkable (registry) and convenient-and-unpinned (git)One tier. A pin is always a hash of source (Lock’s source_hash), so the transport carries no trust and git can be the transport: whatever git delivers is re-parsed, re-typechecked and re-hashed by vcs fetch exactly as a local store is
lex = "0.10.15" as a toolchain floor in [package], which nothing read until #803A dependency moved onto a newer stdlib, installed without complaint, and the mismatch surfaced as unknown_field errors naming a function nobody in the consuming repo had written; now lex pkg install refuses (--ignore-lex-floor to override)Not in this slice, and the reason it is the next one: hash-stability.md is the same failure at a larger scale. Section 7.5
Prebuilt release tarballs with a .sha256, installed by curl | tar in docs and CICI installs a pinned lex in secondsSection 7.5; today a cancho consumer runs cargo build --release of the compiler in CI
A hosted registry (archive download, immutable releases, signed contracts, --trusted-keys)Real infrastructure, built after gitOut of scope, as §4.7 already argued: a registry would be a name-to-pin index in front of the same stores
import "pkg/module" resolved by walking up to the nearest lex.tomlThe compiler reads the manifestDeliberately not copied: modules.md §4.2 and §6 above found that import needs no search path, and the compiler stays ignorant of where files came from

7.3 Step 1: an origin on the lock, and a fetch that can go and get it

Lock gains one optional field, because a lock already addresses exactly one store (vcs fetch --lock takes one --store):

{ "origin": { "git": "https://github.com/alpibrusl/cancho-log",
              "rev": "c0c3852541e927c3e893331207f1a0aaf184bc2e",
              "path": ".cancho-vcs/log" },
  "entries": { ... unchanged ... } }
  • rev is a full commit hash (40 hex digits, or 64 for a SHA-256 repository) and nothing else. A branch or tag name is refused at the point of writing and at the point of reading.
  • path is the store’s directory inside the repository (default .cancho-vcs).
  • An older lock without origin loads as before.

Commands.

  • vcs lock --git <url> --rev <hash> [--path <dir>] -o <file> (--all | <name>...) makes the store available (below), reads its manifest, and writes the pins and the origin. --ref <name> in place of --rev resolves the name once, through git ls-remote, prints the hash it found, and writes the hash. --all pins every declaration of the store; a library locked a name at a time is a list nobody will keep.
  • vcs fetch --lock <file> -o <dir> and vcs resolve --lock <file>: when the lock has an origin and no --store is given, the store is the cached checkout’s <path>. --store still wins when given, for working on a dependency from a local checkout.
  • Closure. A Requirement (§4.6) carries a Lock, so a package that requires a package in another repository records that lock’s origin, and resolve_own_requirements uses the origin when there is one and the relative store path otherwise. Same-repository requirements, which is all of packages/ and all of what step 2 publishes, keep working inside the checkout unchanged.

The cache. $CANCHO_CACHE, else $XDG_CACHE_HOME/cancho, else $HOME/.cache/cancho; a checkout lives at git/<rev>/. (The first draft of this section keyed it by the repository’s location too, git/<blake3(url)[..16]>/<rev>/; building it found that wrong in the useful direction: a commit hash is its content, so two mirrors of one commit should share a directory, and where it came from decides nothing. Corrected in place.) It is created once, in a temporary directory beside its destination (git init; git fetch --depth 1 <url> <rev>; git checkout --detach FETCH_HEAD; git rev-parse HEAD must equal rev), and renamed into place, so a concurrent or interrupted fetch leaves nothing half-made and a directory that exists is complete. It is never updated: a different rev is a different directory. Nothing evicts it; rm -r does.

Why this is not “running the dependency’s code” (§4.3). git is run with core.hooksPath pointed at nothing, GIT_TERMINAL_PROMPT=0, only the https, ssh, git and file transports allowed (ext:: and the other remote helpers run programs, and http is cleartext), and no submodule is initialised; the checkout is read, never built or executed. Whatever the transport delivers is then held to the same recheck as a local store: re-parsed, re-typechecked, and every identity recomputed against the manifest and the lock. A hostile mirror can refuse to serve; it cannot make fetch accept different source, because the pin is a hash of the source and the commit hash only decides which directory to look in.

What stays as it is. No project file, no version string, no resolution of anything the lock does not name.

7.4 Step 2: publishing a library

  1. static is publishable. A static has no effects (it is evaluated at compile time and cannot call out), so cmd_publish gives it the empty row instead of the internal error, and it is published and pinned like any declaration. Private statics (crc_table) are published too, as private functions already are; a consumer only ever names a public one.
  2. vcs publish --dir <dir> --store <root> publishes every .cho file of a directory, each into its own store <root>/<module>, in dependency order. The order and the --requires are derived, not typed: each file’s module name and imports come from its parse, an import of another file of the directory becomes a requirement on that file’s store (a lock of all its declarations, with a relative store path inside <root>), an import of std needs --std as before, and any other import is refused naming the file and the module. A cycle is refused.
  3. Stores are regenerated, not appended to. Publishing a changed declaration into a store that already has it is refused today (vcs-publish.md §5: incremental publish is not built), which would make --dir unusable after the first edit. A directory publish removes and rebuilds each module’s store. This loses nothing a consumer holds: its lock pins a commit of the repository, and the store at that commit is what it fetches; the git history is the version history, which is also what §4.1 said a version is (a label over hashes).
  4. Deterministic. Operations, manifests and blobs are content-addressed and carry no times, so publishing the same directory twice writes the same bytes; committing the store to the library’s repository produces no churn. (This is a claim and is a gate item below.)

The store is committed to the library’s repository, as packages/*/ .cancho-vcs already is here. A CI check that the committed store matches vcs publish --dir of the source is natural and not part of this step.

A package that imports another package (#210). packages/tls imports packages/x509, which --dir cannot publish: it “cannot be combined with input files or --requires”, and works out requirements only among the files of its own directory. Measured, on this compiler:

  • vcs publish --dir packages/x509 works, and packages/tls is refused for its import x509_verify.
  • Publishing one file at a time works, each module into its own store with --requires <lock>:<store> for every module it imports, in dependency order. scripts/publish_packages.py reads the order and the requirements from each file’s module and import lines, so none is typed, and writes packages/<package>/.cancho-vcs/<module>. A store records its requirements as paths relative to itself, so the layout is part of the result. 9 modules (x509, x509_names, x509_verify, tls_record, tls_message, tls_slot, tls_client12, tls_client, tls), 2.6 MB.
  • It is deterministic: --check publishes a copy of the sources into a scratch tree and compares every file of every store with the committed one. It passes, and it fails (exit 1) on a source that changed without its store. CI runs it in the tls-assurance job. This is the CI check §7.4 named as “natural and not part of this step”.
  • A consumer takes one dependency. A scratch project with [dependencies.tls] ... path = "packages/tls/.cancho-vcs/tls" pinned to a revision: cancho install fetched nine files, the three x509 and six tls modules, through the recorded requirements. It built, linked and ran a program that opens and closes an engine. The authority report of that program, with the dependency, is heap and io_write, bounded, and no unbounded_by: a consumer of the pure TLS package gains no foreign authority.

7.5 Not in this step, in the order I would take them

  1. A project file (cancho.toml: entry files, dependencies, the compiler it was written for) read by build, check and test, so a consumer’s scripts/build.sh and its file list disappear, and a module-level lock derived from the import lines.
  2. A compiler pin that is checked. lex-lang’s #803 is the argument: a floor nobody read produced errors that named the wrong thing. For cancho the needed fact is stronger than a floor, because hash-stability.md measured that most of this repository’s history does not type-check under today’s compiler: the project file records the compiler’s source revision, cancho --version reports its own, and build refuses a mismatch with a message that says so. This needs cancho to know its revision, which it does not today.
  3. Prebuilt compiler releases, so CI installs a pinned compiler in seconds instead of running cargo build --release each time.
  4. update shows the authority diff (§4.4): what a bumped dependency can newly do, from cancho authority --output json of both sides.

7.6 The gate, fixed before the build

Steps 1 and 2 are done when all of these hold, measured, on the cancho-hooks / cancho-log / cancho triple, and a gate that fails is reported as failed, not loosened:

ClaimHow it is checked
G1cancho-hooks builds from its own repository plus the compiler: no ../cancho-log, no ../cancho source tree for the packagesa clean directory containing a clone of cancho-hooks and nothing else; cancho vcs lock --git … --all, vcs fetch, build; then idempotency_test and chaos pass on that binary
G2A tampered checkout is refusedone byte changed in a cached source blob (and, separately, in the manifest) before fetch: nonzero exit, nothing written to -o
G3A cache hit needs no network and no gitthe second fetch run with PATH stripped of git succeeds
G4A pin is a hash--rev main is refused; a lock edited to hold a branch name is refused at load; moving a branch of the origin changes nothing
G5cancho-log publishes, resolves and is deterministicvcs publish --dir src on all four modules succeeds including static; vcs resolve of the closure passes; publishing twice gives byte-identical trees; publishing after editing a function succeeds
G6Mutation survivors are classified, as for hooks: remove each check (hash length, rev-parse, tamper recheck, atomic rename, origin-wins in closure) and the test that dies is nameda table in the section that records the result
G7Cost is reported, not gated: cold fetch of cancho-log and of http-server, cache-hit fetch, publish --dir timenumbers in this section

7.7 What building steps 1 and 2 showed

The gate (§7.6), as measured.

Result
G1Met. In a directory holding a clone of cancho-hooks and the compiler binary, and nothing else: vcs lock --git … --all for cancho-log (commit f4bde04) and for http-server (a commit of this repository), vcs fetch of both, build. idempotency_test (including the 65,536-key stage) and chaos (2,000 events, power cuts) pass on that binary. The binary is not byte-identical to the one scripts/build.sh makes from sibling checkouts; the fetched files are named by hash and so sorted differently, and I did not chase the difference further than that, so “same behaviour under these suites” is the claim, not “same bytes”
G2Met. One byte changed in a cached source blob: fetch exits nonzero and writes nothing (a_cached_checkout_that_was_changed_is_refused_and_nothing_is_written)
G3Met. The repository deleted and git taken off PATH, the second fetch succeeds from the cache (a_second_fetch_is_served_from_the_cache_without_git)
G4Met. --rev master refused at the command and, in a hand-edited lock, at load; --ref resolved once and the hash written; a branch that moves afterwards changes nothing a lock pins
G5Met. All four modules of cancho-log publish, crc’s static included; vcs resolve passes for each; publishing twice gives a byte-identical tree, and publishing after editing a function succeeds
G6Met, with one unverified check, below
G7Reported: cold fetch of cancho-log 0.55 s and of this repository’s http-server 1.34 s (a depth-1 fetch by hash from GitHub through this environment’s proxy, 8.3 MB of cache for both); the second fetch of each, from the cache, 0.06 s for both together; vcs publish --dir of the four modules 0.15 s; the whole closure of log verified in well under a second

Mutants of the new code: twenty-one, twenty killed. Killed (the test that died is in vcs_remote.rs unless noted): a rev that is not a full hash accepted (lock.rs unit test and a_lock_holds_a_commit_and_never_a_name); a path that leaves the repository accepted; an origin not validated on load; the checkout’s .git kept; the checkout built in place instead of beside its destination (a_fetch_that_fails_leaves_no_half_made_checkout); the cache always refilled; an origin that beats an explicit --store; a requirement that ignores its lock’s origin; a machine-specific path recorded in a requirement; modules published in alphabetical rather than dependency order; stores not cleared before a republish; a static refused; a program in the directory refused instead of skipped; no cycle detection; an unplaceable import accepted; --ref writing the name instead of the hash; a lock extended with a different store; every transport allowed; git hooks not disabled; ext:: not refused by validate. Three of these survived the first set of tests and each told something: the machine-path check only looked for the cache’s absolute path and the requirement held a relative path to it (the test now asserts the recorded store is empty); nothing exercised vcs lock on a lock that already pins another commit; and the hook and transport settings are invisible to any test that does not provide a hook or a cleartext server (a template directory with a post-checkout hook now proves the first; a listener that hangs up on connection proves the second). Unverified: the git rev-parse HEAD comparison after the checkout. It is a second line behind git’s own object hashing and the full-length rev that validate demands, and I could not construct a repository in which the first fails and the second passes. It stays as defence in depth with no test.

Found along the way.

  • vcs publish could not publish a static. §7.1 predicted this and it was the first thing to fail. A static is evaluated at compile time and cannot call out, so its effect row is empty; the fix is a third case beside the function and the extern the publish path already knew.
  • A module’s qualifier is its last name segment. For module libx.base; the call is base.pick(i), not libx.base.pick(i). Not a bug, but the tests’ first draft assumed the other, and a directory of modules whose last segments collide would have to be told apart by the author.
  • A test hung instead of failing. The cleartext-transport test first listened and checked afterwards; with the transport check removed, git connected, waited for an answer that never came, and the mutation run stalled for twenty minutes. The listener now hangs up at once.
  • The cache holds trees, not repositories. The .git directory is deleted once the commit is verified: nothing later can run git in it by accident, and it is smaller. The cost is that a checkout cannot be updated in place, which is the design.
  • --dir skips a file with no module declaration (a program such as logtool.cho) and says so. A library module that forgot its module line is skipped the same way, visibly.

Not built, as §7.5 says: the project file, the compiler pin, prebuilt releases, and the authority diff on update. Also not built and noticed: a CI check in a library’s repository that its committed .cancho-vcs matches vcs publish --dir of its source (needs this change in a pinned compiler first); a git that is not installed is an environment error with git’s own message and no suggestion; an origin whose server serves only branches and tags falls back to a full fetch of them, which was written and is not tested.

8. The project file

Status: design, written before the code. §7.5 steps 3 and 4. What was built is recorded in §8.7 when it is.

8.1 What asked for it

§7 let a program depend on a library in another repository, and cancho-hooks is the proof; it also shows what is still done by hand. Its scripts/build.sh lists the source files, runs vcs fetch once per lock, clears the output directory so an old lock’s files do not become second declarations, and passes build/deps/*.cho to build. Its CI repeats the file lists for the unit tests. Its two locks are moved by a second script that takes two full commit hashes. And nothing says which compiler the sources were written for: CI pins a commit of cancho in a YAML file, which cancho itself cannot read, and hash-stability.md measured what that costs when it is wrong (71% of this repository’s own history stops type-checking under today’s compiler). lex-lang found the same thing from the other side: a toolchain floor was written in lex.toml for a long time and nothing read it, until a dependency that had moved to a newer standard library installed without complaint and failed with errors that named a function nobody in the consuming repository had written (#803).

8.2 The file

cancho.toml, at the root of a project:

[package]
name = "hooks"
cancho = "f804ce7e6fcf5717ea52442bf648a1fe81090f98"   # the compiler these sources were written for

[dependencies.log]
git = "https://github.com/alpibrusl/cancho-log"
rev = "6b4f46fd045f9e6c1a3f4bda22ee9d850daf63d1"       # a full commit hash, never a name
path = ".cancho-vcs/log"                              # the store inside the repository

[[bin]]
name = "hooks"
sources = ["src"]                                      # files, or a directory: every .cho directly in it
std = true
out = "build/hooks"                                    # default: build/<name>
  • Unknown keys are refused, not ignored: serde silently dropped lex = "..." in lex.toml for as long as nothing read it, and a misspelt key here would be the same trap.
  • rev is a full commit hash (Origin::validate, §7.3). There is no separate lock file: a manifest of exact commits has no ranges to resolve, and the declaration pins that vcs lock --all would write are derived from the store at that commit and re-checked on every install. A lock appears when a dependency can name something that moves.
  • A dependency’s own dependencies (a package in another repository that a package requires) are not listed: they are in the store’s requires/ with their own origins (§7.3), and fetched with it.
  • Paths are relative to the directory holding cancho.toml, found by looking from the current directory upwards.

8.3 The commands

  • cancho install reads the file, checks the compiler (below), and for each dependency fetches its commit into the cache, re-parses, re-typechecks and re-hashes every declaration, and writes the sources to build/deps/<hash>.cho, clearing that directory first. It is the vcs fetch of §7 for every dependency of the project at once.
  • cancho add <name> <git-url> [--rev <hash> | --ref <name>] [--path <dir>] adds a dependency: --ref (default: the repository’s head) is resolved once and the hash is what is written; the store is fetched and checked before the file is touched; then the table is appended to cancho.toml (appended, so comments in the file survive) and the project is installed. A name that is already there is refused.
  • cancho build with no file arguments builds every [[bin]] (--bin <name> one of them): it installs first, which costs a directory lookup and a recheck when everything is cached (0.06 s for the two libraries of cancho-hooks), so there is no staleness to get wrong. With files it is build as before.

8.4 The compiler

cancho --version reports the commit it was built from: build.rs reads it from git (suffixed -dirty if the working tree has uncommitted changes), or from CANCHO_REV for a build outside a git checkout, and says unknown otherwise. install and build compare it with [package] cancho and refuse a difference, naming both; --ignore-compiler-rev is the escape hatch for someone who knows. A binary that does not know its own revision cannot be checked and is refused the same way: a floor that cannot be read is how #803 happened. The pin is on a commit because that is what the repository can say exactly; when compilers are released as binaries (§7.5 step 5) the release will embed the commit it was built from.

8.5 What this does not do

No version ranges, no registry, no feature flags, no dev-dependencies, no workspaces, no remove (delete the table), no linking options in [[bin]]. (test was on this list and is built, §8.8.) Each is a decision that waits for a program that asks, by the bar of CONTRIBUTING.md.

8.6 Distributing a program

Asked alongside this: does cancho build make a binary that a package can be shipped as, and should cancho make Docker images? Measured on cancho-hooks: build makes a native executable (an ELF PIE, 169 KB), linked by cc (the CC environment variable names another linker), and the only shared library it needs is libc (ldd: libc.so.6 and the loader). A static one builds with a linker that adds -static (CC=./static-cc, a three-line script): 1.2 MB, not a dynamic executable, with the glibc warning that getaddrinfo still wants the shared libraries at run time, which the service avoids by dialling IP literals (§16 of its design). So a program is distributable as a file today, and a static one runs in an image with nothing else in it. No Docker daemon was available in the environment this was written in, so no image was built here; that is a claim about the binary, not a test of an image. The recommendation, not built: cancho does not generate images (an image is a deployment decision, not a compiler’s), a release is the binary, its checksum and cancho authority --output json of it (what it can do, in the form lex-os-capsule already signs), and an image of the compiler for CI is a thin layer over a release.

8.7 What building the project file showed

Built as designed in §8.2 to §8.4: cancho.toml ([package], [dependencies.<name>], [[bin]]), cancho install, cancho add, cancho build with no files and --bin, and the compiler’s revision in --version (build.rs) checked against [package] cancho. About 430 lines in crates/cancho/src/project.rs, one of them shared with vcs fetch: fetch_verified was cut out of cmd_fetch, and both call it.

Checked. cancho-hooks runs on it: cancho.toml replaces its two lock files, its lock.sh, the file lists in build.sh, and the second copy of the compiler pin in its CI (the workflow reads the commit out of cancho.toml, so there is one place to change). cancho build there takes 5.7 s from nothing, of which the installs are the same two libraries as in §7.7 and the rest is compiling. 11 conformance tests (project.rs), on local git repositories: add, install and build end to end with the program’s exit code; a --ref is resolved once and the hash written; every refusal of add leaves the file as it was, and an install that fails after the file was written puts it back (a store whose blob was changed after publishing); a project file with an unknown key, an unknown section, a name for a rev, a path that leaves the repository, a cancho that is not a hash, duplicate programs, a program with no sources or a bad name is refused, each with its reason; the wrong compiler is refused naming both, and --ignore-compiler-rev goes on with a note; a moved pin replaces what the old one fetched; the project is found from a directory below it; build <files> is still the compiler it was; --bin picks one; an empty store is not a dependency.

Mutants of the new code: twenty-three, twenty-two killed, one unverified. Killed: the compiler check always passing; --ignore-compiler-rev ignored; the fetched directory not cleared; unknown keys allowed (at the top, and in a program); a dependency’s origin not validated at load; cancho not validated; add without its rollback, allowing a duplicate, writing the ref’s name instead of its hash, accepting any name, or skipping the store check; a project build that did not install first; wants_project always true or the search not going upwards; --bin ignored or an unknown one accepted; an empty store accepted; an install failure that does not name the dependency; duplicate or source-less programs; a --version without the revision; a stamp that is always unknown. Five of these survived the first tests, and each was a test that checked less than it said: the “unknown key” case appended its key to the last table, which the program’s own check caught, so nothing proved the top level refused; the origin and cancho cases were refused later by other checks, so the load-time ones were untested until the tests asserted the reason; a build that skipped its install passed because every test had installed before; and a bad dependency name was refused by a later check, not the name’s. (Two more mutants, a failure that did not name its dependency and duplicate programs, were first written wrongly and did not apply; they were redone and killed.) Unverified: the branch of check_compiler for a compiler that does not know its revision (unknown, a build outside a git checkout without CANCHO_REV). It needs a second build of the compiler to reach, and is a plain refusal; I read it, I did not run it.

Found along the way.

  • A hash in the file is checked against a build that may be dirty. The compiler says -dirty when tracked files differ from the commit, and such a revision can never equal the 40 hex digits the file holds. That is the right answer (a compiler with local edits is not the commit), and it means the test of the accepted case only runs when the tree is clean, as in CI; locally it is skipped and says nothing.
  • name = "app" appears twice in a small project file, and a string replace in a test put the compiler pin into the program too. deny_unknown_fields caught it, which is the case for having it.
  • The commit is not known before it exists. cancho-hooks has to name a compiler that contains the project file, which this change is; the file in its pull request holds the commit of a build of this branch and is moved to the merge commit once there is one, the same dance as the cancho-log pin in §7.

Not built, as §8.5 says. And one thing noticed: cancho test has no project mode, so cancho-hooks still spells out its three unit-test commands, with build/deps/*.cho as the way to name the libraries.

8.8 cancho test in a project

[[test]] is a set of files whose test_* functions run together, against the project’s dependencies, with std = true if they need the library:

[[test]]
name = "state"
sources = ["tests/state_test.cho", "src/state.cho"]

cancho test with no files installs, then runs every set (--test <name> one), each as the file-taking cancho test always was (testing.md §3), under a header == test <name>. A failing set does not stop the others; the exit code is the first failure’s (4, a failed test). With files, test is the runner it was. A set has the same checks as a program: a unique name, at least one source, no unknown keys. 10 mutants of the new code, 10 killed; one survived until the test of “files still mean the old runner” was actually written (an edit to the test file had silently not applied after cargo fmt reflowed it, which is why the mutation run was done after, not before, claiming it).

Found along the way: the compiler needs more than libc. cancho build is not a closed box: the LLVM backend emits IR and runs clang (the CLANG environment variable names another), then links with cc (CC). A released compiler binary (§7.5 step 5) therefore depends on a C toolchain being installed where it runs, and an image of the compiler for CI would be the place that bundles it. ldd on the compiler itself: libc and libgcc_s, 8.5 MB.

9. Prebuilt compilers

9.1 What asked for it

A project pins the compiler by commit (§8.4), and every machine that builds it, a laptop, a CI job, an image, has so far built that commit from source: a Rust toolchain and about 1m23s of cargo build --release on the machine this was written on. cancho-hooks’ CI does it on every run. A release is the same compiler built once.

9.2 What is built

  • .github/workflows/release.yml: builds cancho on ubuntu-22.04 (linux-x86_64) and macos-latest (darwin-aarch64), packages it, installs the package with scripts/install.sh into a scratch prefix and builds hello.cho with the installed binary, and uploads the assets. It runs for pull requests that touch it, and on workflow_dispatch; only a dispatch with publish: true creates a release.
  • The tag is the commit: the full 40 hex digits, the string cancho.toml already holds. A version number would be one more name to map to the commit; nobody pins one. So from a pin an installer can build the URL of the asset without asking anything.
  • scripts/package-release.sh <target> [dir] writes cancho-<commit>-<target>.tar.gz (bin/cancho, LICENSE, README.md) and …tar.gz.sha256. The commit is read from the binary (--version), not from the checkout, and a binary that does not report a clean commit (-dirty, unknown, nothing) is not packaged: a release that is not the commit it is named for is the failure §8.4 exists to prevent. The archive is reproducible on GNU tar (sorted names, no owners, mtime 0).
  • scripts/install.sh <commit> [prefix] downloads the asset and its checksum, refuses a checksum that does not match, unpacks, and refuses a binary that does not report the commit that was asked for, then moves it into <prefix>/bin. Nothing is installed on a refusal. CANCHO_RELEASES points it at another place (a mirror, or a directory for a test). Exit codes: 2 for something that is not a full commit hash, 3 for a platform with no asset, 4 for a checksum or commit mismatch.

9.3 What is, and is not, in the tarball

The compiler is self-contained but for the two programs it runs: the standard library and the guidelines are compiled in (include_str!), and ldd shows libc and libgcc_s only. It still needs clang (the LLVM backend, the default) and cc (the linker) at build time, as the source build does; install.sh says so when clang is not on PATH, and when it is older than 15: the backend’s IR uses opaque pointers, and clang 14 (the default of Ubuntu 22.04) refuses it (ptr type is only supported in -opaque-pointers mode). Found by the first run of the release workflow on ubuntu-22.04, which built the compiler and could not build hello.cho with it; the workflow now installs clang-15 there and names it with CLANG. Not bundling them is deliberate: the toolchain is whatever the host’s is, and the compiler’s output is checked by running it.

9.4 Why ubuntu-22.04

A binary links against the glibc it was built with and needs that version or newer. Built on the machine this was written on (glibc 2.39), the compiler references symbols up to GLIBC_2.39 (objdump -T), which would not start on Ubuntu 22.04 (2.35) or Debian 12 (2.36). Building on the oldest supported runner is the cheap fix; a static or musl build is the alternative, and is not tried.

9.5 What is checked

Six conformance tests (release.rs, skipped with a message on a machine install.sh has no asset for) run the scripts against a stand-in compiler (a shell script that prints a version line, so the result does not depend on whether the compiler under test was built from a clean tree): package then install, and the installed binary reports the commit; -dirty, unknown and a bare version are not packaged and nothing is written; packaging twice, a second apart, gives the same bytes; a tarball with a byte appended is not installed (exit 4, no prefix created); a genuine asset renamed to another commit is not installed (exit 4); and what is not a commit (abc, main, 41 digits, 40 non-hex characters, ../ repeated to 40, empty), an asset that does not exist and a checksum file that does not exist are each refused with nothing installed. Mutants of the scripts: of nine, six killed at once; the control no-op survived as it must; one non-hex check survived until the test passed forty characters that are not hex, and was then killed; one (--sort=name) survives and is believed unobservable here, since three files are listed in the same order by every filesystem tried, and the flag is kept for the ones that do not.

On this machine, packaging the compiler built from a clean checkout of main gave a 2.8 MB tarball; install.sh from a file:// directory installed it, and the installed binary built and ran hello.cho.

9.6 Not verified

What the first run on GitHub showed (it ran for the pull request that adds it): the conformance tests failed on macOS because they named the stand-in asset linux-x86_64 while install.sh looks for darwin-aarch64 on that machine (the tests now ask for the target the installer will compute), and install.sh '' exited 1 there where dash exits 2 (the script now checks the argument itself); and the 22.04 leg found the clang requirement of §9.3. What is still not tested: the macos-latest release leg end to end, whether a binary built on 22.04 starts on an older glibc than the runner’s (it ran on 22.04 itself, which is the evidence for §9.4), and gh release create. No release has been published, and none will be without being asked: a published release is an outward act, and a tag named for a commit stays.

cancho-hooks’ CI would try the release asset first and build from source if it is absent; that is a change in that repository, after the first release exists.

What the overflow trap costs

Status: measured, and the README was wrong.

Not about the number — the number is right about as often as it is wrong. About the reason. README.md said the cost was “a low single-digit percent” and defined-behaviour.md §2.1 said why: “a branch that is never taken is close to free.” The branch is close to free. The branch is not the cost.

This document exists because an outside reader said the claim should be measured before it was sold, and they were right: nineteen slices in, this repository had no benchmark of any kind.


1. What was measured

Four programs, each written twice. The _checked half uses +, - and *, which trap on overflow. The _wrapping half is the same program with wrapping_add/wrapping_sub/wrapping_mul, which lower to a bare iadd/isub/imul (crates/cancho-codegen). Nothing else differs, so the gap between the two binaries is the guarantee and nothing else.

$ python3 scripts/bench.py --with-c

Interleaved A/B, minimum of nine runs per half, linux-x86_64, Cranelift at opt_level = "speed":

benchmarkwhat dominatescheckedwrappingdifference
sumarithmetic, nothing else0.3007s0.2141s+40.5%
sievestrided writes, cache0.2812s0.2715s+3.6%
scancomparisons, branches0.2541s0.2794s−9.1%
fibcalls and returns0.0145s0.0141s+2.8%

Three of the four are inside “a low single-digit percent” — one of them because the checked build was faster. The fourth is forty percent.


2. So the claim is not wrong, it is the wrong shape

The cost of checked arithmetic is not a percentage. It is whether arithmetic is on the critical path.

Which is obvious once it is written down, and is not what either document said. README.md gave a single number, as though the check were a tax on a program’s size. It is a tax on one resource, and most systems code is waiting for a different one — a cache line, a branch, a syscall.

The honest summary is a range with a rule attached, and both documents now carry it.


3. Why the worst case is forty percent, which is not the branch

3.1 The branch really is close to free

fib runs three checked operations per node and pays 2.8%. sieve runs a checked add in its innermost loop and pays 3.6%. If a never-taken branch were expensive, those would not be the numbers.

3.2 The cost is that a trap is observable

Here is the same reduction in C, compiled at -O2, once with __builtin_saddl_overflow and __builtin_trap and once with plain wrapping addition (benches/reduce.c):

compilercheckedwrappingdifference
clang 180.0929s0.0636s+46.1%
gcc 130.1532s0.0881s+73.8%

A mature optimising backend is worse at this than Cranelift, which settles what is going on. Counting the SIMD instructions in clang’s inner loop:

unchecked    26
checked       0

The check does not cost a branch. It costs the vectoriser. An addition that may trap is an addition whose order is observable, so it cannot be reassociated, so a reduction cannot be split across lanes. The unchecked loop adds four at a time; the checked loop adds one at a time and then also tests a flag.

Corrected (#62): “the check” is too general — it is this check. gpu.md §2 ran the same experiment with the two guards switched independently, and a bounds check is free: 1.01×, with the SIMD count unmoved at 10. The index condition is provably true inside a loop the compiler already proved bounded, so it is deleted outright.

A branch on a constant is not a branch, and it was never a reassociation barrier. What costs the vectoriser is a trap on a value the compiler cannot bound — which is the overflow check and, as far as anything here has measured, only the overflow check. This section measured one guard and wrote “the check”; the sentence above is kept because it is true of the one it measured.

Corrected again (#64): “only the overflow check” was the same mistake a second time. check-cost.md ran this experiment over all eight checks this language emits in a loop body, and six of them take the SIMD count to zero. The overflow check is not special; it was first.

The correction above got the mechanism right and the scope wrong, because it had two data points and both were the ones already measured. What decides it is neither memory-safety nor arithmetic: s[lo..hi] and s[i] are both bounds checks and cost 2.16× and 1.00×. Two programs with the same two comparisons and the same trap, differing only in whether the bounds come from memory or from the induction variable, cost 2.16× and 0.99×.

Provable is free, and loaded is not. s[i] is free because i < n is the loop’s own condition, which is this box’s last sentence generalised: a branch on something the loop already proves is not a branch either.

And “vectoriser” is too narrow as well. The truncate(f) loop has no SIMD either way at baseline and still costs 2.50×, because unguarded it is unrolled four wide with four independent accumulators. What a trap costs is reassociation; vectorisation is the largest thing reassociation buys, not the only one.

That is a real, structural cost, it is exactly the one the README promised there would not be, and it lands precisely where the README’s own “linearity gives the optimiser stronger aliasing facts” argument wants to collect its winnings — the hot arithmetic loop. (That argument has since been falsified on its own terms: &! is not a no-aliasing invariant, and the README now says so.)

And this section named one case as though it were the case. compile-time.md §2.1 found the same mechanism somewhere this document did not look: 2 + 3 * 4 - 14 is a constant, and Cranelift folded none of it, because sadd_overflow + trapnz is not the iadd its rules are written for. The trap did not block the vectoriser in particular — it blocks whatever the optimiser would otherwise have known, and a constant-folder is the cheapest thing on that list. That one was ours to fix and has been: the front end has the literals, so it evaluates them itself, and the question of whether the operation traps has an answer before the backend ever sees it.

3.3 And the benchmark that came out backwards

scan is 9% faster checked than wrapping, consistently, across four different code layouts (padding the object with unused functions to move words in memory changed the figure between −5.8% and −10.5% and never changed its sign).

We do not have an explanation, and this document is not going to invent one.

It has a name, found later. Mytkowicz, Diwan, Hauswirth and Sweeney, “Producing Wrong Data Without Doing Anything Obviously Wrong!” (ASPLOS 2009), showed that link order and environment size shift a measured speedup by more than the optimisation being studied — which is exactly the experiment above, run by accident. So the paragraph below is right that the things deciding the time are not in the source language, and the phenomenon is measurement bias rather than a mystery.

What still stands is the sign: padding moved the figure between −5.8% and −10.5% and never changed it. Bias of that size explains the spread, not a consistent direction. What can be said is bounded and worth saying: the difference is about 0.6 cycles per iteration in a loop whose real work is a load, four compares and two unpredictable branches, and at that scale the things that decide the time are not in the source language. It is evidence for §2 — where arithmetic is not the bottleneck, removing the check does not reliably make a program faster, and may not make it faster at all.

3.4 One thing that is ours to fix

Cranelift lowers sadd_overflow + trapnz to three instructions where one would do:

add    $0x1,%rdx
seto   %r10b          # ... into a register
test   %r10b,%r10b    # ... then test the register
jne    <ud2>          # instead of `jo <ud2>`

That is not the vectoriser problem and it is not a language cost — it is a lowering that has not been taught the idiom. It will not rescue sum, whose ceiling is set by §3.2, but it is real and it is in the part of the stack this project controls. Filed in §5.


4. What this does not say

  • It does not say trapping is wrong. defined-behaviour.md §2.1’s argument is unchanged: a silently wrong answer propagates and a stopped process does not, and nothing measured here bears on that. A cost being larger than advertised is a reason to advertise it correctly, not a reason to stop paying it.
  • It does not say the language is 40% slower than C. It says an arithmetic-bound reduction is, in both cancho and C, when the arithmetic is checked. The comparison between cancho and C at equal semantics is a different measurement, and this repository has not made it.
  • It is one machine and one afternoon. Four programs on linux-x86_64, no aarch64 numbers, no profile beyond wall-clock. The script is committed so the next person does not have to trust the table.

5. Open

QuestionWhy it waits
jo instead of seto/test/jne§3.4. Ours to fix, in the sense that it is a Cranelift lowering we could contribute or work around. Worth measuring against sieve and scan, where the instruction count is a larger share of the loop than in sum
The aarch64 numbersCI runs darwin-aarch64 and this table does not. Flag-setting and branch behaviour differ enough that the numbers are not transferable
@wrapping on a block or a functionIf a hot loop wants the bits, it currently says so once per operation. A scoped form would keep the default honest and the loop readable — and would need to be impossible to reach by accident, which is the hard part
Checked arithmetic against real C§4’s second bullet. Needs a program that exists in both languages, which is the port reach.md §6 also wants

6. The suite

TestShows
every_benchmark_pair_agreesBoth halves of every pair compile and compute the same answer, which is what makes the timing meaningful. Not a timing gate — wall-clock in CI is noise
scripts/bench.pyThe table above, re-measured rather than quoted
ProgramMeasures
benches/sum_*.choThe ceiling: arithmetic and nothing else
benches/sieve_*.choMemory-bound
benches/scan_*.choBranch-bound, and §3.3
benches/fib_*.choCall-bound
benches/reduce.c§3.2: what a mature backend does with the same guarantee

Against C and Rust

Status: measured. The answer was 1.6× and is now a range.

docs/overflow-cost.md §4 named this as a measurement the repository owed and had not made: “It does not say the language is 40% slower than C. […] The comparison between cancho and C at equal semantics is a different measurement, and this repository has not made it.”

It is made now. The headline is that at equal semantics cancho is about 1.6× C and Rust, that Rust and C are within 4% of each other, and that the gap is therefore a backend gap rather than a language one.

Corrected by breadth, not by error. benchmarks-game.md added three more programs and the gap ranges from 1.17× to 2.58× across five. The two kernels here are inside that range and neither number below has changed; what was wrong was quoting their midpoint as though it were a constant. The range tracks how much of the run is in code Cranelift generated — 1.17× where the program is mostly inside malloc, 2.58× in a tight float loop — which is the same conclusion this document reaches, with a shape instead of a point.

§2’s own falsifier is now answered, and the whole range did collapse. docs/llvm-backend.md §7 measured mandelbrot.cho through --backend llvm against mandelbrot.c: 0.999×, not 1.786×. That was one point, re-measured while most of benches/ still couldn’t build on this backend at all. --backend llvm is the default now (#127), every kernel this document and benchmarks-game.md track builds on it, and benchmarks-game.md §8 re-ran all five of the wider set plus the two kernels below: §2’s own two numbers, re-run the same way (scripts/three.py --rounds 9, no --backend flag), move to mandelbrot 0.95× (two runs, both 0.95×) and sieve 0.93×–0.95× (two runs) — both now faster than C, not merely level with it. benchmarks-game.md’s own five kernels move less uniformly: the float-heavy one (spectral-norm) drops the most, the malloc-bound one barely moves, and one (fasta) moves the wrong way while staying faster than C — see that document’s §8 for the shape, not just the headline.


1. The discipline, because it is most of the work

Three rules, and each rules out a comparison that would have been easier and worthless.

The same algorithm, line for line — not the same task. Comparing examples/sort to GNU sort would measure decades of tuning and an external merge sort. benches/three/mandelbrot.{cho,c,rs} are the same loop written three times, with the same variables carried across iterations in the same order.

The same semantics. Rust’s release profile wraps on overflow; cancho traps. Reporting rustc -O against cancho would be reporting a semantic difference as a performance difference, so Rust is built both ways and the honest row is the one that also traps.

The same answer. Every build prints a checksum, and the harness refuses to report a time when they differ. That is not ceremony: an early draft had a fixed-point constant wrong in one language and the numbers looked fine.

$ python3 scripts/three.py

2. The numbers

linux-x86_64, Xeon @ 2.10GHz, minimum of seven runs, cc -O2 (gcc 13) and rustc -O (1.98).

Compute-bound: Mandelbrot, Q16.16 fixed point

buildtimevs C at equal semantics
cancho (traps)0.2098s1.69×
C -O2 (traps)0.1240s1.00×
Rust -O (traps)0.1288s1.04×
C -O2 (wraps)0.1011s0.82×
Rust -O (wraps)0.1139s0.92×

All five compute 39,690,297.

Memory-bound: sieve of Eratosthenes

buildtimevs C
cancho0.3189s1.56×
C -O20.2044s1.00×
Rust -O0.1633s0.80×

All three compute 6057.

2.1 Recounted: --backend llvm is the default now

Both tables above are --backend cranelift, whichever backend was the default the day they were measured. scripts/three.py passes no --backend flag to cancho build, so it has always measured whatever “the compiler” defaults to — Cranelift then, LLVM since #127. Re-run on a cloud container reporting the same CPU model (Intel(R) Xeon(R) Processor @ 2.10GHz, so not necessarily the same physical host or as quiet a one), two runs of scripts/three.py --rounds 9 each:

kernelvs C, --backend craneliftvs C, --backend llvm
mandelbrot (traps)1.69×0.95× (both runs)
sieve1.56×0.93×–0.95×

Both now faster than C, not merely closer to it — benchmarks-game.md §8 has the wider five-kernel set this pair sits inside, where the move is real but less uniform.


3. What the numbers say

1.6×, on both kernels, is the number to quote — for these two kernels. benchmarks-game.md §4 is why that sentence needed the qualifier it did not have. Not 1.0, and not the 40% figure from overflow-cost.md — that one measured trapping against wrapping within cancho, which is a different question and is why this document exists.

Rust is within 4% of C at equal semantics, and cancho is not. That is the load-bearing observation. Rust carries linear-ish ownership, a borrow checker, bounds checks and monomorphisation, and pays essentially nothing for them on this kernel. So the 1.6× is not the price of safety, ownership, or effect rows — those are erased at compile time here exactly as the README claims. It is Cranelift against LLVM, and it is the concrete version of the README’s “any larger gap early on is implementation maturity, not language design.”

That claim was an assertion when it was written. It now has a number attached and a falsifier: if an LLVM backend lands and the gap stays at 1.6×, the claim was wrong.

The falsifier is answered: the gap did not stay. docs/llvm-backend.md §7 landed a working (if partial) LLVM backend and measured this exact kernel, benches/three/mandelbrot.cho, against mandelbrot.c on the same host, same rounds, randomised (not merely interleaved) run order: cancho through --backend llvm is 0.999× C — indistinguishable — against cancho through --backend cranelift’s 1.786×, the same figure this document’s own 1.69× already said, within this host’s noise. The gap was Cranelift against LLVM, and closing it took switching backends, not touching the language. This is one kernel, not the whole range benchmarks-game.md widened to — most of benches/, including every program that range is drawn from, does not build on --backend llvm yet (docs/llvm-backend.md §7.3 has the inventory) — but it is the same kernel this document’s own headline number came from, and on it the claim holds exactly.

Rust beat C on the sieve, which is a warning about this whole document. 0.80× is not a language result; it is two implementations of one algorithm meeting different optimisers. The mandelbrot triple is the stronger evidence because the three sources are line-by-line translations with identical data layout, and the sieve pair is not quite that — Rust’s clearing loop is an iterator that LLVM turns into a memset and the C is a written-out loop. Treat the sieve row as a second opinion rather than a second measurement.

And the measurement floor is higher than it looks. The same cancho sieve, compiled from a version that returns its answer rather than printing it, ran 15% faster — a difference made entirely of code layout, the effect overflow-cost.md §3.3 found when padding an object moved a loop by a few bytes. Anything here under about 15% is not a result.


4. What the absence of float costs

This is the part that is not about speed.

cancho has no floating point (reach.md §2), so the Mandelbrot above is Q16.16 fixed point: a value is the real number times 65536, a product is (a * b) >> 16. The C and Rust versions were written the same way so the comparison would be fair — but a program that actually wanted this picture would have written double.

So here is that program, as the row cancho cannot enter:

buildtimevs C fixed-point
C -O2 f640.1449s1.17×
Rust -O f640.1454s1.17×

4.1 The surprise: f64 is slower here

Double precision is 17% slower than Q16.16 integer arithmetic on this kernel, in both C and Rust, while doing slightly less work (the f64 runs total 39,684,084 iterations against fixed point’s 39,690,297).

Integer multiply and shift have better throughput on this processor than double multiply, and the kernel is three multiplies and four adds per iteration with no transcendental anywhere. So on this shape of work, not having floats is not a speed problem.

That is worth stating plainly because the intuition runs the other way, and because it narrows what the missing feature actually costs.

4.2 What it does cost: fifteen decimal digits

resolution
Q16.161.53 × 10⁻⁵
f642.22 × 10⁻¹⁶

Ten orders of magnitude, and the picture shows it: the two runs disagree by 6,213 iterations out of 39.7 million, 0.0157%. Pixels near the boundary land on the wrong side because the grid coordinate itself is only accurate to 1.5 × 10⁻⁵.

For a benchmark that is nothing. For numerical work it is the whole subject:

  • Deep zooms are impossible. Q16.16 holds values in ±32768 with 15 fractional bits. A Mandelbrot zoom past about 10⁴ has no bits left.
  • Range and precision trade against each other, manually. Q16.16, Q32.32 and Q48.16 are three different types in a language with one integer, and choosing between them is the programmer’s arithmetic, checked by nothing. A float carries its exponent.
  • Every standard numerical method assumes it. Condition numbers, convergence criteria, error bounds — the literature is written in floating point, and a fixed-point port of any of it is a new piece of analysis rather than a translation.

So the honest summary is that float is an expressiveness gap, not a performance gap, and reach.md §6’s row — “no design question that is known” — still holds. What this measurement adds is that adding it should be justified by what programs become writable, and not by a promise that they will get faster, because on evidence they will not.


5. What this does not measure

  • Two kernels. Compute-bound and memory-bound, one each. Not a suite, and not startup time, code size, compile time or anything allocating in a loop.
  • One machine, one afternoon, on the compilers that happened to be installed. scripts/three.py is committed so the table can be re-measured rather than believed.
  • Nothing about safety. Every one of these programs is correct in all three languages. The interesting comparison — what it costs to write the wrong program — does not fit in a benchmark.
  • No I/O. examples/base64 and examples/sort would have made better-looking programs to compare, and the comparison would have been against three different buffering strategies.

6. Open

QuestionWhy it waits
Anything cancho does betterAnswered — docs/purity.md. There is exactly one candidate and it is structural: the row is a checked purity proof, which C can only promise and Rust cannot state. Measured at 1.94× (CSE) and 158× (hoisting a loop-invariant call), on 35% of the functions here, and collected by nothing today
An LLVM backend§3 makes this falsifiable: the claim is that 1.6× is Cranelift. The README already commits to “Cranelift for dev, LLVM for release”, and this is the number that says what the second half is worth
jo instead of seto/test/jneoverflow-cost.md §3.4. Three instructions for one on every checked operation, and this kernel does seven per iteration — the cheapest place to look for part of the 1.6×
float§4. Now with an argument attached: expressiveness, not speed, and the design question is which of IEEE-754’s corners (NaN ordering, reassociation, -0.0) this language defines rather than inherits
A third kernel that allocates§5. Both of these run in a fixed footprint, so nothing here tests heap.md’s allocator against malloc or Rust’s

7. The suite

ProgramMeasures
benches/three/mandelbrot.{cho,c,rs}Compute-bound, identical line by line, five builds, one checksum
benches/three/mandelbrot_f64.{c,rs}§4: the row cancho cannot enter
benches/three/sieve.{cho,c,rs}Memory-bound, and §3’s warning about reading it too hard
scripts/three.pyBuilds all of them, refuses to time builds that disagree, prints the tables above
TestShows
the_three_language_benchmarks_agreeEvery build still computes the same checksum, which is what makes the timing meaningful. Not a timing gate — wall-clock in CI is noise

What a checked row is worth

Status: measured, and unspent.

The question is whether there is anything cancho can do better than C and Rust, rather than 1.6× worse (against-c-and-rust.md). There is exactly one candidate, it is structural rather than a matter of tuning, and this document measures it.

The answer: the effect row is a checked purity proof. C can only promise the same fact, unchecked; Rust cannot state it at all. On the loop measured here that fact is worth 158×, 35% of the functions in this repository qualify — and nothing collects it today.


1. The fact the other two cannot have

A function’s row is checked exact in both directions (linearity-and-effects.md §7): performing an undeclared effect is an error, and declaring one you never perform is also an error. So [] is not a hint. It is a proof, produced by the type checker, present on every function in the language whether or not anybody wanted it.

What the other two have:

Can it state purity?Is it checked?
canchoYes — the row, on every functionYes, by the type checker
CYes — __attribute__((const)) / ((pure))No. A wrong one miscompiles silently, with no diagnostic ever
RustNo. There is no purity attribute—

Rust’s position is worth being precise about, because it is surprising: LLVM infers readnone for small functions within a codegen unit, and that inference is why against-c-and-rust.md measured 1.6× rather than something worse. What Rust has no way to do is declare it, so the inference stops at a crate boundary without LTO. There is no stable #[pure], and the effect systems proposed for Rust have not landed.

C’s const is the interesting comparison. The attribute exists, it works, and nobody writes it — because writing it is a promise nothing verifies. That is the same attribute this language gets for free and cannot get wrong.


2. What the row proves, exactly

A function is pure when its performs row is empty and no parameter reaches a unique reference.

Both conditions are load-bearing.

The row alone is not enough. std.vec’s set declares [] and writes through &!v Vec[T]. Memory written through a reference is not an effect in this model — effects are capabilities — so a row of [] says nothing about it. The second condition is what rules it out.

And those two are the whole list, which took checking rather than assuming:

  • A function cannot allocate into a caller’s arena. alloc only works inside a region r { .. } block that is lexically open, so fn build[&r](n: int) -> [] int { alloc_slice[r](...) } is refused — “r is not an arena open here”. Allocation is always local and dies with the block.
  • A shared reference is read-only, and borrow freezes what it borrows, so nothing can write the data out from under a second call during the region.
  • An owned res argument cannot be supplied twice, so the case where purity would be unsound is the case linearity already forbids.

Func::is_pure is those two conditions and nothing else, and purity_is_the_row_plus_what_a_reference_may_do checks it on six cases chosen to break it.

2.1 How many functions qualify

Every program in the repository, counted:

76 pure of 217   (35%)

Ranging from 0 of 2 in hello.cho to 15 of 26 in rational.cho. It is not a curiosity — it is a third of the code.


3. But purity is not “safe to hoist”

This is where a trapping language pays for one of its own commitments, and the two halves of the prize are very different sizes.

A pure function here may still trap: + traps on overflow (defined-behaviour.md §2.1), an index traps out of range, a shift traps past the width. Moving a call out of a loop that runs zero times invents a trap that the program would not have taken. So:

TransformationNeedsMeasured worth
CSE — two identical calls in one iteration become onepurity alone1.94×
Hoisting — a loop-invariant call leaves the looppurity and “does not trap”158×

Both numbers are the same loop, the same function, in C, with and without __attribute__((const)); the CSE row varies the argument so hoisting is impossible and only the redundant second call can go.

So the headline 158× is not freely available to this language, and saying otherwise would be the kind of claim this project exists not to make. What is freely available is the 1.94×, which is still a doubling that neither C-as-people-write-it nor Rust can reach across a compilation boundary.

Recovering the rest needs a second predicate — pure and cannot trap — which is checkable (a body using only wrapping_*, comparisons and control flow cannot trap) and is filed in §6 rather than guessed at here.


4. Nothing collects it, and that is on purpose

$ python3 scripts/three.py        # the `purity` group

cancho  knows, cannot spend      0.2847s
C        knows nothing            0.1852s
Rust     cannot be told           0.1845s
C        __attribute__((const))   0.0012s

All four print the same checksum. cancho is 1.54× uninformed C, which is the backend gap against-c-and-rust.md already measured — and the 158× sits there unspent.

Cranelift cannot express it. There is no call attribute for “no side effects” in cranelift-codegen 0.121; readonly is a flag on load instructions, not on calls. So there is nothing to emit.

Checked against the source (#68): backend-limits.md §1.2. It is one level lower than a missing field. ExtFuncData is { name, signature, colocated } and Signature is { params, returns, call_conv }, so there is nowhere to write one — and a field would not be read anyway, because inst_predicates.rs answers the question with opcode.is_call(), unconditionally, before anything could be consulted. The clause beside it is opcode.can_trap(), which is what check-cost.md measured from the outside: this document’s 158× and that one’s 3.35× are two clauses of the same four-line function.

And writing our own pass is excluded on purpose. README.md’s non-goals list “an own optimiser”, and the backend plan is “Cranelift for dev, LLVM for release”. A purity-driven CSE pass in this repository would be a middle-end by another name, and the reason not to build one is the reason the non-goal exists: it is the work that turns three months into three years.

What this document changes is that the exclusion is now informed. The list said “yes, later” without knowing what later was worth. It is worth 1.94× on every redundant pure call, 158× where the call is also loop-invariant and cannot trap, on 35% of the functions here.

4.1 What would collect it

LLVM has exactly the vocabulary: readnone for §3’s CSE row, plus willreturn and nounwind for the hoisting row. The row and the parameter check produce both facts with no analysis at all — the information a C compiler must infer, or a C programmer must promise, this language simply has.

4.2 And there is no cancho program that can show it

This document overstated its own conclusion on the first pass, and the correction is the interesting part.

The draft said the LLVM backend would be “the one place this language has something to give an optimiser that the optimiser cannot get anywhere else.” True of the language. Not reachable by any program written in it today, for a reason that has nothing to do with purity:

cancho has no separate compilation. A program is the set of files named on the command line, parsed into one AST (many-files.md §2). There are no libraries, no import of a compiled unit, no linking of two cancho objects. So there is no boundary for a purity fact to survive — and §1’s whole argument was about what survives a boundary.

Give that whole program to LLVM and LLVM sees every body. Its function-attrs pass infers readnone itself, for the same functions, without being told. The row would be correct and redundant.

What is left is narrower and worth stating exactly, because it is not nothing:

  • LLVM’s inference is a conservative analysis that gives up — on recursion, on large bodies, on anything reached indirectly. The row never gives up, because it was checked rather than inferred.
  • The row is available before codegen, to any consumer, including tools. cancho authority prints it today and no optimiser is involved.

But the 158× in §4’s table came from a benchmark built with a deliberate compilation boundary, in C, because that is the only way to show the effect at all. cancho cannot construct that program.

So the honest conclusion is conditional: the row is a real advantage over C and Rust if cancho ever gains separate compilation or a library model — which many-files.md §2.1 defers on purpose — and is mostly redundant with LLVM’s own inference until then. That is a reason to file this and move on rather than to build on it, and ROADMAP.md says so.


5. What this does not claim

  • cancho is not faster than C or Rust today, anywhere measured. It is 1.6× slower (against-c-and-rust.md), and the purity benchmark agrees at 1.54×.
  • The 158× is one loop, chosen to make the effect visible. A real program with one redundant pure call in a hot loop gets a doubling of that call, not of itself.
  • Inlining already wins the small cases. Everything here is about what survives a boundary LLVM will not cross: a large function, a separate compilation unit without LTO, recursion. Where inlining reaches, C and Rust already have this and pay nothing for it.
  • And cancho has no such boundary (§4.2). The measurement is real, the advantage is real, and no program in this language can exhibit it until the compilation model changes.
  • §4.2 was right about reordering and wrong to generalise. compile-time.md found an axis this document did not consider: the row also licenses evaluation, and evaluation needs no boundary at all — only purity and constant arguments. cancho now folds a pure call on constants, on the strength of the same is_pure this document defines, and it does not need anything LLVM cannot see past. The band where it beats C is narrow (clang inlines and folds the easy shapes; it gives up on recursion), and it is not nothing, which is more than §4.2 allowed.
  • __attribute__((const)) is a fair comparison and a flattering one. It is the strongest form (no memory reads at all); a function reading through a shared reference would be ((pure)) in C, which permits less.

6. Open

QuestionWhy it waits
Separate compilation§4.2, and it is now the precondition rather than a convenience: without a compilation boundary there is nowhere for a purity fact to be worth anything. many-files.md §2.1 lists the three questions it defers — path resolution, cycles, where a library lives — and none has become easier
Pure and cannot trap§3. Checkable — a body of wrapping_*, comparisons and control flow has no trapping operation — and it is what separates 1.94× from 158×. The awkward part is that it is a property of a body, where purity is a property of a signature, so it does not survive a separate compilation the way the row does
An LLVM backend that reads it§4.1. The row already produces readnone; this is the first argument for the backend that is about capability rather than speed
Nothing collects itPartly false now. compile-time.md collects it — not as an attribute for an optimiser, but as the gate on compile-time evaluation. 15 calls in this repository are evaluated on the strength of the row alone
Purity in the hashA function’s row is already in its SigId, so purity is derivable from the hash without the body. Whether a consumer should be told is a question about what the content-addressed store promises
__attribute__((const)) emissionIf cancho ever emits C rather than objects, the row could be written out as the attribute — where it would be, for once, a checked one

7. The suite

TestShows
purity_is_the_row_plus_what_a_reference_may_do§2’s predicate on six cases, including the two a one-condition rule gets wrong
the_three_language_benchmarks_agreeThe four purity builds compute the same checksum, which is what makes §4’s table a comparison
ProgramShows
benches/three/purity.{cho,c,rs}§4: the same loop, and the fact only one of the three can state
cancho authority --output json"pure" — which functions the checker proved, and §2.1’s count

Layout

Status: measured, and mostly answered “no”.

ROADMAP.md listed struct layout as a place cancho could go past C: “packing reaches C; array-of-structs to struct-of-arrays goes past it.” Half of that is right and the interesting half is wrong.

Packing is real and this repository has nothing to spend it on (§2). Transposing does not go past C, because C gets the same 1.38× from it that cancho gets (§3) — measured, both directions. So this document is the measurement, the deferral with its trigger, and the correction.


1. What the layout is today

defined-behaviour.md §5: a value is scalarised into leaves, and where it has to live in memory each leaf takes 8 bytes. A byte slice is the one exception, packed one per byte, so that a string is something C could read.

struct Rgb { r: byte, g: byte, b: byte }     // 24 bytes. C's is 3.

Measured rather than derived: a 64 KiB arena holds 2730 of them, which is 64 × 1024 / 24.

The same section already says the important thing about changing it:

It is not yet a stability contract. Nothing depends on it across processes: no aggregate crosses the FFI boundary (§8.4 refuses one), nothing is serialised, and layout does not reach any hash.

So the compiler may pack, reorder and transpose whenever it likes. The question this document asks is not may we but is it worth anything.


2. Packing: real, and nothing here can spend it

2.1 What it is worth where it applies

A memory-bound traversal of 4 000 000 structs, eight passes, best of five. The two rows differ only in the field type:

structcanchoC -O2ratio
{ r, g, b: byte }178 ms32 ms5.6×
{ r, g, b: int }174 ms82 ms2.1×

Read those two rows against each other, because that is where the answer is. cancho’s own time barely moves — 178 against 174 — because its layout does not change: 24 bytes either way. C’s time moves by 2.6×, because C’s layout moves by 8× — 3 bytes against 24.

So on this shape the layout costs about 2.6×, and it is the whole of the difference between a 2.1× gap and a 5.6× one.

2.2 And this repository does not contain that shape

Every struct field declared in tests/accept/, examples/ and std/:

count
int60
bool3
everything else (references, boxes, type parameters, other structs)20
byte or bool — the only ones packing shrinks3 of 83

And the one real struct that has a narrow field would not shrink at all:

struct Entry { generation: int, live: bool, value: int }    // examples/slab/

Packed and aligned that is 8 + 1 + 8 rounded back up to 24 — exactly what it costs now. Packing pays when narrow fields outnumber wide ones, and an int next to a bool pays for the bool twice over.

The honest summary is not “packing is worthless”. It is:

Packing is worth up to 2.6× on a struct that is mostly byte or bool, and no program in this repository is one.

That is purity.md §4.2’s lesson applied before the code rather than after it: a transformation nothing can exhibit is a transformation with no test.

2.3 So it is deferred with a trigger rather than built

§4 is the instrument. cancho layout reports every type’s size and what it would be packed, so “would this help my program?” is a command rather than a memory — and this deferral has a falsifier instead of a promise. The first program here with an image, a packet header or a token stream in it will make the report say so.


3. Transposing: a no, and the numbers say why

Array-of-structs to struct-of-arrays is the transform C cannot do for you: struct layout is part of C’s ABI, so a C programmer who wants it rewrites the program. cancho promises no layout, so a compiler could do it silently. That was the argument for it going past C.

The argument does not survive measurement. A loop touching one field of three, 4 000 000 elements, eight passes, both transposed by hand:

array of structsstruct of arraysgain
cancho125 ms87 ms1.43×
C -O272 ms55 ms1.31×

The transform is worth about the same in both languages. It does not move cancho past C; it moves cancho and C along together, and the gap between them stays what it was.

Two reasons the memory-traffic argument oversells it:

  • The naive model says reading 8 bytes per element instead of 24 should be 3×. It is not, because at these sizes both versions are streaming from RAM and the loop is bound by its own instructions — a bounds-checked load, an add and a compare — rather than by bandwidth.
  • Where the model would be right is a vectorised loop, and overflow-cost.md §3.2 already established that cancho does not vectorise: a trapping add is not reassociable. So the half of the prize that needs contiguity-plus-SIMD is a half this backend cannot collect even if the layout were perfect.

And the cost is not small. Choosing AoS or SoA per type needs to know which fields each loop touches, which is a whole-program analysis this compiler does not have and would have to grow — for 1.4× on a shape where C gets 1.3× by hand.

So: no. Not “later”, not “when the backend is better” — the measurement says the transform is not a cancho advantage at all, and the roadmap entry claiming it was is corrected rather than deferred.


4. cancho layout

$ cancho layout examples/slab/slab.cho examples/slab/main.cho --std
type                     leaves    size   packed   stride
Gen                           2      16       16       16
Entry                         3      24       24       24
Slab                          3      24       24       24
Found                         2      16       16       16
Buffer                        3      24       24       24

Every packed equals every size, which is §2.2 as a command rather than as a paragraph: this program has nothing to gain. The same report on a struct that does:

$ cancho layout rgb.cho --std
type                     leaves    size   packed   stride
Rgb                           3      24        3       24
Buffer                        3      24       24       24

packing would save 21 bytes per copy across these types (`docs/layout.md` §2)
  • leaves is the scalarised count defined-behaviour.md §5 defines.
  • size is what it costs in memory today: leaves × 8, except byte.
  • packed is what it would cost with each leaf at its natural width and fields reordered widest-first, aligned. When these two columns differ, §2 applies to your program.
  • stride is the distance between elements of a [T], which is the number a traversal actually pays.

It is a report rather than a flag because there is nothing to turn on: §2 did not change the layout, it measured it.


5. What this does not touch

  • The byte exception stays. [byte] is packed one per byte and that is a promise strings.md §3 makes for a reason: a string has to be something C could read, and examples/base64/ and examples/serve/ both depend on it.
  • Field order stays declaration order. Reordering is only worth anything once leaves have different widths, so it arrives with packing or not at all.
  • Nothing becomes a contract. defined-behaviour.md §5’s paragraph stands unchanged: layout is deterministic, not stable, and the day something depends on it across processes is the day it moves to canonical-ast.md.

6. Open

QuestionWhy it waits
Packing narrow leaves§2.3. Deferred with a trigger, not a date: when cancho layout’s size and packed columns differ on a program someone cares about
A vectoriser, or a backend with one§3’s second reason. It gates the half of the transposing prize that is real, and it is the same wall overflow-cost.md §3.2 hit — so it is one problem wearing two hats, not two
Enum tag widthA tag is one leaf, so 8 bytes, whatever the variant count. Packing would make it 1 for any enum under 256 variants. Folded into §2 rather than separate: it is the same change and the same trigger
Layout as a contract§5. Nothing needs it yet. reach.md §3.2 keeps aggregates out of FFI, which is what keeps it free

7. The suite

TestRule§
the_layout_report_says_what_a_type_costsSizes, strides and the packed column agree with what the backend emits4
BenchShows
benches/layout/§2’s two rows and §3’s four, as programs rather than as numbers in a document

Against a wider set

Status: measured, twice — once on Cranelift, once on LLVM.

against-c-and-rust.md reported 1.6× against C, from two kernels. Three more, from the Computer Language Benchmarks Game, said the gap was 1.17× to 2.58× on --backend cranelift — and that it tracked something legible rather than being a constant with noise on it (§4). Two more Game programs, fasta and reverse-complement, widened that range in a direction the others did not: fasta measured 0.54× — faster than C — because past a certain point the comparison stops being about the backend and starts being about the I/O call underneath it (§7). All of that was Cranelift.

§8 re-runs the same five kernels now that --backend llvm is the default (llvm-backend.md, #127) — scripts/game.py takes no --backend flag, so it was always going to measure whichever backend a plain cancho build uses, and that stopped being Cranelift the day the default flipped. The high end came down hard, as §4’s own falsifier predicted: spectral-norm’s 2.58× is now 1.27×–1.46×. The low end did not fall the way a “LLVM is strictly better” story would predict — fasta’s own 0.54× is now 0.89×–0.90×, still faster than C, but by less, not more. §8 has the numbers and why.

This document is also the answer to “are there official benchmarks?”: no. §2 is what exists, what it is worth, and which rules were borrowed from where.


1. Why two kernels was not enough

against-c-and-rust.md measured Mandelbrot (compute) and a sieve (memory) and reported 1.69× and 1.56×. Two numbers that close invite a single headline, and it got one.

Adding three programs that stress different things turns that headline into a range:

what dominatescancho / C
binary-treesmalloc and free1.17×
fannkuch-reduxinteger arrays, branches1.32×
sievememory and cache1.56×
Mandelbrotfloat compute1.69×
spectral-normfloat compute with a division in the inner loop2.58×

Three of those five are new here. The other two are benches/three/, unchanged.


2. There is no official benchmark, and what that means

No standards body blesses a cross-language benchmark. What exists:

what it isworth here
The Computer Language Benchmarks GameThe de facto citation: ~10 programs, ~30 languagesIts programs are a good source of workloads. Its numbers are not a target — entries are hand-tuned by motivated contributors, and several leading ones are hand-written SIMD
SPEC CPUGenuinely official, licensed, C/C++/FortranBenchmarks CPUs and compilers, not languages. Not applicable
are-we-fast-yetMarr et al., DLS 2016The methodology, which this project had already arrived at independently
TechEmpower, PolyBench, DaCapo, CoreMarkWeb, numeric kernels, JVM, embeddedDomain-specific; none reaches this language yet

So the rules here are are-we-fast-yet’s, not the Game’s:

The same algorithm, line for line. The same output. Otherwise implement idiomatically.

against-c-and-rust.md already said “the same algorithm, line for line, not the same task” — which is that rule, reached by the same reasoning and a decade later. What the Game contributes is the programs and, more usefully, the expected outputs: every one of the three below is checked against a value the Game publishes, on every run.

2.1 Which programs were reachable, and which were not

fannkuch-reduxPorted. Integer arrays and nothing else
spectral-normPorted, after writing sqrt (§3)
binary-treesPorted. box/unbox over a Heap
MandelbrotAlready in benches/three/
n-bodyReachable now that §3’s sqrt exists; not done
fasta, reverse-complementPorted — §7. bulk-io.md §4.1 withdrew the guess that they would gain a lot from bulk output, and the measurement says the guess was wrong in both directions: fasta is not nearer base64’s 1.59×, it comes out faster than C, at 0.54×; reverse-complement, which cannot avoid getchar on its read side, lands at 1.19×, inside the ordinary range
k-nucleotideNeeds a hash table. Writable, not written
pidigitsNeeds bignum division, which std.bignum deliberately does not have (float-printing.md §3.2: the one quotient a printer needs is a single digit)
regex-reduxNeeds a regex engine. Out of reach

Five of ten, which is itself a measurement of the language’s reach.


3. What porting them needed that did not exist

sqrt. floating-point.md §7 leaves std.math over floats open, so spectral.cho carries its own: Newton, which examples/newton.cho already showed converges to the limit of binary64. The C program uses the same hand-written Newton rather than libm’s sqrt, because otherwise the comparison would be between an intrinsic and a loop.

Superseded by #62. sqrt is a builtin now — one instruction, no capability needed — and spectral.cho was one of the two programs whose hand-rolled root motivated it: measured against a correctly-rounded root, its own twenty-step Newton loop was wrong on 58.4% of values and off by 143 orders of magnitude on a large one (float-math.md §2). spectral.cho calls the builtin now; the comparison this paragraph describes as avoided is the one that exists today, between cancho’s intrinsic and C’s still hand-written Newton (spectral.c keeps it, on purpose, for its own reasons). It does not bias the numbers below: the call happens once, outside the O(n²) loop this benchmark actually measures. The paragraph stays as it was written; the comparison it describes does not hold since #62.

Fixed-precision printing. The benchmark’s answer is nine decimal places. std.fmt.float_into prints the shortest decimal that round-trips — 1.2742199912349306e0 — and a stated precision is float-printing.md §7’s open row. So spectral.cho carries a nine-line formatter. Small, and a second vote for that row.

Nothing else. fannkuch-redux and binary-trees needed no language feature that was missing, which is the more interesting half: a permutation benchmark and an allocation benchmark both fell out of what was already there.

3.1 And one compiler bug, found by a program nobody had written

spectral.cho opens a region for its vectors, closes it, and later opens another inside a borrow to format the answer. That crashed the compiler.

Arena numbers are handed out in the order the lowering meets region blocks, but the backend kept open arenas on a stack. Two sibling regions get numbers 0 and 1, and the second opens after the first has closed — so the stack had length 0 where index 1 was wanted. In debug it tripped an assertion that had documented the wrong assumption since arenas landed; in release it indexed out of bounds.

No program in the repository had two region blocks side by side. The fix is three lines — index by arena number, None where not open — and sibling_regions.cho is the fixture that would have caught it.

This is porting.md’s lesson again: the bugs are found by the programs nobody thought to write.


4. What the range means

The gap is not a constant, and the thing it tracks is legible:

The gap is how much of the run is spent in code Cranelift generated.

  • binary-trees, 1.17× — the program is mostly inside malloc and free, which is the same libc in both builds. The backend has less of the run to be slower at.
  • fannkuch-redux, 1.32× — integer array work, bounds-checked, with unpredictable branches. The branches are the processor’s problem in both languages.
  • spectral-norm, 2.58× — a tight float inner loop with a division, which is exactly where a vectoriser earns its keep and where overflow-cost.md §3.2’s finding bites: an observable trap is not reassociable, so the reduction cannot be split across lanes.

Which is the same conclusion against-c-and-rust.md reached from two points — the 1.6× is Cranelift against LLVM, not the price of safety — now with the shape of the dependence rather than one number. The falsifier stands and gets sharper: if an LLVM backend lands and the range does not collapse toward its low end, the claim was wrong.

Answered, §8: the high end collapsed, the low end did not. An LLVM backend landed and became the default (#127); re-running these same five kernels against it moves spectral-norm from 2.58× to 1.27×–1.46×, confirming this section’s own claim — the gap really was how much of the run Cranelift generated. But binary-trees (malloc-bound, “less of the run to be slower at”) barely moves, 1.17× to 1.20×–1.34×, and fasta (the one point below 1.0×) moves the wrong way, 0.54× to 0.89×–0.90× — still faster than C, less so. §8 is why: this section’s own explanation was correct for the kernels it was built from, and incomplete for the two it did not have yet.

4.1 Report the spread, or the number is not checkable

scripts/three.py reports best-of-7: the minimum. That suppresses OS noise, which is why it was chosen, and it throws away the distribution — so none of the published ratios carried an interval.

scripts/game.py reports the median and the range, per build:

program            N             cancho               C -O2   ratio
                        median  (spread)    median  (spread)
fannkuch          11      3978.2ms ( 2.9%)      3007.6ms ( 1.3%)   1.32x
spectral        2000       598.2ms (11.1%)       231.8ms ( 5.2%)   2.58x
binarytrees       18      1439.5ms ( 1.6%)      1228.3ms ( 1.4%)   1.17x

spectral-norm’s 11.1% is the row worth looking at: it is the noisiest build here, and a best-of-N report would have shown none of that.


5. Open

QuestionWhy it waits
n-body§2.1. Reachable now that sqrt exists, and it would add a second float-heavy point beside spectral-norm’s own (now much smaller) gap
fasta and reverse-complementAnswered, by measuring — §7. fasta is faster than C; reverse-complement is not
Why fasta moved from 0.54× to 0.89×–0.90× under --backend llvm§8. §7’s own “this is an I/O-call-shape fact, not a backend fact” would predict no move at all, since fasta.c is unchanged; objdump on the per-line loop around io.write_all, the way §7.16 of llvm-backend.md already did for spectral.cho, is the next thing that would actually answer it rather than guess
A quieter host for revcomp§8. 97%–203% spread on this container swallows any real signal; the three re-runs bracket 1.05×–1.60× without narrowing it
std.math over floatsAnswered, and stale here since #62 — sqrt is a builtin (float-math.md), closing the capability question this row named; the n-body row above already assumed it. exp/log/pow followed (#95, float-math.md §7). Only sin is still open, for lack of an asker
A stated precision in std.fmt§3. float-printing.md §7’s row, with a second caller now
Confidence intervals rather than a range§4.1. The spread is honest and it is not a statistical model. Georges et al. (OOPSLA 2007) is the standard method; nothing here needs that rigour until a change is claimed on a difference smaller than the spread

6. The suite

TestRule§
benchmark_game_programs_print_the_published_answerfannkuch, spectral, binarytrees, at the N the Game publishes a value for2
fasta_and_reverse_complement_print_the_published_answerfasta at N=1000; reverse-complement fed that output, both against the Game’s own reference files7
FixtureRule§
sibling_regions.choTwo region blocks side by side, which crashed the compiler until this slice3.1
fasta-1000.txt, revcomp-1000.txtThe Benchmarks Game’s own N=1000 reference output for fasta, and for reverse-complement fed that file as input — fetched from benchmarksgame-team.pages.debian.net and reproduced as fixtures rather than downloaded at test time7

7. fasta and reverse-complement, measured

§2.1’s last row, and §5’s answer: the Game’s own spec was reachable this session (benchmarksgame-team.pages.debian.net was not blocked), so both programs are ported and checked byte for byte against the Game’s own N=1000 reference output — not a value this repository derived, one downloaded from the Game’s own site and committed as benches/game/fasta-1000.txt and benches/game/revcomp-1000.txt.

fasta.cho draws one linear-congruential step and does one linear search over a cumulative-probability table per byte — the two things the benchmark’s own description forbids optimising away — into a 60-byte line buffer, flushed with one io.write_all per line (bulk-io.md’s primitive). revcomp.cho reads with getchar, one byte at a time: bulk-io.md §3.3 is why there is no bulk read to reach for, so the whole read side stays exactly as expensive as standard-input.md already priced it. Both write sides are the same prepared-line buffer.

program            N             cancho               C -O2   ratio
                        median  (spread)    median  (spread)
fasta         1000000       121.8ms ( 4.6%)       224.9ms ( 7.4%)   0.54x
revcomp       1000000       208.3ms (16.6%)       174.5ms (15.8%)   1.19x

fasta is faster than C, and it says nothing new about the backend. fasta.c writes with putchar, one libc call per byte, because that is what an ordinary C program computing this algorithm writes and benchmarks-game.md §2’s rule is the same algorithm, not a hand-tuned one. fasta.cho cannot write that way at all — there is no per-byte Io primitive cheap enough to reach for, only io.write_all, so the ordinary cancho program is the bulk one. The 0.54× is not Cranelift outrunning cc -O2; it is one fwrite-shaped call every 60 bytes outrunning one putchar-shaped call every byte, which bulk-io.md §1 already measured in isolation (11× in C’s own numbers) and which shows up here because the language leaves no slower way to write.

reverse-complement is the control. Its read side is getchar either way it could be written — cancho has no bulk read (bulk-io.md §3.3) — so nothing shields it from the ordinary backend gap, and 1.19× lands inside the range the other five programs already described. The 16.6%/15.8% spread is the widest in the suite, which benches/three reserved for float compute (spectral-norm’s 11.1%); here it is a five-record loop dominated by malloc-sized buffer growth rather than a steady inner loop, and neither language is quiet about it.

So the range this document opened with is no longer 1.17×–2.58×; it is 0.54×–2.58×, and the new low end is not evidence the backend closed any gap. It is evidence that “the same algorithm” can leave two languages with genuinely different cheapest ways to do the same I/O, and when it does, the ratio measures that instead.


8. Recounted: --backend llvm is the default now, and so is this range

Everything above §8 was measured on --backend cranelift, whichever backend happened to be the default at the time — scripts/game.py passes no --backend flag to cancho build, by design, so it has always measured “the compiler,” not one specific backend. That stopped meaning Cranelift the day #127 flipped the default to LLVM, and nobody had gone back and re-read what the script now reports until this slice did.

Three full runs, scripts/game.py --rounds 9, this session:

program        run 1   run 2   run 3
fannkuch       1.02x   1.01x   0.99x
spectral       1.27x   1.35x   1.46x
binarytrees    1.20x   1.25x   1.34x
fasta          0.90x   0.89x   0.90x
revcomp        1.60x   1.27x   1.05x

Four of five kernels are stable across runs. revcomp is not: its own spread within a single run reads 97%–203% here, wide enough that no single number below is worth trusting to two significant figures — this container has 4 cores and a low load average, so the jitter reads as scheduling noise rather than contention, but a quieter host would be needed to pin it down further than “somewhere around 1.0×–1.6×.” Every other row’s own spread is the ordinary 10%–40% this document has reported since §4.1.

The high end moved exactly the way §4’s falsifier called it. spectral-norm was the kernel §4 named as the reassociation wall’s clearest case — a tight float loop with a division, the shape a vectoriser earns its keep on — and it dropped the most, 2.58× to 1.27×–1.46×. llvm-backend.md §7.16 already measured this kernel in isolation and found real vectorisation (62 SIMD instructions, objdump-confirmed); this is that same fact showing up in the wider suite’s own numbers rather than a standalone reading.

binary-trees moved the least, for the reason §4 already gave. §4’s own explanation was “mostly inside malloc/free, the same libc either way — the backend has less of the run to be slower at,” and 1.17× to 1.20×–1.34× is that explanation holding: a program dominated by a shared library call has little room for a faster backend to show up in, whichever backend it is.

fasta is the real surprise, and it argues against its own headline. §7’s own reading was that 0.54× “says nothing new about the backend” — it measured io.write_all’s bulk path beating fasta.c’s putchar- per-byte loop, a fact about the libc call shape the two programs use, independent of which backend compiles either one. If that reading is right, the ratio should not have moved when only the cancho side’s backend changed — fasta.c is still cc -O2, unchanged, call for call. It moved anyway, from 0.54× to 0.89×–0.90×: still faster than C, markedly less so. The likely account, not yet confirmed the way §7’s own claim was: LLVM’s -O2 optimizes the scalar per-line loop around each io.write_all call differently than Cranelift did — nothing here has objdump-checked that loop the way §7.16 checked spectral.cho’s — so §7’s own “this is an I/O-call-shape fact, not a backend fact” is probably still the larger effect, and what moved is a smaller backend- shaped one sitting on top of it that this slice did not isolate. Marked open, not answered, in §5.

revcomp’s own noise here means its number cannot confirm or refute anything past “still in the ordinary range.” §7 measured it at 1.19× on Cranelift, inside the range every other kernel described; the three runs above (1.05×, 1.27×, 1.60×) bracket that number without pinning a tighter one.

against-c-and-rust.md’s own two kernels, re-run the same way (scripts/three.py --rounds 9, no --backend flag there either), move further than any of the five above: mandelbrot from 1.69× to 0.95× (two runs, both 0.95×) and the memory-bound sieve from 1.56× to 0.93×–0.95× (two runs) — both now faster than C, not merely closer to it. That document’s own status header carries the correction.

Bench
benches/game/Five programs, each in cancho and C to the same algorithm
scripts/game.pyRuns them against whichever backend cancho build defaults to today, checks the output every time, reports the spread

What every check costs, and the rule underneath

Status: measured, and it falsifies both documents that came before it — including the one that was written to correct the first.

overflow-cost.md §3.2 found that a check costs the vectoriser rather than a branch. It measured the overflow check and wrote “the check”. gpu.md §2.1 caught the over-generalisation, measured the bounds check, found it free at 1.01×, and replaced the claim with a narrower one: what costs the vectoriser is “the overflow check and, as far as anything here has measured, only the overflow check”.

That is also wrong, and wrong in the same way. Of the eight checks this language emits inside a loop body, six take the SIMD count to zero. The overflow check is not special; it was first.

The rule is not about what a check is about. §3 is a pair of programs with the same two comparisons and the same trap, one costing 2.16× and the other 0.99×, differing only in whether the loop already proves the condition. Memory safety is not free. Arithmetic is not expensive. Provable is free, and loaded is not.


1. The checks, counted by reading the backend

Every trapnz/trapz in crates/cancho-codegen/src/lib.rs, grouped by what it guards. Eight can appear in a loop body and are what this document measures; the rest happen once per allocation or per syscall, where a comparison is not what the time goes on.

in a loop bodywhat it tests
a + b, a - b, a * bthe operation overflowed
-xthe one value with no negation
a << b, a >> bthe amount is outside 0..64
a / b, a % bone comparison each, and not the same one — see §5
s[i]the index is inside the slice
s[lo..hi]hi > len, and lo > hi
byte_of(n)n is outside 0..255
truncate(f)f is NaN, infinite, or out of range

The last row was written int_of(f) until emitted-checks.md §3 read the binary. int_of widens a byte and emits no check at all; the float conversion is truncate, and five documents had copied the wrong name from this one.

once, not per elementwhat it tests
box, box_slice, a new arena chunkmalloc answered null
an arena bumpthe chunk cannot spare the bytes, or the size wrapped
alloc_slicethe count is negative, or count * stride overflowed
a path to fs_read/fs_writelength, prefix, .., and the prefix boundary
arg(i)the index is inside argc

band, bor and bxor emit nothing at all: bitwise.md §4 established they cannot overflow, and the backend says so by having no arm for it.


2. The measurement

benches/guards.c is one kernel per check, each written so the unguarded form is as vectorisable as the instruction set allows — otherwise the guard would be measured against a loop that was scalar anyway. scripts/guards.py builds each twice, counts the packed instructions in the emitted run with objdump, and times both interleaved. clang 18.1.3, minimum of nine runs.

Baseline -O2 (x86-64, SSE2 — no -march):

checkpacked offpacked onoffoncost
s[i] bounds8862.7 ms62.9 ms1.00×
a / b00536.6 ms538.0 ms1.00×
a << b180133.3 ms164.0 ms1.23×
a + b overflow8062.7 ms90.4 ms1.44×
byte_of(n)11061.3 ms117.2 ms1.91×
-x8063.5 ms121.8 ms1.92×
s[lo..hi]8062.2 ms134.3 ms2.16×
truncate(f)0088.9 ms222.5 ms2.50×

-march=native (this machine has AVX-512), to show the answer is not an artifact of the instruction set:

checkpacked offpacked oncost
s[i] bounds26261.02×
a / b001.00×
a << b2101.15×
a + b overflow2601.40×
byte_of(n)1801.55×
-x2601.82×
s[lo..hi]2602.14×
truncate(f)1303.35×

Same ordering, same six zeros, and the float conversion — which had no SIMD to lose at baseline — becomes the most expensive check in the language once the hardware can vectorise it.

2.1 Two counts, and why this table’s 8 is gpu.md’s 10

gpu.md §2 counted every instruction touching a vector register. That is the right signal for an integer reduction, where nothing else uses xmm, and the wrong one for a float kernel, where scalar double arithmetic uses xmm too — the guarded int_of loop touches more vector registers than the unguarded one while doing no vector work at all.

So scripts/guards.py reports both. On the overflow kernel it prints 10 → 0 by the register count, which is gpu.md §2’s published figure exactly, and 8 → 0 by the packed count — the two extra are the movq pair that moves the accumulator between a general register and xmm. The documents agree; the definitions differed.


3. The control, which is the actual finding

s[lo..hi] costs 2.16× and s[i] costs 1.00×. Both are bounds checks. Both trap. The subslice does two comparisons where indexing does one, so a first guess is that the count of comparisons is what matters.

It is not. Kernel 8 is the same subslice check with the bounds taken from the induction variable instead of from memory:

long lo = w[i] & 1;    long lo = i;
long hi = lo + 1;      long hi = i + 1;
TRAP_IF((unsigned long)hi > (unsigned long)n);
TRAP_IF((unsigned long)lo > (unsigned long)hi);
packedcost
bounds from memory8 → 02.16×
bounds from the induction variable8 → 80.99×

Two comparisons either way. The same trap either way. The same arithmetic either way. The difference is whether the loop the compiler already proved bounded also proves these, and it is the whole difference between free and the most expensive integer check in the table.

That is why s[i] is free, and it has nothing to do with memory safety: i < n is the loop’s own condition, so the check is a branch on a constant, and a branch on a constant is not a branch. Take the same check off the induction variable — which is exactly what a subslice does — and it costs more than the overflow trap.

So the axis is not memory-versus-arithmetic, which is what §2.1 of gpu.md implied and this document had to measure to disprove. The axis is provability.

A check is free when the compiler can discharge its condition from what the loop already establishes. It costs reassociation — and with it vectorisation — when the condition depends on a value loaded from memory, whatever the check is about.


4. “Vectoriser” is too narrow as well

The baseline truncate(f) row has no packed instructions either way and still costs 2.50×. Nothing was vectorised, so nothing vectorisable was lost. Reading the two loops says what was:

unguarded                          guarded
cvttsd2si (%rsi,%r9,8),%r10        movsd  (%rsi,%rdi,8),%xmm2
cvttsd2si 0x8(%rsi,%r9,8),%r11     ucomisd %xmm0,%xmm2
add    %rax,%r10                   jb     <ud2>
cvttsd2si 0x10(%rsi,%r9,8),%rbx    ucomisd %xmm2,%xmm1
add    %r11,%rbx                   jb     <ud2>
cvttsd2si 0x18(%rsi,%r9,8),%rax    cvttsd2si %xmm2,%r8
add    %r10,%rbx                   add    %r8,%rax
add    %rbx,%rax                   inc    %rdi
add    $0x4,%r9

The unguarded loop is unrolled four wide with four independent accumulator chains, which is the same reassociation a vectoriser needs, spent on scalar instruction-level parallelism instead of lanes. The guarded loop is one element, one accumulator, and two compares.

So the cost of a trap is not “the vectoriser”. It is reassociation, and vectorisation is the largest thing reassociation buys rather than the only one. A machine with no SIMD at all would still pay for these checks, which is worth knowing before concluding that a narrower instruction set makes the guarantee cheaper.


5. Division is free, for a third reason again

a / b costs 1.00× on both instruction sets, and the backend is why: BinOp::Div lowers to a bare sdiv and BinOp::Rem to a bare srem, with no comparison emitted at all. Cranelift says so in a boolean — Opcode::Sdiv.can_trap() is true where Opcode::SaddOverflow’s is false (backend-limits.md §2). Cranelift’s sdiv/srem trap on a zero divisor and on int::MIN / -1 because the hardware does — an idiv with a zero divisor faults, and the language gets its guarantee from an instruction that was going to fault anyway.

Corrected (#71): emitted-checks.md §4. The paragraph above is a fact about the IR stated as a fact about the emitted code, and the two differ. Both operators emit exactly one comparison, and not the same one: a / b emits test %rsi,%rsi and branches to its own ud2, so a zero divisor is SIGILL rather than the hardware’s SIGFPE; a % b emits cmp $-1 and answers 0, reaching idiv — and the hardware — for a zero divisor. One IR instruction is not one machine instruction.

The 1.00× stands, for a fourth reason rather than the third: not because nothing is emitted, but because one predictable compare is nothing beside a 40-cycle idiv with no packed form to lose.

There is also nothing to lose: integer division has no packed form on SSE2, AVX2 or AVX-512, so the loop is scalar unguarded and scalar guarded. The row is 0 → 0 and 1.00× twice over, and C — where this same program is undefined behaviour rather than a trap — buys its undefinedness nothing.

So the three free checks in this language are free for three unrelated reasons: s[i] because the loop proves it, a / b because the hardware does it, and band/bor/bxor because there is nothing to prove.


6. A constant operand folds the check away

Checked against the compiler rather than assumed. x << 3 and x << k, from cancho build and objdump:

lexs_shift_const:            lexs_shift_var:
    mov  %rdi,%rax               cmp  $0x40,%rsi
    shl  $0x3,%rax               jae  <ud2>
    ret                          mov  %rsi,%rcx
                                 shl  %cl,%rax
                                 ret

The constant amount emits no check at all. This is §3’s rule again at its easiest setting — a condition on a literal is discharged at compile time — and it is why the shift check can only be measured with an amount that comes out of memory.

It also bounds how much of the table a real program pays. byte_of(65), s[0..3] and x << 8 are free; the same operations on loaded values are not.


7. What this means for the thesis

README.md calls defined behaviour a design commitment and overflow-cost.md priced one instance of it. The price is larger and more structural than one bad check:

  • Six of eight loop-body checks make their operation non-reassociable. This is a property of “an operation that may trap on a value the compiler cannot bound”, not a property of addition.

  • The worst is truncate(f) at 3.35×, not the overflow trap. Nothing had looked.

    Corrected (#71): emitted-checks.md §3. Nothing had looked at the emitted guard either. The C kernel tests the range with two ucomisd comparisons on every element; Cranelift emits cvttsd2si plus one cmp $0x1/jno, because the conversion answers a sentinel that rax - 1 overflows on and nothing else. So 3.35× is an upper bound for a guard this language does not emit, and the ranking — that this is the worst check — does not survive. What does survive is the direction: a data-dependent trap on a loaded value does not vectorise, whichever of the two spellings carries it.

  • gpu.md §5.1 asks whether poison — a per-lane flag reduced at the launch boundary — costs less than trapping. That question was about one check when it was written. It is now about six, which makes it the largest open question in the design rather than a GPU detail.

    Answered (#65): poison.md. For five of the six, yes and by a lot — a << b and -x become free, and truncate(f) drops from 3.28× to 1.33×. For the sixth, the overflow check carried by a reduction, poison does not help and is worse, because its condition is not a per-element property: four lanes compute four different partial sums, so the flag would record a different question. The rule there is this section’s rule again one level up — poison rescues a check exactly when the check is about one element.

And it sharpens the roadmap’s vectoriser row. An LLVM backend would vectorise the loops Cranelift leaves scalar — gpu.md §2.3 measured cancho at 2.27× off vectorised C with the trap removed — but it would vectorise them only where the traps are not in the way, and §2’s table is the list of places they are.


8. What this does not say

  • Not that the checks should go. defined-behaviour.md §2.1 is the thesis, and a silently wrong answer is what this language exists not to give. Knowing the price is not an argument for not paying it.
  • Not that these are cancho’s numbers. They are clang’s, on kernels written in C, because Cranelift has no pass that produces SIMD from scalar code (backend-limits.md §1.4) — so the current backend pays none of this and gets none of the benefit either. The table is what a backend with a vectoriser would find, which is the one the roadmap wants.
  • Not a claim about every program. These are loops chosen to vectorise. A program whose time goes on calls, branches or cache misses pays the overflow-cost.md §2 figures instead, which are between −9.1% and +3.6%. The shape of the loop decides which table applies, and that was §2’s finding there and is unchanged.

Does poison cost less than trapping? Measured

Status: measured, and the answer splits exactly where the thesis lives.

gpu.md §4.1 proposed poison — each lane sets a flag, the boundary answers something went wrong — as the way this language’s no-undefined-behaviour claim could survive hardware that cannot stop. §5.1 said it had no number and was measurable on a CPU today; check-cost.md §7 raised it from a question about one check to a question about six.

On a wide vector machine, poison is a large win for every check whose condition is a per-element comparison. Two of them become free, and the most expensive check in the language drops from 3.28× to 1.33×.

On the overflow check carried by a reduction — the check the whole defined-behaviour argument is named after — poison does not help and makes it worse, 1.37× to 1.73×. Four kernels separate why, and it is not the compiler: “no partial sum overflowed” is a claim about one association order, and a vectoriser computes a different one. That check is not a per-element property and no spelling makes it into one.

And on a narrow vector ISA the win disappears entirely: at baseline -O2, poison is worse than trapping in eleven of twelve kernels. The answer is a property of the target, not of the semantics.


1. What poison is here

Mode 2 of benches/guards.c. Instead of

if (condition) __builtin_trap();

the kernel does

bad |= condition;              /* per element */
...
poisoned |= bad;               /* once, after the loop */
if (poisoned) __builtin_trap();

and the operation keeps a defined result where the check would have fired: a masked shift, a truncated byte, a wrapped negation, a clamped conversion. Each of those is one instruction and vectorises, which is part of what is being priced.

The hypothesis is one sentence. A trap is a side effect at a point, so the operation cannot be reordered. A flag OR-ed across elements is a reduction, and reductions reassociate — so the loop should vectorise again.

What it gives up is where. defined-behaviour.md §2.1 says an operation with no right answer stops; poison says it finishes, and something later says that it should not have. The program computes with defined-but-wrong values until the boundary, and the element that caused it is gone. That is a real weakening and §6 is where it is argued rather than waved at.


2. The numbers

clang 18.1.3, minimum of nine runs, scripts/guards.py. Packed SIMD counted in the emitted run with objdump, printed unchecked / trap / poison.

2.1 -march=native — this machine has AVX-512

checkpackedtrappoison
s[i] bounds26 / 26 / 260.95×1.00×
a / b0 / 0 / 01.01×1.01×
a << b21 / 0 / 531.17×1.00×
-x26 / 0 / 381.78×0.99×
byte_of(n)18 / 0 / 461.61×1.23×
truncate(f)13 / 0 / 593.28×1.33×
s[lo..hi]26 / 0 / 432.19×1.98×
a + b overflow, on a reduction26 / 0 / 01.37×1.73×

Five of the six checks check-cost.md found expensive get most or all of their cost back, and the SIMD count says why: the loop vectorises again, harder than it did unchecked, because the flag is extra vector work the unchecked loop did not have to do.

The exception is the last row, and §3 is about it.

2.2 Baseline -O2 — x86-64, SSE2

checkpackedtrappoison
-x8 / 0 / 241.96×1.45×
a << b18 / 0 / 481.27×1.64×
byte_of(n)11 / 0 / 381.79×2.18×
s[lo..hi]8 / 0 / 402.13×2.46×
truncate(f)0 / 0 / 582.47×2.79×
a + b overflow, on a reduction8 / 0 / 01.45×1.76×

Poison vectorises here too — and is still slower. The packed counts go up in every row; the times go up with them. Baseline x86-64 has no 64-bit packed compare, no per-lane variable shift and no packed double-to-int64, so the vector spelling of each condition is an emulation several instructions long, and it costs more than the scalar branch it replaced.

So poison is not a semantic improvement that a compiler cashes in wherever it can. It is a trade that needs a vector unit wide enough and complete enough to take it, and on the narrow one it loses.


3. The one it cannot save, and four kernels to prove it

The overflow check on total += v[i] stays at 0 packed under poison and gets worse. Three controls separate the possible reasons.

kernelthe conditionpacked under poisonpoison
overflowtotal + v[i], builtin01.73×
overflow_carriedtotal + v[i], sign logic02.02×
overflow_eachv[i] + w[i], builtin01.04×
overflow_signsv[i] + w[i], sign logic490.98×

Reading the four rows in order:

It is partly the spelling. __builtin_saddl_overflow is opaque to the vectoriser. A signed add overflows exactly when the result differs in sign from both operands, which is ((a ^ s) & (b ^ s)) < 0 — three XORs, an AND and a compare, every one of them an operation a vector unit has. Written that way the element-wise check vectorises fully and is free: 0.98×, 49 packed, against 0 packed for the same semantics written as the builtin.

That is worth knowing for a language that emits its own IR. cancho lowers + to sadd_overflow plus trapnz; a backend emitting a wrapping add, the sign test and a flag OR would get the check for nothing on an element-wise addition.

And it is partly not the spelling. overflow_carried is the sign test applied to the reduction, and it does not vectorise either. The reason is not in any compiler:

A reduction’s overflow condition is a claim about this association order. Four lanes compute four different partial sums, so “no partial sum overflowed” is a different property after vectorising — sometimes true where the sequential order overflowed, sometimes false where it did not.

A flag cannot fix that, because the flag would be recording the wrong question. The check is not a per-element property, so poison, whose whole mechanism is making a check per-element, has nothing to work with. Paying for the flag and getting no vectorisation is why poison is worse than trapping in that row rather than merely equal.

So the rule, and it is the same shape as check-cost.md’s:

Poison rescues a check exactly when its condition is a property of one element. A condition the reduction carries is not, and no spelling makes it one.


4. What this answers

gpu.md §6 asked one question and it now has three answers, which is more useful than the one it wanted.

answer
Does poison cost less than trapping?On a wide vector ISA, yes, for every per-element check — two become free and the worst drops from 3.28× to 1.33×
Does it save the arithmetic half of the claim?Only element-wise. a + b inside a loop is free under poison, written right. total += a is not, and cannot be
Is it free?No. On baseline x86-64 poison is worse than trapping in eleven of twelve kernels, because the vector spelling of each condition is an emulation

gpu.md §4.1’s three options were: wrapping arithmetic in kernels, poison reduced at the launch boundary, or refusing trapping operations in kernels. Option (2) now has numbers, and they say it is a good trade for element-wise work and no help for a reduction — which is awkward, because a reduction is exactly what a GPU is for.


5. What this does not say

  • Not that cancho should switch to poison. This measures C compiled by clang. cancho emits Cranelift, which vectorises none of these loops (gpu.md §2.3), so today the language would pay poison’s extra work and collect none of its benefit. This is a measurement of what a backend with a vectoriser would find.
  • Not that the trap is wrong. defined-behaviour.md §2.1 is the thesis and a number is not an argument against it. What changes is that the thesis now has a priced alternative rather than an assumed one, and the alternative is worse exactly where the thesis is loudest.
  • Not a claim about every program. These are loops chosen to vectorise. overflow-cost.md §2 measured programs dominated by calls, branches and cache, where the whole effect is between −9.1% and +3.6%.

6. The part that is not a number

Poison trades a stop for a report, and the cost is not in the table.

A trap names the operation: the process dies at the instruction that had no answer, and a debugger has the element in a register. Poison says somewhere in this loop, after the loop has finished writing its output — and the wrong values have been used in the meantime, because the fallback (& 63, & 255, a clamp, a wrap) is defined and silently wrong. strings.md §2 refuses truncation on exactly that ground, and line-reading.md found cut printing 60 KB of the wrong field with exit 0, which is what a silently wrong answer looks like when it reaches a user.

So poison is not a cheaper trap. It is a different failure model, and adopting it would be a change to defined-behaviour.md §2.1 rather than an optimisation underneath it. The honest framing for a future decision:

  • Where a program cannot act on which element failed — a kernel whose whole output is discarded on failure — poison loses nothing it was using, and §2.1’s table is what it buys.
  • Where a program reports, recovers, or debugs, it loses the thing that made the guarantee worth having.

That is a language decision with a measured price on one side and an unmeasured one on the other, which is the right state for it to be in before anyone builds it.


7. Open

QuestionWhy it waits
Does the sign spelling pay off in Cranelift?§3 found the builtin opaque to clang. cancho emits sadd_overflow + trapnz and Cranelift vectorises nothing, so the spelling is free to change and currently buys nothing. It becomes a real question the day the backend has a vectoriser
A per-element trap that is not a stopThe middle option nobody has costed: a trap that records the element and continues, which is poison with the where kept. It costs a store per failure and nothing per success, and no kernel here measures it
The reduction case, properly§3 says “no partial sum overflowed” is not reassociable. The question that leaves open is whether a different claim — no overflow in any association order, which is a bound on the inputs — is cheap enough to be worth having, and that is arithmetic rather than measurement

What the backend can be asked for, checked against the backend

Status: an audit, and it found one thing nobody was looking for.

Five documents gate the largest row on ROADMAP.md — an LLVM backend — on four factual claims about Cranelift. Exactly one of them had ever been checked against the source: aliasing.md §5 read cranelift-codegen 0.121.2 and found no noalias, which is how that document reached “two independent blockers” instead of one.

The other three were written from knowledge rather than from reading, and a backend that ships every few weeks is a poor thing to know about. All four are now checked. All four hold — and checking them turned up a fifth fact that none of the five documents has:

The two largest unspent facts in this project — a checked purity proof worth 158× (purity.md) and defined behaviour costing up to 3.35× (check-cost.md) — are blocked by two adjacent clauses of the same four-line function.


1. The four claims

WhereClaimHolds?
aliasing.md §5no noalias on a parameteryes (checked in #63)
purity.md §3no “no side effects” attribute on a callyes
gpu.md §1no GPU targetyes
gpu.md §2.3, check-cost.md §8no SIMD from scalar codeyes

1.1 No noalias

ir::AbiParam is { value_type, purpose, extension }, and purpose is Normal | StructArgument | StructReturn | VMContext. The only aliasing vocabulary in the IR is MemFlags’ AliasRegion::{Heap, Table, Vmctx} — three fixed regions for WebAssembly’s memory model, not a per-pointer restrict.

1.2 No purity attribute on a call

purity.md says “there is no call attribute for ‘no side effects’”. There is not, and the reason is one level lower than a missing field.

ir::ExtFuncData is { name, signature, colocated }, and ir::Signature is { params, returns, call_conv } — so there is nowhere to write one. But even a field would not be read, because inst_predicates.rs decides the question before consulting anything:

#![allow(unused)]
fn main() {
fn trivially_has_side_effects(opcode: Opcode) -> bool {
    opcode.is_call()
        || opcode.is_branch()
        || opcode.is_terminator()
        || opcode.is_return()
        || opcode.can_trap()
        || opcode.other_side_effects()
        || opcode.can_store()
}
}

opcode.is_call(), unconditionally. A call is side-effecting because it is a call. Nothing downstream — not the egraph’s GVN, not dead-code elimination — asks anything else about it.

1.3 No GPU target

src/isa/ holds aarch64, pulley_shared, riscv64, s390x and x64. pulley is Cranelift’s own portable interpreter, not a device. No PTX, no SPIR-V, no AMDGPU, as gpu.md §1 said.

1.4 No SIMD from scalar code

This is the one worth stating precisely, because “Cranelift emits no SIMD” is easy to read as a tuning gap.

Cranelift has SIMD: vector types, opts/vector.isle rewrite rules, and vector lowerings per target. What it has no pass for is producing them. The optimisation passes in src/ are alias_analysis, egraph (GVN and ISLE rewrites), legalizer, loop_analysis (a loop tree, for dominance) and nan_canonicalization. There is no auto-vectoriser, and loop_analysis is not one under another name.

So SIMD in Cranelift is an input language — it is there because WebAssembly has v128 — and a scalar loop stays scalar by design rather than by oversight. gpu.md §2.3 measured cancho emitting zero SIMD instructions and attributed the remaining 2.27× to Cranelift; this is why that attribution was right, and why it will not improve with a newer version.

Scope (parallelism.md §3.1). This section is about Cranelift. The default backend is LLVM, which has a vectorizer and uses it on loops that cannot trap (reduce_wrapping.cho: 54 SIMD instructions, 64-67 ms against C’s 66-69 ms). Its default target is the architecture’s baseline (clang -c -O2 -target, no -march), which on x86-64 is SSE2: no 64-bit integer compare, which is why a vectorized max/min pass there is slower than the scalar loop.


2. The fifth fact, which is the reason to write this down

Read trivially_has_side_effects again with this project’s two open performance arguments in hand:

#![allow(unused)]
fn main() {
opcode.is_call()        // purity.md's 158x
...
opcode.can_trap()       // check-cost.md's 3.35x
}

Asked directly, on the opcodes cancho actually emits:

opcodecan_trap()is_call()
Trapnztruefalse
Trapztruefalse
Callfalsetrue
Sdivtruefalse
SaddOverflowfalsefalse
Iaddfalsefalse

Three things fall out.

The two blockers are the same shape. A pure call cannot be eliminated because is_call() is true; a checked addition cannot be moved because the trapnz beside it has can_trap() true. Two guarantees this language is proud of, stopped by two clauses of one predicate, neither of which takes an argument.

sadd_overflow is pure. The add-with-flag itself is side-effect-free — it is the separate trapnz that pins it. So the overflow check’s cost is not in the arithmetic at all, which is the backend’s own account of what check-cost.md measured from the outside.

sdiv traps by itself, with no trapnz needed. That is check-cost.md §5’s finding from the other end: division’s guarantee is free because the instruction already carries it, and the backend agrees in a boolean.


3. What follows

README.md’s backend row says “Cranelift for dev, LLVM for release”, and until now the case for the second half was a stack of measurements with an inference on top. It is not an inference any more:

  • There is no aliasing fact to emit, so proving &! unique buys nothing here (aliasing.md).
  • There is no purity fact to emit, so a 158× stays unspent (purity.md).
  • There is no vectoriser to feed, so removing a trap gets a scalar loop (check-cost.md, gpu.md).

Each of those is a property of the backend’s design rather than a version it has not reached. The three arguments for LLVM are one argument: this language computes facts its backend has no vocabulary for.

What this does not do is make the backend a mistake. Cranelift compiles fast, it is what a dev loop wants, and bootstrap.md chose it for reasons that still hold. The row says “for dev” and it is right.


4. What this does not say

  • Not that LLVM would collect all of it. LLVM has noalias, readnone and a vectoriser, so the vocabulary exists — but check-cost.md §3 showed the vectoriser only helps where the check’s condition is per-element, and poison.md showed the reduction case is not rescued by any spelling. The ceiling is lower than the sum of the three numbers.
  • Not a version claim beyond 0.121.2. Everything here is that version, read from ~/.cargo/registry and, for §2’s table, asked of the compiled crate rather than inferred from generated source. A future Cranelift may grow any of this; the point of writing the citations down is that the next person can re-check in minutes.
  • Not an argument for writing our own passes. README.md’s non-goals exclude an own optimiser and purity.md §3 argues why. A middle-end in this repository is the work that turns three months into three years.

A second backend, and how it would actually get built

Status: complete, and the default. Every gap this document ever named a target for is closed — every fixture in tests/accept/ and every program in examples/ builds on --backend llvm — and docs/README.md’s “Performance, honestly” section made it the default backend rather than an opt-in one, since it closes the 1.17×–2.58× gap to C that the default used to carry. That flip is what found the last two things this backend had never been asked to get right on its own: docs/ROADMAP.md #92’s “already-accepted” socket-declaration collision, tolerable while nothing built by default ever hit it, became a live regression the moment something did (§7.25’s own postscript, below, has the fix); and Expr::Unboxed had no scalar_kind arm at all, invisible until a default-backend build ran docs/tuples.md’s own identity fixture for the first time. Both are fixed, not documented around. What follows is the slice-by- slice history that got here, kept as it was written rather than rewritten now that the destination is known. docs/benchmarks-game.md §8 has the current gap to C, re-measured on this backend rather than quoted from the Cranelift-era number above.

Corrected later: Fs and Net were built only as statements. fs_read, fs_write, connect and bind are each their own IR node (Expr::FileOp, Expr::Connect, Expr::Bind), and scalar_kind had no arm for any of them, so if fs_read(fs, path, buf) == 32 or connect(n, host, port) < 0 failed with “the compiler failed to generate code” here while Cranelift built and ran them; only a result bound by let first could be compared. §7.22’s “Net itself is fully built” and §7.24’s “Fs, closed” were true of the call and false of its use as an operand. All four answer int, and now say so (trap() gained the same arm); the other capability nodes answer tagged enums, which no operator takes. Pinned on both backends by a_file_op_is_an_operand_on_both_backends and connect_and_bind_are_operands_on_both_backends.

Superseded status, kept for the history below: “first five slices built, and examples/hello.cho builds” (§5, cancho-codegen-llvm, --backend llvm). §5 originally named hello.cho as the first slice’s target; building that slice found the claim false — hello.cho needed checked arithmetic, bounds-checked indexing, string-literal data, and (found along the way) control flow, four things across four slices rather than one. The fourth, slices and strings, is what closed that loop: hello.cho — ci.yml’s own smoke test — builds and runs through this backend, byte for byte the same as through Cranelift. The fifth, structs and enums, is a different kind of slice: nothing in this document’s original plan asked for it by name the way hello.cho asked for the first four — it is next because docs/ROADMAP.md‘s own “later slices” list already named it, and tests/accept/enums.cho turned out to be a ready-made target once it landed. tests/accept/llvm_smoke.cho, llvm_arith.cho and llvm_control.cho are what the first three slices’ own bullet lists actually describe; the fourth and fifth needed no new fixture of their own, because hello.cho and enums.cho already were ones. The second slice found LLVM’s sdiv/srem are undefined, not trapping, on the two inputs Cranelift traps — a correction below, not assumed going in. The third needed no phi: every local here is already memory, so an if’s two arms and a while’s back edge just read whatever was last written, the same fact the first slice’s alloca-per-leaf design was for. The fourth reused that fact a third time: a string literal’s global symbol is already a usable pointer constant in LLVM IR, so Expr::Bytes needed no instruction at all, only a declaration. The fifth reused trap_if a third time too, for match’s own tag-test chain, and needed no phi either — the same memory-backed reasoning, applied to a chain of branches instead of a diamond or a loop.

backend-limits.md made the case: three separate performance arguments — aliasing.md’s unspent uniqueness fact, purity.md’s 158×, check-cost.md/gpu.md’s missing vectoriser — turn out to be one argument, because they are all blocked by the same four-line Cranelift predicate. This document is the next question, which nobody had asked yet: how, concretely, would a second backend be wired in, and what does building one actually cost before it computes a single one of those facts?

Two things came out of spiking it rather than guessing. The architecture is cheaper than it looks: textual LLVM IR, clang as an external tool, no new build dependency — connect.md §9’s three-slice split, aimed at a new target instead of a new capability. And there is a real, measured correctness trap sitting in the middle of it: the obvious way to emit cancho’s overflow trap in LLVM IR produces the wrong signal, silently, on one of the two targets this project ships.

§7 is a third thing, found later: run_clang never passed clang an optimisation level, so the mem2reg promotion §5’s third slice called “mandatory” never actually ran — every leaf stayed a real stack slot, and the backend was measurably slower than Cranelift on every kernel that builds on both. Fixed by always passing -O2; measured again after, the same three kernels are 8%–54% faster than Cranelift, not slower, and the seven trap-signal tests still pass unchanged — a genuinely false claim, corrected in place rather than left standing. §7.4 closed wrapping_add/sub/mul and found LLVM can delete a whole no-op loop outright once nothing in it can trap. §7.5 closed region/alloc_slice — the biggest remaining gap — and found the first real vectorisation in this document: the wrapping halves of sieve/scan compile to hundreds of SIMD instructions where their checked twins, otherwise identical, compile to none. §7.7 closed heap boxing and, underneath it, a second gap nothing had tried to lift since the first slice: a function could not return more than one leaf. reduce_checked.cho — docs/gpu.md’s own kernel — needed both, and confirms the same vectorisation split a fourth time. §7.9 closed getchar and s[a..b], and found revcomp.cho’s own boundary sitting one gap further out still: Place::Field/Place::Deref, named since §5 and, until now, never connected to a program that actually needed it.


1. The question this document answers

lib.rs’s own header has said this since M0:

“Cranelift rather than LLVM for M0 (#3): trivial to embed, no C++ build dependency, and compile speed suits the feedback loop. LLVM arrives later for release-quality codegen — the plan is to ship both, as rustc does.”

“Ship both” has been the plan for over ninety pull requests and nobody had written down what that means in practice: one compiler, two backends, selected how, sharing what, and — the part every one of this project’s other big features gated on — what is the smallest thing that proves the wiring works before either backend has to be feature-complete against the other.

That smallest thing is what §3 measures.


2. Two ways to talk to LLVM, and why one of them is the one M0 already rejected

Option A: link against LLVM. inkwell or llvm-sys, building IR through a Rust API the way cranelift-codegen is used today. This is what most Rust-hosted LLVM front ends do.

Option B: emit LLVM IR as text, and shell out to clang. The compiler writes a .ll file; an external process turns it into an object file; linking is whatever already happens to that object file.

Option A is exactly the C++ build dependency lib.rs’s M0 comment named as the reason to wait: llvm-sys links against a specific major version of libLLVM, found via llvm-config on the host, and getting that right across ubuntu-latest and macos-latest — two different package managers, two different default LLVM versions, neither guaranteed to match what inkwell’s pinned version wants — reintroduces the “no C++ build dependency” §1 already quoted from lib.rs, just deferred from the compiler’s language to the compiler’s build.

Option B has no such coupling, and it is not a new pattern here: link in crates/cancho/src/main.rs already shells out to cc for every single build, Cranelift path included (docs/backend-limits.md never had to mention this because it is not a Cranelift fact, it is a M0 fact). A second backend that shells out to clang for both compiling and linking is the same architecture the first backend already has, aimed at one more external tool.

Decided: Option B. §3.1 is why it is cheaper than it sounds.


3. The spike

Two questions had to be answered before either was a claim: does a plain .ll file actually compile on both targets without extra tooling, and does the one piece of codegen this language cannot get wrong — the overflow trap — come out the same way.

3.1 clang alone is enough; llc and opt are not needed

The concern going in was that LLVM IR needs llc (to lower .ll to an object file) and optionally opt (to run passes), neither of which ships with Xcode’s command-line tools — only Homebrew’s separate llvm formula has them (confirmed: /usr/bin/clang is Apple’s, and llc is not on PATH without brew install llvm).

It does not matter. clang itself accepts .ll as an input language and both compiles and optimises it directly:

$ /usr/bin/clang -c hello.ll -o hello.o   # Apple's clang, no Homebrew LLVM installed
$ /usr/bin/clang hello.o -o hello
$ ./hello
hi!
$ echo $?
42

clang -O2 -c foo.ll -o foo.o is the entire codegen step. ubuntu-latest and macos-latest both ship clang already (ci.yml’s existing cc step already depends on this), so the CI matrix needs no new installed tool for this backend — a smaller footprint than the current one, which the no C++ build dependency reasoning did not even promise.

The one thing worth carrying into the implementation: clang warns (overriding the module target triple) if the emitted .ll omits or disagrees with the host triple. Harmless here, but the real lowering should emit target triple = "..." from compile_object_for’s own Triple argument rather than let clang guess it — the same value Cranelift’s path already threads through today.

3.2 The overflow trap: the obvious spelling is wrong, and it is wrong silently

Every one of overflow-cost.md, emitted-checks.md and backend-limits.md treats SIGILL as the signal a checked trap raises, because that is what Cranelift’s trapnz lowers to. The obvious LLVM translation of “add with overflow, then trap” is:

%r = call {i64, i1} @llvm.sadd.with.overflow.i64(i64 %a, i64 %b)
%ov = extractvalue {i64, i1} %r, 1
br i1 %ov, label %trap, label %ok
trap:
  call void @llvm.trap()
  unreachable

Measured on darwin-aarch64: this compiles and runs, and the shell reports exit 133 — SIGTRAP, not SIGILL. llvm.trap() lowers to brk #0x1 on AArch64 (confirmed by disassembling the compiled output), which the kernel delivers as a breakpoint trap. The actual compiler’s own output, for the same overflow, disassembles to udf #0xc11f, and the shell reports 132 — SIGILL — every time.

This is a real gap the existing suite would not have caught: every runtime-trap test here (compile_time.rs, arguments.rs, and any other Command::status.code()) checks assert_eq!(run.status.code(), None, …) — Rust’s own signal for “a signal killed this,” true for SIGTRAP and SIGILL alike — and none reads std::os::unix::process::ExitStatusExt’s .signal() to see which one. A backend that emitted SIGTRAP here would pass every test in this repository today while contradicting what docs/defined-behaviour.md and emitted-checks.md say the language does, and only a person watching a shell’s $? would ever see the difference. §5’s second slice is also where that gap gets closed.

The fix, also measured: emit the trap as target-specific inline assembly instead of llvm.trap() —

trap:
  call void asm sideeffect "udf #0xc11f", ""()   ; aarch64
  unreachable

— which disassembles to the same udf #0xc11f Cranelift emits and exits 132, matching exactly. The x86-64 side needs the equivalent "ud2" string, unverified on this machine (no x86-64 host in this session) but consistent with ud2 being the documented cause of Cranelift’s own SIGILL there (emitted-checks.md §4).

This is the finding worth the whole spike. A backend that reached for the portable intrinsic — the correct-looking choice, and the one an LLVM newcomer would pick — would have shipped a language whose defined-behaviour guarantee (docs/defined-behaviour.md) is silently weaker under one backend than the other, on one target, and nothing in the existing test suite would have noticed. Trap codegen cannot be generic between targets; only the checker’s decision to trap can be.

3.3 The traps were already asymmetric between targets, before this document existed

Checking §3.2 against int::MIN / -1-style division found something emitted-checks.md had only ever measured on x86-64. On aarch64 (measured here, div0.cho and mod0.cho built with the actual compiler): both division and remainder by zero exit 132, SIGILL, disassembling to explicit udf after a compare. emitted-checks.md §4 found, on x86-64, 1/0 dies with SIGILL from its own ud2 and 1%0 dies with SIGFPE from the hardware — a different pairing of operator to signal.

The reason is the hardware, not Cranelift’s choice: x86-64’s idiv faults on division by zero by itself (#DE, delivered as SIGFPE), so Cranelift only needs an explicit check for the one case the instruction does not cover (int::MIN / -1, which does not trap in hardware but must). AArch64’s sdiv does not fault at all — ARM’s architecture manual defines division by zero as answering 0 — so Cranelift must check both zero and int::MIN / -1 explicitly on that target, and both checks reach the same udf.

A second backend inherits this, not a cleaner version of it. The observable contract (docs/defined-behaviour.md) is target-independent prose — “traps” — but which signal that trap is is target-dependent, already, in the compiler this one has to match. Getting this right is per-target codegen work, checked instruction by instruction the way emitted-checks.md checked Cranelift, not a single IR pattern reused across isa/.


4. The shape this takes

A sibling crate, cancho-codegen-llvm, with the same doorway cancho-codegen has:

#![allow(unused)]
fn main() {
pub fn compile_object_for(
    program: &Program,
    entry: &str,
    triple: Triple,
) -> Result<Vec<u8>, CodegenError>
}

fed the identical cancho_ir::Program the Cranelift path already consumes — nothing about parsing, checking, or lowering to IR changes. cancho-ir/src/lib.rs’s own header already states the boundary this relies on: “the backend receives IR that cannot fail.” Internally it builds a .ll string and shells out to clang -c (§3.1), the way link already shells out to cc (§2).

Built as such: CodegenError itself is not duplicated, only reused — cancho-codegen-llvm depends on cancho-codegen for that one type ({ function: Option<usize>, message: String }, already backend-agnostic) so the CLI’s internal_refusal needed no change to accept either backend’s failures. Everything else stays as sibling as the plan said: no Cranelift type, and no Cranelift call, crosses the boundary.

The CLI grows one flag, --backend cranelift|llvm, defaulting to cranelift — so this is purely additive. This is not an edition: nothing about the language changes, only which tool compiles it. But it is the same shape docs/editions.md §6’s rule takes for the language — “only additions are held open” — applied to the compiler instead: a second backend that changes no existing default is exactly an addition. Every existing test, example and fixture keeps running exactly as it does today; the new backend is opt-in until §5’s bar is cleared enough to flip a target’s default, which is a much later decision than this document makes.

link itself needs no change at all: it already takes an object file and a cc, and an LLVM-produced object file is not distinguishable from a Cranelift-produced one at that step.


5. What the first real slice is, and is not

Matching connect.md §9’s own precedent — Net turned out to be bigger than one slice too, and was “agreed as three slices once that came into view” rather than forced into one PR:

First slice: the doorway and the smallest possible program — built. cancho-codegen-llvm exists, takes --backend llvm, and builds and runs a program needing exactly: function declarations and calls, World/capability erasure (every capability is a zero-field struct, so it scalarises to no leaves at all — confirmed rather than assumed, since cancho-codegen’s own abi::leaves_into has no special case for Io, Ffi, Fs, Heap or Args; they fall through to the same struct rule Split does, five zero-leaf fields making a zero-leaf whole), putchar, and process exit. No arithmetic, no traps, no structs with real fields.

This corrects the row above: that program is not examples/hello.cho. ci.yml’s smoke test was assumed to be the smallest one, unchecked against what strings.md had since added to it. It is not — write_all’s n = n + 1 and n < len(s) are checked arithmetic and a comparison, s[n] is bounds-checked indexing, and "Hello, world!\n" is a string-literal data object, and this slice lowers none of them. tests/accept/llvm_smoke.cho is the program this bullet list actually describes — two functions, one call between them, four putchars, no operator anywhere — and it is what cancho-codegen-llvm’s own test builds and runs, checked byte-for-byte against the Cranelift path’s output for the same file (crates/cancho/tests/conformance/backends.rs). hello.cho moves to the second slice below, where checked arithmetic lands, and needs bounds-checked indexing and string data besides — closer to a third and fourth slice than a continuation of this one.

Every node this slice does not lower is refused with a located CodegenError, never a panic (docs/internal-errors.md): unlike cancho-codegen’s unreachable!s, which state an invariant the checker already guarantees, a gap here is an ordinary limit of an opt-in, unfinished backend, and --backend cranelift is unaffected either way (--backend is purely additive, defaulting to cranelift).

Second slice: checked arithmetic — built, and not quite as scoped. Every BinOp that can trap (Add, Sub, Mul, Div, Rem, Shl, Shr) lowers, plus the three bitwise operators (BitAnd, BitOr, BitXor), which cannot trap and so needed no extra scoping to include — tests/accept/llvm_arith.cho exercises all ten, each once, printing a value chosen so the exact byte proves the answer rather than only clang accepting the module. The six comparisons (Eq/Ne/Lt/Le/ Gt/Ge) lower too, but stay untested end to end: they are this language’s only way to produce a bool, and this language has no way to observe a bool outside if/match — control flow this backend still does not lower (§5’s own next gap, not this slice’s to close). &&/|| stay refused outright: ir.rs’s own comment says they lower as control flow rather than as an instruction, for the same reason.

Not built the way this section originally planned: not checked against benches/guards.c-style kernels the way every_guard_kernel_agrees_across_its_three_modes checks Cranelift’s three modes against each other. That harness needs a loop to run a kernel’s cases, and a loop is control flow. What replaced it: every runtime-unknown operand needed for a trap test is a value putchar hands back (an impure call the compile-time folder cannot evaluate, unlike a bare literal pair — see below), which is enough to prove each of the seven trapping operators raises SIGILL on a real clang without needing a loop to do it in. §3.2’s fix and §3.3’s per-target asymmetry are pinned down as tests for x86-64 here; aarch64 is CI-only, as it was after the first slice.

A finding this slice’s own tests forced, not a design decision made in advance: docs/compile-time.md §3 folds an operator whose both operands are literals at compile time, and turns one that would overflow into a refused program (Rule::ConstantTraps) rather than a running one. Writing checked_add_traps_on_overflow as 9223372036854775807 + 1 does not reach this backend at all — it is refused before lowering finishes, correctly, and for a reason that has nothing to do with either backend. Every trap test here instead routes one operand through x, a value read back from probe, a function that performs io_write and so is never a candidate for constant folding (docs/purity.md §2’s own condition). This is not a workaround around the language; it is the proof that a genuinely run-time-unknown overflow reaches the checked- arithmetic codegen this slice built, rather than one the folder would have already caught for a different reason.

A second finding, in the codegen itself: LLVM’s sdiv/srem are undefined, not trapping, on a zero divisor or on int::MIN / -1 — unlike Cranelift’s, which trap on both (docs/defined-behaviour.md). The obvious LLVM translation of Div/Rem — lower straight to sdiv/ srem, the way Shl/Shr almost can (masked by LLVM’s own semantics where Cranelift’s would need the same explicit check this backend already writes) — would have been silently wrong on exactly the two inputs the language exists to make defined. Both checks are explicit, ahead of the instruction, the same shape Shl/Shr’s range check already has: checked_div_traps_on_division_by_zero and checked_div_traps_on_int_min_over_negative_one are what prove the checks actually run, on a real clang, rather than merely compile.

Third slice: control flow — built, and needed no phi after all. Stmt::If/Stmt::While both lower, plus the two short-circuit operators, And/Or, which ir.rs’s own comment already said belonged here rather than in binop’s instruction table. The phi node §5’s own text once assumed a real if would need never got written: every local in this backend is already an alloca (§5’s first-slice design, chosen so clang’s mem2reg does the SSA work this crate does not — at -O1 or above; §7 found and fixed the session where this crate was still invoking clang at its default -O0, where mem2reg does not run at all), so the block after an if simply loads whatever the taken arm last stored, and a while’s loop header is re-entered by its back edge the same way — fresh loads each time, no value carried in a register across the edge. &&/|| use the same trick over a one-leaf temporary alloca rather than a phi merging two live values. Corrected (#173): that temporary, and the buffer each borrow writes its referent into, were emitted where they were used, which is wrong inside a loop. An alloca outside the entry block is dynamic: it takes stack every time it executes and returns it only when the function does, and mem2reg promotes only entry-block ones. A loop with a borrow of a nine-word value used up the 8 MiB stack in about 30,000 iterations and died with SIGSEGV – on the LLVM backend only, in programs Cranelift ran correctly, and in none of the fixtures here, because their loops were short or their references never escaped the optimiser. Every alloca outside the slot prologue is now emitted into the entry block (FuncEmitter::hoist); the cell is the same one each time the code runs, which is all any of these uses wanted. See docs/map.md §7. The only real branching this slice added is the diamond if_stmt builds and the loop br pair while_stmt builds; trap_if’s branches, from the second slice, turn out to have been this backend’s first working example of exactly that shape, one side of the diamond always unreachable.

What this closes: comparisons, built and untested since the second slice, are now exercised end to end — llvm_control.cho prints a different byte depending on each of the six, which is the first time any of them influenced this backend’s output rather than merely compiling. What it does not close: hello.cho still refuses at this point, now on its string literal (Bytes) rather than on its while loop — a --backend llvm run against it before this slice failed at the control flow; after, it reaches the greeting itself and fails there instead, which is this slice’s own evidence that the boundary moved rather than merely that a claim changed. (The fourth slice, next, is what actually closes it.)

Stmt::Match stays refused, deliberately not scoped into this slice: an enum’s tag-and-payload layout is a structs-and-enums question (below), and dispatching on a tag needs a switch or a chain of brs this document is not claiming to have designed yet. Stmt::Region stays refused too, for the arenas-and-regions reason already listed below — neither needed the branching this slice actually built to also need scoping into it.

Fourth slice: slices and strings — built, and hello.cho builds. Expr::Bytes, Expr::Len and Expr::Index all lower, plus Builtin::IntOf (hello.cho’s own int_of(s[n]), a plain zext i8 — necessary plumbing to reach the target, the same reason the first slice needed putchar beyond what its own bullet list first named). Two things came out cheaper than expected and one came out exactly as planned:

  • A string literal needed no instruction, only a declaration. cancho-codegen’s Cranelift path reads a literal’s address with global_value, an instruction Cranelift’s SSA builder needs to materialise a global into a value. LLVM has no equivalent step: @sym is already a ptr constant wherever one is expected, so bytes_lit writes the private unnamed_addr constant declaration into a module-level buffer and hands back the bare symbol name as the pointer leaf — nothing is emitted into the function body at all for the address half of a literal.
  • Bounds checking is exactly one more trap_if. s[i]’s check — icmp uge i64 <index>, <len> — is textually the same shape checked_shift’s range check already is, reusing trap_if rather than adding a new branching primitive. The one design decision this slice made was plain integer-array syntax ([i8 72, i8 105, ...]) over LLVM’s c"..." string-constant shorthand, to avoid writing (and getting wrong) a second escaping rule for characters the shorthand does not accept directly.
  • docs/compile-time.md’s constant folder does not reach Index at all, unlike the second slice’s own finding about BinOp. A literal out-of-range index ("abc"[5]) passes cancho check and only traps at run time, which is what let this slice’s own bounds-check tests (indexing_past_a_slice_traps_with_sigill, indexing_before_a_slice_traps_with_sigill) use a literal index directly, with none of the second slice’s probe-routed workaround.

Place::Element (s[i] = e) lowers too, by the same element_address the read side uses, so indexed writes are not a silent asymmetric gap next to indexed reads. Place::Field/Place::Deref stay refused — both need struct/box layout, structs-and-enums’ question below.

Fifth slice: structs and enums — built. Expr::Struct needed almost nothing new: a struct value is positional already (declaration order, not source order), so its leaves are just every field’s leaves concatenated — the same shape Expr::Tuple would be, and this backend already reads a struct’s fields this way through Expr::Field, one slice’s worth of reading built before any construction was.

Expr::Enum and Stmt::Match are where the real work was, and both matched cancho-codegen’s own layout exactly rather than inventing a narrower one: an enum’s leaves are a tag (i64) followed by every variant’s payload leaves, not only the constructed one — wasteful and deliberately so, because overlaying payloads is a layout decision this milestone makes none of. variant_layout computes where one variant’s slice of that whole starts, shared by both directions: enum_lit writes the tag and the constructed variant’s values, zero-filling every other variant’s leaves (a value that is not this variant is not readable without matching on the tag first, so what is actually there is unobservable); bind_payload reads the same offsets back out when a match arm binds them. match_stmt lowers to a chain of tag tests — icmp eq against each arm’s variant, in order, falling through on a miss — matching cancho-codegen’s own comment that a jump table would be faster and is the obvious later move for both backends alike, not just this one.

One real subtlety, not obvious until measured against if_stmt’s own shape: a match whose every tested arm returns still needs a merge block, unlike if_stmt, which omits one in the equivalent case. The reason is exhaustiveness’s two different sources. if/else are syntactically exhaustive — there is always a real else, even an empty one — so §5’s third slice could simply not emit a fall-through edge when both sides terminate. A match over enum variants is only exhaustive because the checker proved every variant is covered, which a chain of icmps does not know on its own: with no wildcard arm, the chain’s final next block falls through what the value’s type makes impossible but the IR does not. That block still needs a legal terminator, so match_stmt always emits merge and gives it a default return exactly when nothing valid reaches it — the same “unreachable but must still be well-formed” reasoning emit_default_return (factored out of emit’s own function-level fall-through in this slice) already had one job for.

Deliberately not in this slice: matching through a reference. docs/reading-references.md’s address-only binding mode — where a matched payload is a pointer into the referent rather than a copy of it, and costs nothing because nothing is loaded — has no counterpart here; by_reference scrutinees are refused outright. Getting that right needs the same pointer-into-a-referent arithmetic Place::Field/Place::Deref need, which is also still refused, for the same reason: none of it was needed to make tests/accept/enums.cho build, and enums.cho’s own match binds an owned Point (Shape:: At(p, r) => p.x + p.y + r), read back through the Expr::Field this backend already had.

Later slices, each its own PR, not scoped further here: matching through a reference and Place::Field/Place::Deref (both need pointer arithmetic into a referent — the address-only counterpart to what this slice built by value), arenas and regions (a Heap-free allocation scheme LLVM has no special vocabulary for either, so likely the same bump-pointer strategy translated rather than redesigned; Stmt::Region waits on this), Ffi/extern fn (LLVM’s own declare is close to a direct match), Net. Each is a Builtin or BinOp variant this document is not claiming to have scoped — ir.rs has 34 builtins and lists them so the next slice can pick a subset by reading them rather than guessing at the size of what is left (crates/cancho-ir/src/builtin.rs).

Partially answered, later, in §7: is it faster. This section still holds as written for the session that wrote it — five slices in, nothing here had measured a ratio, and backend-limits.md §4’s warning against overclaiming what LLVM’s vocabulary buys stands regardless of the number. §7 is where a first, narrow measurement was actually taken, once three real kernels built on both backends, and it found a bug in this crate before it found a ratio worth reporting.


6. Open

QuestionWhy it waits
The exact ud2/udf spelling and signal on linux-x86_64Measured, this slice’s session, on a real linux-x86_64 host: call void asm sideeffect "ud2", ""() assembles to the same two bytes (0f 0b) Cranelift’s own ud2 does, and the linked binary exits 132 — SIGILL — every time, matching §3.2’s aarch64 finding exactly (no SIGTRAP substitution, no divergence). One more thing confirmed alongside it: passing clang -target <triple> with the same spelling the .ll module’s own target triple line carries silences the overriding the module target triple warning §3.1 first saw on darwin — compile_object_for always does this, so a program built through this crate never sees it. aarch64’s udf #0xc11f half of §3.2’s fix is still unverified — no aarch64 host in this slice’s session either — but this row is otherwise closed
Which clang/LLVM IR version to targetTextual IR has version-dependent syntax; pinning to whatever ci.yml’s two runners ship, rather than a specific LLVM release, is the plan until a real incompatibility forces a choice. This slice’s session measured against clang 18 on linux-x86_64 only
Optimisation level and its effect on trap codegenMeasured, §7: clang -O2 is now what run_clang always passes (it has to be, for mem2reg to run at all — §7). All sixteen cancho-codegen-llvm unit tests, including all seven trap-signal tests, pass unchanged under it: -O2 does not fold, reorder, or eliminate a checked operation’s trap on any kernel this backend can build today
Whether noalias/purity attributes get emitted at allbackend-limits.md §4’s ceiling question — real, and not this document’s to answer before the backend can run anything

7. -O2, a first measurement, and what still blocks the rest of the suite

7.1 The bug: clang was never asked to optimise anything

§5’s third slice justified the memory-backed design on clang’s “mandatory mem2reg”. That is true of clang’s standard pipeline, and false of what this crate was actually invoking: run_clang (lib.rs) called clang -c -target <triple> <file>.ll, no -O flag at all, and mem2reg is not part of -O0. Confirmed directly — a minimal .ll identical in shape to sum_checked.cho’s inner loop (alloca, store, loop back-edge reading with load) compiles at the default optimisation level to nine real stack load/store instructions per iteration; at -O2 the loop promotes to registers and, for a closed-form case like this toy one, disappears into a handful of instructions entirely. Every one of this backend’s own tests still passed, because none of them time anything — docs/llvm-backend.md’s own §5 said as much when it deferred the speed question, but the reason it stayed deferred was this bug, not only that no kernel built yet.

Fixed: run_clang now always passes -O2. This is not tunable per build — the backend has no --opt-level flag, and docs/llvm-backend.md is not proposing one; an “unoptimised LLVM backend” mode is not a configuration this project has a use for, since the entire reason to reach for clang over Cranelift is the optimisation pipeline §3.1 already established clang brings for free. All sixteen cancho-codegen-llvm unit tests pass under -O2, including the seven that check a checked operation’s trap raises the exact signal Cranelift raises (the_two_backends_agree_on_* in crates/cancho/tests/ conformance/backends.rs also re-checks this at the CLI level) — -O2 changes the code the trap sits inside, not whether or how it fires.

7.2 A first backend-vs-backend measurement

Three of benches/’s kernels build on both backends today, unchanged: sum_checked.cho (tight checked arithmetic, no memory traffic — the “worst case” for the overflow check, sum_checked.cho’s own header), fib_checked.cho (recursion; the cost is calls, not arithmetic) and benches/three/mandelbrot.cho (Q16.16 fixed-point compute, the kernel docs/against-c-and-rust.md already runs against C and Rust). All three are covered by a new differential test each (the_two_backends_agree_on_sum_checked, ..._on_fib_checked, ..._on_mandelbrot in backends.rs) and by scripts/backend_compare.py, which is scripts/bench.py’s own interleaved-minimum method applied to backend choice instead of source-program choice.

Measured on this session’s linux-x86_64 host, --rounds 40, minimum of each interleaved half (the run is noisy — a shared, non-dedicated host — so the direction and rough size of the gap is the finding, not the exact percentage):

program        cranelift        llvm      llvm/cranelift
sum_checked      0.227s       0.105s            -54%
fib_checked      0.010s       0.009s            -11%
mandelbrot       0.148s       0.092s            -38%

LLVM is faster on all three, once -O2 actually runs — consistent with backend-limits.md’s and check-cost.md/gpu.md’s original hypothesis (a missing vectoriser and missing per-target instruction selection are what the four-line Cranelift predicate they identify is blocked on), and the opposite of what §7.1’s bug alone measured (LLVM 14%–58% slower across the same three kernels before the fix). fib_checked’s narrower gap is the expected shape: call/return overhead, not arithmetic, is that kernel’s critical path, and a better arithmetic pipeline has less of the run to speed up. This is not backend-limits.md’s full claim measured yet — none of these three kernels exercises the memory-bound or SIMD-shaped cases (sieve, scan, reduce, the Benchmarks Game programs) that motivated the backend in the first place; §7.3 is why.

Against C, not just against Cranelift — scripts/backend_compare.py --with-c adds a third, three-way interleaved leg for mandelbrot.cho against mandelbrot.c, the exact kernel docs/against-c-and-rust.md’s 1.6×/1.69× headline came from:

mandelbrot, three-way interleaved against clang -O2 (20 rounds):
  cranelift  0.1504s  (1.633x clang)
  llvm       0.0887s  (0.964x clang)
  clang      0.0921s  (1.000x clang)

docs/against-c-and-rust.md §2 stated a falsifier for exactly this number: “if an LLVM backend lands and the gap stays at 1.6×, the claim was wrong.” It did not stay — cancho through --backend llvm is indistinguishable from C on this kernel (0.96×–1.00× across repeated runs), while --backend cranelift reproduces the original 1.6×–1.8× almost exactly. docs/against-c-and-rust.md §2 now carries this correction in place, next to the claim it falsifies.

Not vectorisation — checked, not assumed. gpu.md’s own methodology warns against reading SIMD off the clock; objdump -d on both sum_checked_llvm and mandelbrot_llvm’s .text finds zero %xmm/%ymm/%zmm instructions, at -O2, on either. What actually changed against the pre-fix disassembly: every leaf that was a real -0x10(%rsp) load/store is now a register (mem2reg, finally running), and sum_checked’s loop is partially unrolled ×2 with the same add/jo/sub/jo sequence Cranelift already emits, just scheduled better and with no memory traffic between iterations. So overflow- cost.md/check-cost.md’s established finding — an observable trap is not reassociable, so a checked loop does not vectorise — holds here too, confirmed on a real backend rather than only inferred from Cranelift’s absence of one: this is docs/ROADMAP.md’s “What is next” row’s own open question, and the answer is no, still scalar, not “a vectoriser fixed it.” The whole measured gain in §7.2 is register allocation and instruction selection catching this backend up to what a competent scalar compiler already does — real, and worth having, and a different, smaller claim than “LLVM vectorises checked arithmetic.”

7.3 What still blocks the rest of benches/ from being comparable

Not a request for the LLVM backend to reach Cranelift’s feature set in the abstract — a request for exactly what would make the rest of the existing benchmark suite buildable on both backends, so the comparison backend-limits.md actually wants (memory-bound, checked-vs-wrapping, SIMD-shaped) can be taken. Found by trying to build every file under benches/ with --backend llvm and reading the refusal, not by inspecting emit.rs and guessing:

GapBlocksWhere it already shows up in this document
region/alloc_slice (arena allocation, Stmt::Region)Closed, §7.5: sieve_*.cho, scan_*.cho, benches/three/sieve.cho now build (fasta.cho/revcomp.cho still refuse, on Type::Float and getchar respectively — their own rows below)§5’s “later slices” list, already named
wrapping_add/wrapping_sub/wrapping_mulClosed, §7.4: every _wrapping.cho half of a benches/ pair and benches/three/purity.cho now build on --backend llvmImplicit in §5’s “every Builtin beyond PutChar/Split/Release/Narrow/IntOf”; not previously named on its own
Bare Expr::Alloc (single-value arena allocation)tests/accept/arena_roundtrip.cho — this backend’s own “outside the boundary” fixture, not a benches/ programNot previously named on its own; distinguished from alloc_slice only once §7.5 closed the latter
box_slice/Contents/unbox_sliceClosed, §7.7: reduce_*.cho, every benches/layout/*.cho file now build (bare box/unbox, a single-value box, stay refused — nothing in benches/ asks for one)Same bucket as above; not previously named on its own
arg_count (and argument reading generally)benches/game/binarytrees.cho, benches/game/fannkuch.choSame bucket
Type::Float and float arithmeticbenches/game/spectral.cho, benches/game/fasta.cho (two alloc_slice fills)emit.rs’s own LKind doc comment already says floats are refused; not previously named as a benchmark-blocking gap
getchar/io_readClosed, §7.9 (tests/accept/stdin_roundtrip.cho is the fixture; revcomp.cho itself needs Place::Field/Place::Deref too, found the same slice — its own row above, not closed)Found trying to build revcomp.cho once §7.5 closed region; not previously named

Ordered by what it would unblock, as each closed: wrapping_* first (§7.4) — smallest of the remaining gaps, and it made the overflow- check’s own cost (docs/overflow-cost.md’s question) measurable on this backend for the first time. Arenas next (§7.5) — the biggest single gap by program count, and building against the real targets found two smaller gaps (byte_of, Expr::Not) sitting in front of it that no inspection of emit.rs alone would have named. Heap boxing after that (§7.7) — the next-biggest pocket, and building reduce_checked.cho against it found a second, unrelated gap underneath: multi-leaf function returns, closed the same session. getchar next (§7.9) — smallest by design, and building revcomp.cho against it found s[a..b] in front, closed alongside it, and Place::Field/Place::Deref behind both, not closed. What is left — bare alloc, Place::Field/Place::Deref, arg_count, float — is four gaps, one of them (Place::Field/Place::Deref) larger than a single builtin or bounds check (§7.10 has the current, complete list).

Bench
scripts/backend_compare.pyInterleaved cranelift-vs-llvm timing on the kernels that build on both, today eleven; --with-c adds a three-way leg for mandelbrot.cho against mandelbrot.c
crates/cancho/tests/conformance/backends.rsthe_two_backends_agree_on_{sum_checked,fib_checked,mandelbrot,sum_wrapping,fib_wrapping,purity,sieve_checked,sieve_wrapping,scan_checked,scan_wrapping,the_three_language_sieve,reduce_checked,reduce_wrapping,layout_aos,layout_soa,layout_ints,layout_rgb,stdin_roundtrip} — not a timing gate, for the reason every_benchmark_pair_agrees gives
crates/cancho-codegen-llvm/src/tests.rs{subslicing_past_the_end,an_inverted_subslice}_traps_with_sigill — the two ways s[a..b] can be wrong, checked by signal the same way every other trap here is
crates/cancho-codegen-llvm/src/tests.rsallocating_past_an_arenas_chunk_traps_with_sigill — the arena-exhaustion trap, checked by signal the same way every other trap here is

7.4 wrapping_add/sub/mul, closed — and what removing a trap buys an optimiser

§7.3’s smallest-first ordering: wrapping_add, wrapping_sub, wrapping_mul lower to LLVM’s own add/sub/mul, no nsw/nuw requested — already two’s-complement wraparound, the direct counterpart of cancho-codegen’s plain iadd/isub/imul (crates/cancho- codegen/src/body/expr.rs). No overflow check, no new type, no new Place: three match arms and a four-line helper (emit.rs’s wrapping, built on the plain helper BitAnd/BitOr/BitXor already used).

Every checked-vs-wrapping pair in benches/ that does not also need region/box_slice now builds on --backend llvm: sum_wrapping.cho, fib_wrapping.cho, and benches/three/purity.cho besides (which needed this and nothing else). Three new differential tests (the_two_backends_agree_on_{sum_wrapping,fib_wrapping,purity}) join §7.2’s three.

The interesting number is not the ratio — it’s why one measurement looked broken until it was checked. scripts/backend_compare.py first reported sum_wrapping.cho at −99.1% against Cranelift (0.0012s against 0.1286s), which is not “faster,” it is a different program running. objdump on the object confirms it: lexs_run compiles to xor %eax,%eax; ret — two instructions, no loop at all. sum_wrapping.cho’s inner loop is total = total + i; total = total - i; i = i + 1, which is mathematically a no-op on total regardless of i’s value, and — unlike sum_checked.cho’s identical shape — nothing in the wrapping version can trap, so there is no observable effect left for the optimiser to have to preserve by actually running the two hundred million iterations. LLVM proves this and deletes the loop. Cranelift does not perform this optimisation at all (overflow-cost.md/ check-cost.md already established why the checked twin can’t be deleted this way — an observable trap is not reassociable, and deleting a loop that might trap would delete the trap too); §7.2’s disassembly check already found LLVM does not vectorise a checked loop for the same reason. This is the same fact from the other side: remove the thing that makes a loop’s effects observable, and an optimiser that has one can prove the loop is worth nothing and skip it entirely, which no amount of instruction selection would have bought on its own.

fib_wrapping.cho is the number worth trusting instead — recursion with data-dependent branching has no such algebraic identity to collapse, and objdump confirms real, unrolled code (21 instructions in lexs_fib, not two). Measured, −23.3% against Cranelift, in the same range §7.2’s other three kernels landed in.

This also answers a question docs/overflow-cost.md could only ask of Cranelift before: what the checked-vs-wrapping guarantee costs under LLVM. sum_checked.cho cannot collapse the way sum_wrapping.cho did — its +/- can trap, so the loop’s iteration count is observable even though the arithmetic result is not — and it still runs in 0.10s against Cranelift’s 0.22s (§7.2). The overflow check’s cost on this backend is not yet its own document’s worth of measurement; it is a byproduct worth naming here: on this one kernel, the gap between checked and wrapping is far larger under LLVM (a real loop vs. no loop at all) than under Cranelift (overflow-cost.md’s own +40.5% on the same kernel shape) — because LLVM had a bigger optimisation to lose.

7.5 region/alloc_slice, closed — and a vectoriser firing for the first time

§7.3’s second-named gap, and the bigger of the two remaining: Stmt:: Region/Expr::AllocSlice need real arena allocation, which this backend had none of. Built the same shape cancho-codegen’s own body/memory.rs already has — one malloc in, one free out, and a bump pointer that only ever moves forward within the chunk — kept in two ptr-typed alloca cells rather than in an SSA value, this backend’s own idiom for anything that needs to vary rather than an cancho-codegen Variable. bump’s bounds check is textually the same two-comparison shape (next past end, or next wrapped below at) cancho-codegen’s own bump makes, translated into ptr arithmetic throughout (getelementptr/icmp both work directly on ptr in LLVM IR, so nothing here needs ptrtoint). ARENA_CHUNK is the same 64 KiB constant, unmodified from cancho-codegen’s abi::ARENA_CHUNK — sieve_checked.cho’s own header sizes its allocation against this exact number, so a mismatched chunk would trap where the Cranelift build does not, or the reverse.

“One free out” was true of a region left by falling out of its last statement and of nothing else: a return from inside one emitted its ret with no free, so the chunk was kept for good, one 64 KiB malloc per call (cancho#252). cancho-codegen’s emit_return had always freed every arena open at the return, innermost first; the Stmt::Return arm here now does the same (release_arenas), after the returned value is in registers. cancho-hooks found it as about 11 KB kept per delivery attempt (2 MB to 1,075 MB resident over 100,000 deliveries), and worked around it by never returning from inside a region. A function that writes and reads its region at indices the optimiser cannot fold, called 100,000 times, peaked at 3.3 GB resident before and 1.4 MB after (macOS, arm64); a_region_left_by_return_frees_its_chunk_before_the_ret checks the IR.

Building sieve/scan against this found two smaller gaps actually sitting in front of them, not named until tried: byte_of (narrow-or- trap, docs/strings.md §2 — one unsigned comparison and a trunc, the same shape element_address’s own bounds check already has) and Expr::Not (!b — a bool leaf is 0 or 1, so flipping the low bit is the negation, one instruction, never trapping). Neither is its own row in §7.3’s table; both were found by building the actual target and reading the refusal, the same method §7.3 itself used.

Every program behind only these four gaps now builds on --backend llvm: sieve_checked.cho, sieve_wrapping.cho, scan_checked.cho, scan_wrapping.cho, and benches/three/sieve.cho — five differential tests join the nine already in backends.rs, checked byte-for-byte (or exit-code-for-exit-code) against Cranelift, plus a new crate-level trap test: allocating_past_an_arenas_chunk_traps_with_sigill, requesting 9000 ints (72000 bytes) from one arena and checking the same SIGILL §3.2 already established for every other trap here. fasta.cho/revcomp.cho — §7.3’s other two region-blocked programs — still refuse: fasta.cho on a float-typed alloc_slice fill, and revcomp.cho on getchar (io_read), each its own already-named gap.

Measured, --rounds 25, minimum of each interleaved half:

program           cranelift        llvm      llvm/cranelift
sieve_checked        0.173s       0.122s            -29%
sieve_wrapping       0.144s       0.061s            -58%
scan_checked         0.192s       0.061s            -68%
scan_wrapping        0.192s       0.040s            -79%

These are the first memory-bound, bounds-checked kernels measured backend-vs-backend — every program in §7.2/§7.4 was either pure arithmetic or recursion. LLVM is faster on all four, in the same direction as every kernel so far.

And, checked with objdump rather than assumed: the wrapping halves of these two pairs are the first kernels in this document where LLVM actually vectorises. scan_wrapping.cho’s object has 409 %xmm/%ymm/%zmm instructions; sieve_wrapping.cho’s has 22. scan_checked.cho and sieve_checked.cho — otherwise identical source, diffed to confirm the only change is +/- becoming wrapping_add/ wrapping_sub at every site, including the loop’s own induction variable — have zero, matching §7.2’s and §7.4’s finding on sum_checked.cho/mandelbrot.cho exactly. check-cost.md’s “an observable trap is not reassociable” is not a claim about arithmetic kernels specifically; this is the first direct evidence it holds for a bounds-checked memory scan too, on a real backend, both directions of the comparison in the same controlled pair. sieve_wrapping.cho’s smaller SIMD count against scan_wrapping.cho’s is not measured further here — its inner loop’s stride (m = m + p) is data-dependent, not unit, which is a harder shape to vectorise regardless of trapping, and untangling how much of the gap is that versus something else is its own question this document is not answering today.

7.7 Heap boxing, closed — and a gap that turned out to be two

§7.6’s largest remaining pocket: box_slice/contents/unbox_slice (Expr::BoxedSlice/Expr::Contents/Expr::UnboxedSlice), needed by reduce_*.cho and every benches/layout/*.cho file. Built the same shape alloc_slice already is — boxed_slice shares alloc_slice’s own slice_bytes helper (factored out once there were two callers) for the checked-multiply sizing, and reaches for malloc/a null trap instead of bump/an arena chunk; contents and unbox_slice are each one load and one free respectively, matching cancho-codegen’s own body/memory.rs exactly. Bare Boxed/Unboxed (a single-value box, not a slice) stay refused — nothing in benches/ asks for one.

Building reduce_checked.cho against this found a second, unrelated gap sitting underneath it: multi-leaf function returns. fill, the helper that builds reduce_checked.cho’s array, returns Box[[int]] — two leaves, a pointer and a length (docs/boxed-slices.md §2) — and emit/call had refused any function return past one leaf since the first slice, a restriction nothing had tried to lift because nothing had needed to yet. Closed the same way LLVM’s own multi-result intrinsics already read back in this file: a multi-leaf return packs into one anonymous struct ({ptr, i64} for a boxed slice, via insertvalue, mirroring how checked_arith already unpacks {i64, i1} out of @llvm.sadd.with.overflow.i64 with extractvalue), and a call site unpacks the same way. Not scoped to two leaves specifically — struct_ty/pack_struct take any number of kinds, so a three-field struct return works the same way, untested only because nothing in benches/ needs one yet.

Every program behind only these gaps now builds on --backend llvm: reduce_checked.cho, reduce_wrapping.cho, and all four benches/ layout/*.cho files (aos.cho, soa.cho, ints.cho, rgb.cho) — six differential tests join the eleven already in backends.rs, all checked byte-for-byte (or exit-code-for-exit-code) against Cranelift; the existing seventeen cancho-codegen-llvm unit tests, exercising every earlier slice’s own return path, still pass unchanged.

Measured, --rounds 20, minimum of each interleaved half:

program            cranelift        llvm      llvm/cranelift
reduce_checked        0.248s       0.096s            -61%
reduce_wrapping       0.100s       0.068s            -32%

reduce_checked.cho is docs/gpu.md’s own kernel — “the shape a GPU runs,” its own header says — and the first time it has been measured llvm-vs-cranelift rather than only cranelift-vs-C/Rust. Checked with objdump, once more before trusting it: reduce_checked.cho has zero SIMD instructions and reduce_wrapping.cho has 54, the same pattern §7.5 found in sieve/scan and §7.2/§7.4 found in sum/mandelbrot — an observable trap blocks vectorisation, on a fourth and different kind of kernel now (a boxed-slice reduction, after arithmetic, recursion, and a bounds-checked memory scan).

The four layout kernels were not separately timed here — scripts/ backend_compare.py stays scoped to benches/’s own checked/wrapping pairs, which benches/layout/ is not shaped as (each file is its own point, not a pair), consistent with scripts/three.py/scripts/ game.py already existing as the tools for differently-shaped suites rather than folding every kind of benchmark into one script.

7.8 What still blocks the rest of benches/, updated

box_slice/contents/unbox_slice and multi-leaf returns move out of §7.6’s table. What is left: bare Expr::Alloc (a single-value arena allocation — tests/accept/arena_roundtrip.cho, not a benches/ program but this backend’s own “outside the boundary” fixture), arg_count (benches/game/{binarytrees,fannkuch}.cho), Type::Float (benches/game/spectral.cho, and fasta.cho’s two float-filled alloc_slice calls), and getchar/io_read (benches/game/ revcomp.cho). Four gaps, each its own single pocket, none bundled with anything else in benches/ — the same shape §7.6 described, one row shorter.

7.9 getchar and s[a..b], closed — and revcomp.cho’s boundary moves past both

getchar’s own row: the mirror of putchar, sign-extended the same way, no argument to erase or narrow since io: &!i Io is already zero leaves. Trying it against revcomp.cho immediately found a second gap sitting in front, the same way sieve/scan found byte_of/Expr:: Not in §7.5: s[a..b] (Expr::Subslice), used by the buffer helpers revcomp.cho calls through std.buffer. Built the same shape element_address already is — the same two bounds checks cancho-codegen’s own subslice makes (past the end, or inverted), then a pointer-and-length pair rather than one element. Two new crate-level trap tests (subslicing_past_the_end_traps_with_sigill, an_inverted_subslice_traps_with_sigill) join the existing bounds-check pair.

Neither gap had a benches/ fixture of its own to build against, so tests/accept/stdin_roundtrip.cho — the repository’s own first //~ STDIN fixture — is what closes the loop this time: a getchar/ putchar echo loop, piped "hello\nworld\n" and checked byte-for-byte against Cranelift’s output, which needed its own comparison in backends.rs rather than reusing assert_backends_agree (nothing built here before piped a program’s stdin).

revcomp.cho itself still refuses, and the boundary moved a second time in the same session — worth stating exactly, not left as “still blocked on getchar” now that it is not. Past getchar and s[a..b], it reaches std.buffer’s Buffer.clear, which writes a field through a reference (b: &!b Buffer) — Place::Field/Place::Deref, already named in §5’s fourth slice as needing “pointer arithmetic into a referent this backend has not built” and never closed since. This is a materially bigger gap than either of this slice’s two — general struct-field writes through any reference, not one more builtin or one more bounds check — and is not scoped here. revcomp.cho is removed from the gap table below on that basis: it is no longer blocked by getchar, but it is still blocked, by something this document already knew about and had not yet connected to this specific program.

7.10 What still blocks the rest of benches/, updated again

getchar/io_read moves out of §7.8’s table, closed. revcomp.cho stays off the “now builds” list, moved instead under Place::Field/ Place::Deref below, where it belongs now that getchar is not what stops it:

GapBlocksWhere it already shows up in this document
Bare Expr::Alloc/Expr::Boxed/Expr::Unboxed (single-value allocation, arena or heap)tests/accept/arena_roundtrip.cho — this backend’s own boundary fixture, no benches/ program§7.6, §7.8
Place::Field/Place::Deref (writing through a reference)Closed, §7.11: benches/game/revcomp.cho now builds§5’s fourth slice, named and deferred there; not connected to a benches/ program until §7.9
arg_countbenches/game/{binarytrees,fannkuch}.cho§7.3
Type::Floatbenches/game/spectral.cho, fasta.cho’s two float-filled alloc_slice calls§7.3

Four gaps, one of them (Place::Field/Place::Deref) larger than anything else left in this table — every other row is a builtin, a bounds check, or a single-value allocation shape; this one is general pointer arithmetic into an arbitrary referent, the same size of gap matching through a reference has been since §5.

7.11 Place::Field/Place::Deref, closed — and everything sitting behind the same name

The gap §7.10’s table called one row was, on contact, four IR nodes on the write side and five on the read side, none of them named until this slice actually built against revcomp.cho:

  • Writes: Place::Deref (*r = e, the whole referent replaced) and Place::Field (r.x = e, one field through a reference) — the two named in §5’s fourth slice.
  • Reads, never previously named on their own because nothing had tried building a program that used them through this backend: Expr::Deref (*r), Expr::FieldRef (r.x, a field’s value through a reference), Expr::FieldAddr (r.x where the field is res, a reference to the field rather than a copy — docs/reading- references.md §2.0), and their tuple-shaped counterparts, Expr:: TupleFieldRef/Expr::TupleFieldAddr.
  • Expr::Tuple/Expr::TupleField — building an owned tuple and reading one of its components — were also never built, found only because tests/accept/tuple_roundtrip.cho was reached for as a test fixture and refused before it could serve as one.

All nine share one new helper, field_offset (and its tuple-shaped twin, tuple_field_offset): the byte offset of one field among a struct’s (or tuple’s) leaves, computed once and used by every one of Expr::Field/FieldRef/FieldAddr and Place::Field alike — the same arithmetic cancho-codegen’s own write doc comment already named (“the same field arithmetic as reading one, running the other way”), collected into one function here rather than repeated five times. Every address is ptr arithmetic throughout (getelementptr/load/store, no ptrtoint), the same idiom §7.5’s arena code and §7.9’s element_address already established.

Building revcomp.cho against this found one more gap underneath, the same way every slice since §7.3 has: write_bytes/write_err (docs/bulk-io.md §3), needed once std.buffer’s own field writes stopped being the reason it refused. fwrite through stdout/ stderr — FILE * variables in libc, so the symbol is the address of the pointer and the stream is one load away, and the symbol differs by platform (__stdoutp/__stderrp on Darwin, stdout/stderr elsewhere) exactly the way cancho-codegen’s own emit.rs already resolves it.

revcomp.cho itself now builds, runs, and matches the Benchmarks Game’s own published output on --backend llvm — checked against benches/game/revcomp-1000.txt, the same reference benchmarks.rs’s fasta_and_reverse_complement_print_the_published_ answer already checks --backend cranelift against. Two more real fixtures needed nothing but what this slice built: tests/accept/deref_roundtrip.cho (the int/Point reference round-trip named above) and tests/accept/tuple_roundtrip.cho (tuples through a reference). All three are new differential tests in backends.rs; deref_roundtrip.cho also gets a crate-level test, matching every earlier slice’s own fixture-plus-differential pair.

Measured (piped a 1,000,000-record FASTA input generated by --backend cranelift’s own fasta.cho, since fasta.cho itself still needs Type::Float to build on --backend llvm): revcomp.cho is 42%–46% faster on --backend llvm, in the same range every other kernel in this document has landed in. objdump finds 23 SIMD instructions in the --backend llvm object — real, but far short of scan_wrapping.cho’s 409 (§7.5): revcomp.cho’s read side is getchar, one byte at a time (bulk-io.md §3.3 — there is no bulk read to reach for), which benchmarks-game.md §7 already found is what keeps this exact program’s Cranelift-vs-C ratio inside the ordinary range rather than at either extreme; the same ceiling applies here.

Still refused, and not to be confused with what this slice built: matching through a reference (Stmt::Match’s own by_reference flag) is a different feature from reading or writing a field through one — it needs a match arm to bind a pointer into the scrutinee rather than a copy of it (docs/reading-references.md’s address-only binding mode), which is its own, still-unbuilt address arithmetic over an enum’s variant layout. match_stmt still refuses it outright, in the same words §5 always has.

7.12 What still blocks the rest of benches/, updated again

Place::Field/Place::Deref and the read-side cluster behind it move out of §7.10’s table, closed. Matching through a reference is added as its own row for the first time — it was always refused, but §7.10 and earlier never separated it from the field/deref gap it happened to sit next to; now that the latter is closed, the two are visibly different sizes of problem, not one:

GapBlocksWhere it already shows up in this document
Bare Expr::Alloc/Expr::Boxed/Expr::Unboxed (single-value allocation, arena or heap)tests/accept/arena_roundtrip.cho — this backend’s own boundary fixture, no benches/ program§7.6, §7.8
arg_countbenches/game/{binarytrees,fannkuch}.cho§7.3
Type::Floatbenches/game/spectral.cho, fasta.cho’s two float-filled alloc_slice calls§7.3
Matching through a reference (Stmt::Match’s by_reference)No benches/ program reaches it yet§5’s fourth slice; named separately from field/deref access in §7.11

Three gaps with a benches/ program actually behind them, plus one with none yet. Type::Float is now the only row blocking more than one program (spectral.cho and fasta.cho both); arg_count blocks two of its own. Closing either finishes a benches/ program outright, the same way §7.9 and §7.11 each did.

7.13 arg_count/arg, closed — and fannkuch.cho builds, binarytrees.cho does not

argc/argv as main was handed them, stashed once into module-local storage (@lexs_argc, @lexs_argv, both internal global) before the entry function’s own body runs — cancho-codegen’s own emit_c_main does the identical thing a globals-table-and-Linkage::Local step apart (docs/arguments.md §3). arg_count is a load; arg is the same bounds check every other indexing operation in this backend already makes (index >= argc traps, one icmp uge covering both a negative index and one past the end), then argv[n] read back and its length computed with libc’s own strlen — the C interface’s NUL is not part of the value this backend hands back, matching docs/ arguments.md §3.2.

Both of §7.12’s named targets were tried, and only one of them builds. fannkuch.cho builds, runs, and matches Cranelift exactly, both with no argument (the fallback path) and with one (arg_count and arg together, checked against a real 8, not only assumed from reading the code). binarytrees.cho still refuses — past arg_count/ arg it reaches build’s own box[h](Tree::Node { .. }), bare Expr::Boxed, §7.12’s other still-open row and not touched here. The same shape §7.9 found with revcomp.cho and §7.6/§7.8 already named for this one: a gap can block more than one program and close only some of them, and the table has to say which.

tests/accept/arguments.cho — a ready-made fixture, never built through this backend before — gets a crate-level test (no arguments, its own contract) and a CLI differential test (also no arguments, the “one argument, its own name” path). fannkuch.cho gets its own CLI differential test passing a real 8, the first test in this module to give a compiled program an actual argument rather than only piped stdin or none at all.

Measured (fannkuch(11), two runs): --backend llvm is 22%–28% faster than Cranelift, in the same range every other kernel in this document has landed in. objdump finds 16 SIMD instructions in the object — fannkuch’s own array rotation is not the loop shape scan/sieve vectorise on, so this is a smaller number than those, consistent with §7.11’s own reading of revcomp.cho’s similarly modest count: how much a kernel vectorises depends on its own loop shape, not only on whether its traps are observable.

7.14 What still blocks the rest of benches/, updated again

arg_count/arg moves out of §7.12’s table, closed — but binarytrees.cho does not move with it, since bare Expr::Boxed was always the bigger of the two gaps standing between it and this backend, arg_count/arg merely the first one reached:

GapBlocksWhere it already shows up in this document
Bare Expr::Alloc/Expr::Boxed/Expr::Unboxed (single-value allocation, arena or heap)tests/accept/arena_roundtrip.cho, benches/game/binarytrees.cho (build’s own box[h](Tree::Node {..}), found in §7.13)§7.6, §7.8, §7.13
Type::Floatbenches/game/spectral.cho, fasta.cho’s two float-filled alloc_slice calls§7.3
Matching through a reference (Stmt::Match’s by_reference)No benches/ program reaches it yet§5’s fourth slice; named separately from field/deref access in §7.11

Two gaps with a benches/ program behind them, one with none yet. Type::Float is still the only row blocking more than one program; bare Expr::Alloc/Expr::Boxed/Expr::Unboxed now blocks a real benches/ program for the first time, not only this backend’s own boundary fixture.

7.15 Bare Expr::Alloc/Expr::Boxed/Expr::Unboxed, closed — binarytrees.cho builds too

Single-value allocation, arena or heap: alloc[a](value) is bump plus one store_leaves call, the exact helper alloc_slice already opened (§7.5) minus its fill loop; box(h, value) is boxed_slice’s own malloc-and-null-check minus its fill loop; unbox(h, b) is one load_leaves followed by one free, the load ordered first because a freed pointer is not a valid read afterwards — cancho-codegen’s own alloc/boxed/unboxed (body/memory.rs) are the identical shape, each one a smaller version of a primitive this backend had already built for a slice’s many elements. All three share one new value_bytes helper (leaves_of(ty).len() * 8), the whole-value counterpart of stride_of’s per-element version — a whole value is never a bare byte the way a slice’s element can be, so it needs none of that function’s special case.

tests/accept/arena_roundtrip.cho (alloc) and tests/accept/ box_roundtrip.cho (box/unbox) were both ready-made fixtures, checked against Cranelift byte-for-byte, each getting a crate-level test and a CLI differential test. binarytrees.cho — §7.13’s other named target, left refusing there on build’s own box[h](Tree::Node {..}) — now builds too, checked both with no argument (the fixture’s own //~ STDOUT depth) and with a real one, matching Cranelift exactly in both cases and exercising alloc/box/unbox together with arg_count/arg rather than either capability alone. §7.13’s own finding — a gap can block more than one program and close only some of them — resolves the other way this time: the second, deeper gap it found closes too, and both of its named targets now build.

Not measured for performance: binarytrees.cho is allocation-and- freeing-bound rather than arithmetic- or memory-scan-bound, the kind of kernel this document has not yet built a --with-malloc comparison for, and objdump’s own SIMD count is not the interesting number for a kernel with no loop body to vectorise. A future slice measuring allocator-bound kernels specifically is left to docs/llvm-backend.md §6’s own “Open” list rather than invented here.

7.16 What still blocks the rest of benches/, updated again

Bare Expr::Alloc/Expr::Boxed/Expr::Unboxed moves out of §7.14’s table, closed — and unlike §7.13’s arg_count/arg, this one takes both of its named benches/ targets with it, not only one:

GapBlocksWhere it already shows up in this document
Type::Floatbenches/game/spectral.cho, fasta.cho’s two float-filled alloc_slice calls§7.3
Matching through a reference (Stmt::Match’s by_reference)No benches/ program reaches it yet§5’s fourth slice; named separately from field/deref access in §7.11

One gap left with a benches/ program behind it, one with none yet. Type::Float is now the only thing standing between this backend and the rest of the Benchmarks Game suite in benches/game/ — closing it finishes both spectral.cho and fasta.cho at once, the same way arg_count/arg very nearly did with fannkuch.cho/binarytrees.cho.

7.17 Type::Float, closed — and a structural gap the surface reading missed

float is one leaf, LKind::F64, exactly like int — but the straightforward reading of that fact (“add an F64 arm to a few matches”) turned out to be wrong before any code was written. Cranelift knows an operand is a float dynamically, from the SSA Value itself (self.builder.func.dfg.value_type(a) == types::F64), because every Cranelift value is intrinsically typed. This backend’s LValue is not: it is a bare Const(i64) | Reg(String), with nowhere to keep a type tag, and Expr::Bin’s own IR node carries no type annotation either. binop/compare had no branch point to detect a float operand and take a different path at all.

The fix is scalar_kind, a new structural helper: given an expression, it answers what LKind its value has without evaluating it — walking the same shape the type checker already agreed on (Expr::Load reads a slot’s own kind, Expr::Bin recurses into lhs unless the operator is a comparison, Expr::Call reads a builtin’s fixed return kind or a function’s declared one, and so on), erring rather than guessing on anything it does not recognise. binop and Expr::Neg call it once, ahead of evaluating their operands, to choose between the checked-int path already built and the new unchecked-float one.

Once that existed, the rest was direct, each piece checked against docs/floating-point.md’s own contract:

  • Literals (Expr::Float(bits)) are stored as bits already, so LValue gets a new FConst(u64) variant printed in LLVM’s hex float syntax (0x3FF0000000000000, not 1.0) — the constant this backend emits is the bit pattern the parser read, never a decimal round-trip through f64’s Display.
  • Arithmetic (fadd/fsub/fmul/fdiv) is never checked — a float has no analogue of the traps checked_arith/checked_div guard against, “the whole of §2.1’s argument in code.”
  • Comparison is the ordered fcmp predicates (oeq/olt/…), false whenever either side is NaN, except !=, which is une (unordered-or-not-equal) rather than the ordered one — the one place ==’s and !=’s IEEE semantics are not simple negations of each other, the same riddle is_nan’s own x != x is named for.
  • float_of is sitofp, unchecked. truncate has no direct LLVM counterpart — fptosi is poison on NaN/±inf/out-of-range, unlike Cranelift’s fcvt_to_sint, which traps in hardware, and llvm.fptosi.sat is the saturating form the design doc already calls the wrong, silently-incorrect answer — so truncate is three explicit checks ahead of the instruction (NaN, x ≥ 2^63, x ≤ -2^63, the second bound chosen to include exactly -2^63 even though it is representable, matching Cranelift’s own reasoning about cvttsd2si’s “integer indefinite” collision), the same ahead-of-the-instruction shape checked_div already uses.
  • bits_of is bitcast plus a select over an fcmp uno NaN-test, canonicalising every NaN to 0x7ff8000000000000 — x86-64 and aarch64 disagree on the sign bit 0.0 / 0.0 produces, so a bare reinterpretation would be the one target-dependent value in the language.
  • sqrt is one call to @llvm.sqrt.f64, correctly rounded by construction, declared unconditionally in the module header alongside the checked-arithmetic intrinsics.
  • Expr::Neg, found unhandled entirely while building this (it fell into the catch-all refusal — nothing in the accepted-test suite had exercised non-constant negation before now): fneg for float, total including on NaN and on zero, where it produces -0.0; 0 - x, checked, for int, the one place integer negation overflows. Unrelated to floats themselves, but the same dispatch point scalar_kind was built for, so closed alongside rather than left as a fourth thing this document would otherwise have to reopen later.

Both of Type::Float’s named targets build and match Cranelift exactly: spectral.cho (accumulation loops, float_of, sqrt) and fasta.cho (many-digit decimal literals not exactly representable in binary, and a real runtime float comparison — cumulative[idx] < r — picking a base, not just arithmetic), the second checked against the Benchmarks Game’s own published output the same way revcomp.cho was in §7.11. tests/accept/floating_point.cho — the design doc’s own dedicated fixture, literals through bits_of/is_nan and the sign of -0.0 — matches too, byte for byte. It could not become this crate’s own crate-level test the way every other closed gap’s fixture has, because it imports std.io for its printing and the crate-level harness’s bare cancho_ir::lower has no --std source injection to resolve that; a self-contained program threading an unfoldable runtime value through float_of/truncate/sqrt/bits_of/is_nan stands in for it there instead, and the CLI differential suite covers the real fixture directly.

Measured: spectral(1500) is 45%–56% faster on --backend llvm, objdump finding 62 SIMD instructions; fasta(1000000) is 10%–20% faster, 46 SIMD instructions — both real vectorisation, neither the checked-arithmetic ceiling this document has measured against until now, because float arithmetic was never checked to begin with.

One more consequence, unrelated to what floats compute: this slice’s own diff pushed emit.rs past CONTRIBUTING.md’s 2,000-line file budget. Split by concern, the same shape cancho-codegen’s own body/ directory already is: body/mod.rs (the shared FuncEmitter scaffolding — new, emit, stmts), body/arith.rs (scalar_kind, binop and everything checked/unchecked arithmetic), body/control.rs (if/while/match/borrow), body/memory.rs (allocation and addressing), body/expr.rs (every other Expr and every call). A pure reorganisation — no line of logic changed, confirmed by rebuilding every fixture and benches/ program this document already tracks before and after.

7.18 What still blocks the rest of benches/

Type::Float moves out of §7.16’s table, closed. One gap remains, and it has had no benches/ program behind it since §5 first named it:

GapBlocksWhere it already shows up in this document
Matching through a reference (Stmt::Match’s by_reference)No benches/ program reaches it yet§5’s fourth slice; named separately from field/deref access in §7.11

Every benches/ program this document tracks now builds on --backend llvm. What is left is a feature nothing in benches/ happens to need: a match arm binding a pointer into the scrutinee rather than a copy of it (docs/reading-references.md’s address-only binding mode), still its own address arithmetic over an enum’s variant layout, not yet built. tests/accept/match_a_reference.cho is this backend’s own boundary fixture for it now, the same role arena_roundtrip.cho and floating_point.cho each held in turn.

7.19 Matching through a reference, closed — every documented gap is now closed

Unlike every other slice in this document, this one was close to what it looked like on the surface — because the two slices that came before it had already built everything it needed. §7.11 built the getelementptr-address idiom (Expr::FieldAddr: compute an address, don’t load); §5’s fifth slice built variant_layout and the by-value half of bind_payload. Matching through a reference turned out to be those two facts composed, not a third thing.

The whole change is in body/control.rs. A reference is always one pointer leaf (Type::Ref’s own rule in leaves_into), so the scrutinee evaluates identically in both modes — only reading the tag out of it differs: by value it is already the loaded tag; by reference it is the scrutinee’s own address, and the tag is one more load i64 away. bind_payload gained the mirror image: by value, each bound leaf is copied out of already-loaded values; by reference, a binding gets the address of its payload position instead — variant_layout’s own leaf-offset, scaled to bytes and added to the scrutinee’s pointer via getelementptr, then that address (not a value) stored into the one-leaf slot a reference always occupies. A _ binding still advances past its payload position in both modes; there is simply nowhere to put the value, or the address. One difference from Cranelift’s own debug_assert_eq! on the “every by-reference binding is one leaf” invariant: this backend returns an Err instead, matching its own no-panic convention rather than a panic that would only fire in debug builds.

Both fixtures this document already knew about build and match Cranelift exactly. tests/accept/match_a_reference.cho — read three times through a shared reference, freed once — matches byte for byte. examples/tree.cho, the richer target docs/reading-references.md §4 names by name: a three-field variant (Box[Tree], int, Box[Tree]) matched by reference three separate ways — contains binds and recurses through all three; deepest discards the first position with _, exercising the offset bookkeeping past a skipped payload with an asymmetric variant shape; tally binds and recurses through all three, folding the results into a multi-leaf struct Walk return — matches too. Neither fixture needed anything beyond what bind_payload and match_stmt already gained. A third case, a &! unique-reference match writing through a bound payload (*n = *n + 1), has no fixture in tests/accept/ or examples/ — the address arithmetic does not distinguish shared from unique, so this was checked by hand instead, in this slice’s own session: it builds, runs, and matches Cranelift.

Every gap this document names a benches/ or tests/accept/ target for is now closed. The “outside this backend” boundary fixture moves a fourth time: tests/accept/bytes_to_c.cho, refusing on a foreign call (extern fn) — Ffi/extern fn was always the next-named gap in lib.rs’s own module header, just never connected to a fixture until matching through a reference stopped being in the way of naming it. What remains unbuilt — Ffi/extern fn, Net, and the handful of builtins beyond the ones this document’s fifteen slices have closed — has no benches/ program or tests/accept/ fixture asking for it today; closing any of them is a future slice with no forcing function behind it yet, the same position bare alloc/Type::Float/matching- through-a-reference each held before something connected them to a real target.

7.20 listen/accept, closed — the first crack in Net itself

Not a benches/-driven slice like every one before it: nothing in benches/ or tests/accept/ asked for Net, so this one was scoped directly instead, smallest sub-piece first, ahead of connect/bind rather than after them.

docs/listen.md §6 is why listen/accept are the smallest piece of the four: neither one takes a capability. A fd’s authority is proved once, at bind (net_in’s bound is checked by equality against the capability’s own bound before the syscall runs); listen, accept, and every later read/write/close on the same fd take a plain int and nothing else — the same reasoning docs/net.md §4.1 already gives for why the resolver is a builtin rather than a lex-os facility. That makes both ordinary fixed-signature libc calls, no different in shape from Sqrt: narrow the int argument(s) to i32, call, sext the i32 result back to i64. emit.rs gained two unconditional declares (declare i32 @listen(i32, i32) and declare i32 @accept(i32, ptr, ptr), the trailing ptr null, ptr null the same “the peer address is ignored” choice examples/serve/serve.cho already made by hand); body/expr.rs gained the two match arms, mirroring cancho-codegen’s own body/expr.rs arms line for line.

What this slice did not build, and could not have without building far more first: a real bound fd. bind is a dedicated Expr::Bind IR node (not a Callee::Builtin call), and this backend still refuses it outright — confirmed empirically, not assumed: pointing a real edition 2; program’s bind(n, port) at --backend llvm produces the documented “not part of the LLVM backend yet” refusal, naming Bind specifically, while the same source runs to completion on --backend cranelift. Ffi/extern fn is refused too (§7.19’s own finding), so there is no back door to a real socket either — examples/serve/serve.cho’s own hand-rolled socket+bind+listen+ accept sequence, which is how a real fd gets tested against Cranelift, is not reachable from this backend at all yet.

Tested the other side instead. tests/accept/ listen_accept_bad_fd.cho calls listen(999, 16) and accept(999) against a deliberately invalid fd — never opened by anything — which fails the same way, EBADF, on any host, with no real socket and no live connection required. Both backends agree (backends.rs); the LLVM path also gets its own self-contained crate-level test (crates/cancho-codegen-llvm/src/tests.rs), the same “fixture plus differential plus crate-level” trio every earlier slice left behind.

Still refused: connect, bind, and Ffi/extern fn — three of Net’s four builtins, and the only way to obtain a real fd at all, so listen/accept remain untestable here against a real, successful accept until at least one of the other two lands. bind is next, smallest-first (connect’s getaddrinfo-based host resolution is the larger of the two remaining pieces, per docs/connect.md and crates/cancho-codegen/src/body/net.rs’s own ~150 lines); no forcing function names an order beyond that.

7.21 bind, closed — a real fd, and the first slice with no phi-free shortcut

bind folds socket, setsockopt(SO_REUSEADDR) and bind into one call, building the same struct sockaddr_in cancho-codegen’s own bind builds by hand and examples/serve/serve.cho builds by hand again — family bytes (2, 0, the same “BSD reads family 0 as AF_INET too” fact docs/connect.md §3 already measured, so no platform branch is needed here either), the port big-endian, then INADDR_ANY. emit.rs gained four more unconditional declares (socket, setsockopt, bind, close); the byte-store loop reuses the same getelementptr i8/store i8 idiom Expr::Bytes and the arena code already established.

The one genuinely new shape: socket/bind can each fail, and a failure returns -1 rather than trapping — only a bound mismatch traps, checked first, before any syscall runs, the same order cancho-codegen’s own bind checks it in. Every slice before this one that needed a value conditional on a runtime test either trapped (never returning, so nothing to merge) or was a Stmt-level if writing into an already-alloca’d var. bind is the first expression needing a value that depends on which of three paths ran — body/net.rs’s new store_byte plus a plain alloca i64 result cell, written in each of the three blocks and loaded once at the merge label, following the same “no phi, a local is already memory” rule if_stmt/while_stmt established rather than introducing a new one.

Checked against a real accepted connection, not only against a description of one. crates/cancho/tests/conformance/backends.rs gained the_two_backends_bind_and_accept_a_real_connection: for each backend, build a listener that binds a free loopback port, listens, and accepts, spawn it, connect a real TcpStream from the test process, and check the process exits 0. --backend llvm could not have passed this before this slice — there was no way to get a real fd out of it at all, Ffi/extern fn and connect both still being refused — so this is the first Net-capable program this backend has ever actually run, not a bad-fd stand-in like §7.20’s own fixture. read/write on the accepted connection are left out on purpose: covering them needs extern fn, and adding it here would test a boundary this slice does not touch. The wrong-port trap (docs/listen.md §6.1) is checked at the crate level instead (binding_the_wrong_port_traps_with_sigill), by signal rather than only by status.code() == None, the same discipline every other trap test here already follows — Cranelift and this backend agree, SIGILL both times.

Still refused: connect and Ffi/extern fn. connect needs getaddrinfo-based host resolution, genuinely larger than anything bind needed; Ffi/extern fn remains the boundary named since §7.19. Both are what stand between this backend and a program that reads or writes what it accepts, not only accepts it.

7.22 connect, closed — the last of Net’s four builtins

Genuinely the larger of the two remaining pieces, as §7.21 predicted: checked_host (mirroring cancho-codegen’s own function of the same name) is a loop, not a straight-line byte-store sequence — copying the dialled name into a 256-byte stack buffer while checking, byte by byte, that the prefix inside the capability’s bound matches, and NUL- terminating the result for getaddrinfo. Built the same “no phi” way every loop in this backend already is: a cursor in an alloca i64 cell, advanced and reloaded each iteration, rather than a block parameter — body/memory.rs’s own arena-fill loop is the precedent, not a new one.

connect itself reuses checked_host, then builds a struct addrinfo hints (IPv4, TCP, everything else zeroed) on the stack, calls getaddrinfo, patches the resolved sockaddr’s port bytes the same big-endian way bind already does, and calls socket/connect. Three independent failure points — resolution, socket, connect itself — none of which trap, so bind’s own “one alloca i64 result cell, written in every branch, loaded once at the merge label” shape carries over unchanged, now with three failure paths funnelling into it instead of two. store_field/load_field are store_byte’s general form, needed once a struct has i32 and ptr fields alongside i8s. emit.rs gained three more unconditional declares: getaddrinfo, freeaddrinfo, connect.

Checked against a real accepted connection, both directions. crates/cancho/tests/conformance/backends.rs’s the_two_backends_connect_to_a_real_listener: for each backend, build a client that connects to a plain std::net::TcpListener (standing in for the peer, since connect does not care what accepted it) and check both the client’s exit code and that the accept actually completed — isolating the half connect adds over bind, not re-checking bind’s own machinery. Manually, this slice’s own session also built an all-LLVM pair — a bind/listen/accept listener and a connect client, both --backend llvm, talking over real loopback — the first time two programs built by this backend have ever talked to each other. The two bound-mismatch traps (docs/connect.md §10.1: host outside the prefix, then the exact-port check) are checked at the crate level, by signal, the same way bind’s own wrong-port trap already is — Cranelift and this backend agree, SIGILL every time.

Still refused: Ffi/extern fn, named since §7.19. Net itself is fully built on --backend llvm — listen, accept, bind and connect all lower (corrected later: bind and connect lowered only outside an operator; see the status note at the top) — but a program that wants to read/write what it accepted or connected to still needs extern fn for that, the same way examples/serve//examples/fetch/ do on Cranelift. Closing Ffi/ extern fn is what would let a program like examples/seek/’s next-door neighbour actually move bytes over a socket on this backend, not merely open one.

Correction. This section, and this file’s module-header summary in lib.rs, called Ffi/extern fn “the only gap left” three times over §7.20–§7.22. That was never measured — Fs (fs_read/fs_write/ open_read/file_read) has been unbuilt on this backend the entire time, and nothing in this document named it, because no slice had tried building a program that used it. §7.23 found this the first time it checked a real Fs-using program (examples/seek/) against the backend it had just declared complete. Corrected here rather than quietly, the same way §3.3 and §7 corrected an earlier wrong claim in place instead of deleting the sentence that made it.

7.23 Ffi/extern fn, closed — and the correction finding it forced

An import under the symbol the declaration named, mirroring cancho-codegen’s own Callee::Extern arm: a capability parameter carries no data and never reaches C (crosses_to_c, copied over unchanged); everything else crosses at cancho’s own widths — an int is i64 here whatever the C function’s own parameter width is, the same choice cancho-codegen’s own emit.rs makes and docs/reach.md §3 documents. emit.rs gained a new pass declaring one declare per distinct symbol in program.externs, deduplicated (docs/modules.md §3: an extern fn name is scoped to its module, but the C symbol is not, so two declarations can share one) — computed with the same leaves_of/crosses_to_c pair the call site uses, so the two can never disagree. Type::Unit (no -> Type written) reads as void; it is otherwise unwritable, so this is the one place it is read rather than produced.

The call site reuses Callee::Fn’s own call-and-unpack shape, factored out as emit_call once Callee::Extern needed the identical “void to nothing, one leaf to a register, more than one to extractvalue” logic — a real, if small, simplification found while building this slice rather than a plan going in.

One structural gap surfaced immediately: scalar_kind (§7.17) had no arm for Expr::Call { callee: Callee::Extern(_), .. }, so a foreign call’s result could not be told apart from a float — needed the moment a program built here uses one in an expression rather than only as a statement. Closed the same way Callee::Fn’s own arm already was: the declared return type’s leaf, read structurally rather than evaluated.

Checked against tests/accept/bytes_to_c.cho, not a fixture written for this slice. It was already checked in — GNU’s own write(fd, ptr, len), both a string literal and an arena-allocated slice crossing as the pointer-and-length pair docs/strings.md §6 describes — and its existing //~ STDOUT///~ EXIT directives are now met on --backend llvm unchanged. backends.rs’s new the_two_backends_agree_on_bytes_to_c checks both backends against it; crates/cancho-codegen-llvm/src/tests.rs’s new extern_fn_labs_computes_the_real_answer is the plain-int case, checked against the real answer (labs(-5) == 5) rather than only against “it built”.

What checking real programs against this slice actually found: two things this document had not named.

  1. Fs is still unbuilt, per the correction above — found trying examples/seek/’s own read_file against --backend llvm and getting `OpenFile { .. }` is not part of the LLVM backend yet. The “outside this backend” boundary fixture moves a sixth time, from tests/accept/bytes_to_c.cho (now inside) to tests/accept/ file_handle.cho, refusing on fs_write’s own FileOp — checked at the crate level and through the CLI, the same two ways every earlier move was.
  2. A user-declared extern fn can collide with this backend’s own fixed libc declarations. examples/serve/serve.cho declares extern fn socket[&f](ffi, domain: int, kind: int, proto: int), crossing every int at i64; bind (§7.21) already declared @socket unconditionally at libc’s own i32 width, for its own internal use. Two declared signatures, one linker symbol — clang -c correctly refuses the module (invalid redefinition of function 'socket'), a located, non-crashing refusal, not a silently wrong build. Not a new bug: docs/ROADMAP.md’s own entry for #92 already recorded the identical exposure on Cranelift — close declared at two different widths by bind’s own failure path and by a program’s extern fn close, “not fixed [t]here […] a known exposure shared with open_read/fs_read/fs_write since before Net existed.” This slice makes the same exposure visible on --backend llvm too, for the same reason and left unfixed for the same one: none of serve.cho/fetch.cho/report.cho/collect.cho needs a different socket/bind/connect/listen/accept/ setsockopt/close than this backend already declares for Net itself, so the fix is narrowing what each program actually asks for, not this backend’s declare list — the same shape the Cranelift note already reached for the same reason.

Between the two: Ffi/extern fn lowers, and docs/agent-tools.md’s own examples/seek/ is closer to portable across both backends than it was, but Fs is what actually stands between it and --backend llvm today — not Ffi, which this document spent three slices calling the last gap before checking.

7.24 Fs, closed — and a comparison bug three slices had never reached

(Corrected later: fs_read/fs_write closed as statements and let initialisers only, not as operands; see the status note at the top.)

checked_path (mirroring cancho-codegen’s own function of the same name) is checked_host’s loop with two things added: a /-boundary check on the way out (/tmp does not contain /tmpevil) and a ..- traversal refusal inside the loop, both built the same “no phi, trap_if on the escape” shape connect’s own loop already established — not a new mechanism, one loop body slightly larger than the last. file_op (fs_read/fs_write) and open_file (open_read) both reuse it; read_file (file_read) loads the handle’s one leaf and calls read. errno — a function in every modern libc, because it has to be per-thread — is one more platform-symbol split (__errno_location on glibc, __error on Darwin), the same shape stdout/stderr’s own split already is. emit.rs gained a new Type::Named(def, _) if def.0 as usize == PRELUDE_FILE arm: a file handle is one i64 leaf and nothing else, the same special case Box already has, needed because File is an opaque prelude type program.type_info has no entry for — Opened/Read need no such arm, being ordinary prelude enums the general TypeInfo::Enum case already scalarises correctly.

Checked against tests/accept/file_handle.cho, the boundary fixture itself. Unmodified, it now builds and runs on --backend llvm, matching its own //~ STDOUT directives exactly: fs_write writes a file, open_read opens it, file_read reads it twice — once for the twelve bytes, once past the end, proving Read::End answers End again rather than repeating the last byte count — and file_close ends it. backends.rs’s new the_two_backends_agree_on_file_handle checks both backends against it.

One collision found immediately, and fixed rather than added to the known-exposure list. read/write, declared unconditionally for Fs’s own use, broke tests/accept/bytes_to_c.cho — the very fixture §7.23 had just proven — because that program declares its own extern fn write, crossing every int at i64, where Fs’s own write call needs libc’s true 32-bit fd. Unlike socket/bind/connect’s own already-documented exposure (§7.23, docs/ROADMAP.md #92), this one regressed a program that had been working a slice ago, not one that had never worked — so read/write are now declared only when program.externs does not already claim the symbol, letting a program’s own declaration stand in for this backend’s internal one. open/creat are not guarded: no example declares either for itself, so there is nothing yet to collide with, and guarding everything unconditionally would be the cross-cutting fix docs/ROADMAP.md #92 already declined to make, not this slice’s to make unasked.

The second finding is not about Fs at all. Building examples/cut/ and examples/seek/ against this slice — both reach Fs only incidentally, through std.flags’s own argument parsing — found clang refusing the emitted module with an ill-typed comparison: %t20 (an i8) compared as i64. compare had always hardcoded icmp {cc} i64 regardless of what its operands actually are, silently correct for int — already i64 — and silently wrong for byte/ bool (i8), and nothing had caught it in eighteen prior slices because nothing this document tracks compares a raw byte: std.bytes.compare converts through int_of first, sieve/scan/ fannkuch compare ints throughout, and match’s own tag comparisons are already i64. std.bytes.find’s text[at + i] != needle[i] — called by std.flags.named, called by both cut.cho and seek.cho — is the first comparison this document’s own suite reaches that is not. Fixed by threading binop’s already-computed lhs_kind through to compare, the same value float_binop was already being dispatched on one line above it. tests/accept/bytes_to_c.cho did not trip this only because a &r [byte] crossing to C never compares its bytes to anything; examples/cut//examples/seek/ compare them constantly. Checked directly: byte_comparison_uses_the_right_width (crates/cancho-codegen-llvm/src/tests.rs), != and == on two runtime bytes an int literal cannot fold away.

The “outside this backend” boundary fixture moves a seventh time, from tests/accept/bytes_to_c.cho (now inside, since §7.23) to tests/accept/static_data.cho, refusing on Expr::Static — found the same way Fs was, by checking whether this backend’s own Expr match was actually exhaustive rather than assuming it, once every named gap this document had tracked was closed. docs/compile-time-data.md’s whole feature — a static block evaluated at compile time, its result read as ordinary data — has no --backend llvm support yet, and no slice before this one had tried a program that used one.

7.25 Expr::Static and Expr::BitNot, closed — and the boundary that ran out of fixtures to move to

Expr::Static’s data does not need building twice: cancho-ir already evaluates every static at compile time, into Program::statics: Vec<StaticValue> — one entry per static, holding its element type and its values as i64s (a float’s bits, the same shape Expr::Float already holds one in). Both backends read the same Vec; only laying it out and referencing it is backend work. emit_module gained a loop mirroring cancho-codegen’s own emit.rs: one private unnamed_addr constant [N x i8] global per static, packed at stride_of’s own stride — one byte per element for Type::Byte, eight for int/bool/float, the same rule alloc_slice’s own byte-copy already follows, so a [byte] static comes out packed the same way a string literal or any other byte slice does. Expr::Static itself is then only a reference to an already-declared symbol: @lexs_static_{name} and the element count, the same (pointer, length) pair every slice in this backend is. Unlike a string literal, whose bytes_lit declares a fresh global at every occurrence (docs/strings.md §8 leaves interning open), a static’s global is declared exactly once in emit_module, because a 512 KB table is not a greeting: two readers of the same static get two references to the one symbol, never two copies of the table.

Checked against tests/accept/static_data.cho, the boundary fixture itself, unmodified. It builds and runs on --backend llvm, matching Cranelift byte for byte: a table built by a while loop, a [byte] table shifted through int_of/byte_of, a second [int] table built by calling a pure function (twice) against the first — proving a static body may call an ordinary function, the same evaluator that folds factorial(5) running it — and a pure function (square_of) that reads a static directly, with no parameter threading it in. backends.rs’s new the_two_backends_agree_on_static_data checks the same claim through the CLI, and crates/cancho-codegen-llvm/src/ tests.rs’s new a_static_table_is_built_at_compile_time_and_reads_ back_correctly checks a loop-built table numerically (0 + 1 + 4 + 9 − 14 = 0) rather than only “it printed the right thing”, so a wrong symbol name or a wrong stride would fail loudly rather than by coincidentally printing something plausible.

Expr::BitNot was not part of this backend at all, found while checking whether it actually was. Closing Expr::Static left this document ready to claim, again, that every named gap was closed — the same claim §7.20 through §7.22 made three times running before §7.23 found it false. This time the check was direct: walk every variant cancho_ir::ir::Expr declares and confirm body/expr.rs’s expr match has an arm for each one, rather than trusting the running list of named gaps to be complete. Expr::BitNot had none. tests/accept/ bitwise.cho already exercises ~0 and has since this backend’s early slices, so a Cranelift-only reader would reasonably have assumed the operator worked everywhere bitwise.cho does — but ~0 is two literals, folded to Expr::Int(-1) by docs/compile-time.md §3 before codegen ever runs, so bitwise.cho alone was never capable of catching a missing BitNot arm on this backend. Confirmed directly: a self-contained program applying ~ to putchar’s own runtime echo (a value the checker cannot fold) hit `BitNot(Load(Slot(n)))` is not part of the LLVM backend yet, the same located refusal every other gap in this document produced, not a panic. docs/bitwise.md §1 restricts the whole operator set to int, so BitNot’s fix is one line: the same xor, %flipped, LValue::Reg shape Expr::Not’s arm right above it already has, at i64 and -1 in place of Not‘s i8 and 1 — the two operators’ entire difference, since Not’s operand is always exactly 0 or 1 and BitNot’s is not.

Checked against tests/accept/bitwise.cho, run against this backend for the first time. backends.rs’s new the_two_backends_agree_on_ bitwise matches Cranelift’s output for every operator on that page — &, |, ^, <<, >>, the arithmetic-shift and no-trap-on-value rules, and the precedence case — byte for byte; bitwise.cho’s own ~0 had run against Cranelift alone until now (corpus.rs, no --backend flag), never against this one. Because that fixture’s only ~ use folds away, bitnot_flips_every_bit_not_just_the_low_one (crates/cancho-codegen-llvm/src/tests.rs) checks the operator on a value the checker cannot fold: ~5 computed as -6 and checked against -6 arithmetically, rather than against the 4 a mistaken “flip the low bit” implementation (Not’s own shape) would have produced.

With both closed, body/expr.rs’s expr match has no unhandled Expr variant — so its wildcard arm came out. What had been a runtime string — `{other:?}` is not part of the LLVM backend yet — is now nothing: rustc refuses to build this crate at all if a future Expr variant goes unmatched, which is a strictly stronger guarantee than the removed arm’s own test ever gave. The same enumeration run over Callee::Builtin’s variants found nothing left either — every one reaches an arm in this backend’s call(), FsRead/ FsWrite/OpenRead correctly absent from that list because they never reach Callee::Builtin, lowered as Expr::FileOp/Expr::OpenFile before codegen sees them, the same way Connect/Bind are.

The “outside this backend” boundary fixture does not move an eighth time, because this slice’s own session found no real fixture left to move it to. Every file in tests/accept/ and every program in examples/ was built against --backend llvm, checked directly rather than inferred from the two enums: all of them succeed. The one program that still refuses, examples/collect/collect.cho (and fetch/, report/, serve/ alongside it), does so for the reason §7.23 already named and §7.24 already found a second instance of: each declares its own extern fn socket, crossing at int’s own i64 width (docs/reach.md §3), which collides with the i32-parameter @socket this backend declares unconditionally for its own Net support — clang refuses to link the disagreement rather than silently picking one. Not a new exposure: docs/ROADMAP.md’s #92 entry already recorded the same shape on Cranelift for close, accepted there and left accepted here for the same reason — a program naming the real libc signature for itself needs no help from this backend’s own internal declaration of the same symbol, and guarding every libc name this backend ever declares would be the cross-cutting fix #92 already declined to make. a_program_outside_this_backend_is_refused_not_ panicked and a_program_outside_this_backend_is_refused_through_the_ cli now check that collision directly — still a located refusal, still not a panic, just no longer a missing Expr.

What this slice actually closes, honestly stated: not “every program this backend will ever refuse” — docs/ROADMAP.md #92’s own collision is proof a refusal can still be correct and permanent — but every missing Expr or Builtin arm this backend’s own match statements could have, which is now zero, and checked by the compiler rather than asserted in prose.

Corrected: the collision above is fixed, not permanent. “The cross-cutting fix #92 already declined to make” was a claim about a cost nobody had asked to pay yet — true only while --backend llvm stayed opt-in. Making it the default (docs/README.md’s own “Performance, honestly” section) turned an accepted, theoretical exposure into a live regression for every socket-declaring example in this repository, which is a different question with a different answer: socket/bind/listen/accept/connect/getaddrinfo/ freeaddrinfo/close/creat/open are now declared internally only when the program’s own extern fn does not already claim the symbol — the same guard read/write already had, extended to every other libc name this backend declares unconditionally. collect.cho, serve.cho, fetch.cho, report.cho, vsock.cho, agent_guest.cho/agent_supervisor.cho all build clean now. a_program_outside_this_backend_is_refused_not_panicked is now a_colliding_extern_fn_no_longer_collides; a_program_outside_this_ backend_is_refused_through_the_cli is now every_socket_declaring_ example_builds_clean_on_llvm — both prove the fix rather than pin the collision.

One genuinely new thing making the default flip found, not named anywhere above: Expr::Unboxed had no arm in scalar_kind (the exhaustiveness guarantee §7.25 built covers body/expr.rs’s codegen match, not arith.rs’s type-inference one), crashing on any tuple or struct field holding a boxed value — docs/tuples.md’s own identity fixture, run for the first time on this backend by the default flip rather than only on Cranelift. Fixed the same way Deref/Contents already were: leaves_of on the unboxed type. And int::MIN % -1 traps here — killed by signal, not the 0 both the constant folder and Cranelift’s own optimiser agree on — because checked_div traps on is_min && is_neg1 for Rem the same way it correctly does for Div, when a remainder by -1 can never overflow and should never reach srem at all. Fixed with a real branch around the instruction, not a select (select’s two arms are both evaluated, so srem would still see the undefined pair even with its result discarded).

WebAssembly target

A wasm32-wasip1 (later wasip2) target for cancho, through the existing LLVM backend. Not a new backend: cancho-codegen-llvm already emits textual IR and shells out to clang -target <triple>; wasm is one more triple plus per-target builtin coverage.

Why it is worth doing, in one line: related-work.md frames WASI as the incumbent and cancho as “authority known before execution.” A wasm build gives both – the static row from cancho authority, and a module whose import section the runtime enforces – and makes row ⊆ imports a mechanical check. Defence in depth without a Firecracker VM per unit.

Status: W0 through W0.5, the errno decision, W1, W2a (the console without libc stdio), W2b (the table and the check) and W2c (the command line) are built (§W0 results). W1 onward is the plan below.


W0 results

cancho run examples/hello.cho --target wasm32-wasip1 prints Hello, world! under wasmtime, and scripts/wasm_coverage.py ran every tests/accept fixture for the target. This is the honest map (104 fixtures), run with WASMTIME_FLAGS=--dir=/ because several fixtures open / as their capability; that grant is the harness’s, not the compiler’s:

countmeaning
pass83built, ran, stdout and exit code match the fixture’s //~ annotations
refused2016 are located unsupported-on-target refusals from the compiler (W0.2); 4 are still the toolchain’s own message
wrong1built and ran and disagreed with the annotations: a program’s own memchr, below
trap0no accept fixture expects a trap

The first run was 35 / 9 / 60. One cause, size_t, was behind 38 of the 60; the OS-constant tables (W0.1) took 73 to 78 and cleared slicing, the errno translation took it to 79, W0.2 turned nine thread traps into located refusals, and W0.3 took the four __multi3 link errors to passes.

What W0 changed

  • --target <triple> on build, run and check (LLVM backend only; --backend cranelift with a target is a usage error, test refuses it for now). check --target runs the backend, so it is also the cheap way to ask “does this program build for wasm?”.
  • __main_argc_argv. A C compiler targeting wasm renames main(argc, argv) because the wasm ABI cannot give one name two signatures; wasi-libc’s __main_void calls that name. Our hand-written IR defined main, and the program trapped on a weak undefined symbol before running a line. (Superseded by W2c: the module now defines _start itself and nothing calls __main_void.)
  • Trap instruction. unreachable on wasm32 (trap_asm). A trap is a wasmtime trap with exit code 134, not SIGILL/132: the behaviour is the same, the number is not (§Risks).
  • size_t is 4 bytes. cancho’s int is i64 everywhere, and the backend declared malloc(i64), fwrite(ptr, i64, i64, ptr), read, write, memchr, … (11 functions, 23 call sites). wasm-ld treats a call whose type disagrees with the definition as a warning that swaps in a trap, so such a program links and then dies at its first malloc. Fixed at every call site (W0.4). The first version was a post-pass over the emitted text that declared each with its real signature behind a clamping wrapper; it worked and was labelled scaffolding. It is gone. Now emit::size_ty(triple) says i32 on wasm32 and i64 elsewhere, the module header declares all eleven functions with it, and each of the 23 call sites says so itself through FuncEmitter::size_ty, size_arg and size_result. size_arg clamps a size above u32::MAX rather than truncating it (so an allocation too large for the target fails and traps, instead of quietly asking for a few bytes); size_result widens a size_t or ssize_t back to the i64 the rest of the backend works in, signed for the latter so -1 stays -1. Native output is unchanged. A test scans the emitted wasm32 module for every sized libc call and fails on any i64 (save pread/pwrite’s 64-bit off_t), without a toolchain. It found a site the rewrite had missed on its first run: one call in fs.rs builds its name dynamically (@{name}(), so a search for @write( never saw it. The coverage map did not move: 83 pass, 20 refused, 1 wrong, the same fixtures.
  • wasm-ld --fatal-warnings. Because of the above, and kept now that every site is fixed, the linker is run with it, so every remaining mismatch is a build failure naming the symbol (function signature mismatch: write) instead of a trap at run time. Two of the refusals in the first map were this working.
  • W0.1: a Wasi arm for the file and directory constants. cancho_ir::Os { Linux, Darwin, Wasi } and open_flags_for, dirent_layout_for, dirent_types, enametoolong_for, stat_layout_for (the (darwin, aarch64) helpers Cranelift calls are unchanged wrappers). The values are wasi-libc’s headers’, pinned in tests/os_tables.rs, and two of them contradicted what ir.rs said was true of every target, now corrected in place:
    • O_RDONLY is not zero: it is 0x04000000, and an open with access mode 0 asks for no rights and answers EINVAL (28). That is what the six file fixtures were dying of. A read-only open now ORs in OpenFlags::read_only (0 elsewhere).
    • d_type’s values are not shared: WASI’s DT_DIR, DT_REG, DT_LNK are 3, 4, 7 (Linux and Darwin: 4, 8, 10). Also: O_CLOEXEC is 0 (a module cannot exec), AT_FDCWD is -2, AT_SYMLINK_NOFOLLOW is 0x1, struct dirent is {ino_t; u8 d_type; char d_name[]} (d_type at 8, d_name at 9), ENAMETOOLONG is 37, and struct stat happens to have Linux x86-64’s offsets.
  • errno is translated on WASI to the language’s numbering, which is Linux’s. A program that compares a failure’s errno (e == 2 for a missing file) now means the same thing on every target it is built for. The table is cancho_ir::WASI_ERRNO_TO_LINUX, all 76 of wasi-libc’s E*, applied by a generated @cancho_wasi_errno at the one place the backend reads errno (zero stays zero; a number WASI does not define passes through). Why this over per-target accessors: the language already fixes some of its own error numbers at Linux’s (std.dirs.einval() is 22), so a program on WASI otherwise saw two numberings at once, and translating needs no change to any program. Two judgment calls, written in errno.rs: ENOTSUP is Linux’s EOPNOTSUPP (95), and ENOTCAPABLE, which Linux has no errno for, is EPERM. enametoolong_for(Wasi) therefore answers 36, not WASI’s 37. Darwin is not translated: its raw errno still reaches a program, which is why enametoolong has a per-OS answer. That inconsistency is older than WASI and is not changed here; if it should be, the same mechanism applies.
  • W0.2: what WASI cannot do is a located refusal. A new rule, unsupported-on-target (the 58th), and cancho_ir::unsupported_on_target, a pass over Program::funcs – which is the reachable set – run by check and build before any code is generated, so it needs no wasm toolchain. It refuses at the function that reaches the builtin, once per (function, family), naming the first builtin found: `main` uses `spawn`, and threads do not exist on `wasm32-wasip1`. Five families (seven since W0.6, below), from wasi_gap, an exhaustive match over all 166 builtins (74 refused, 92 supported; a new builtin is a compile error until someone says which side it is on) (corrected, #533: 162 and 70 were stale since #524’s three process builtins, 165 and 73; child_kill_group, processes.md §4.12, refused as the other process builtins are, makes 166 and 74, and os_tables.rs pins both): threads (spawn, join, fork_*), sockets (connect, bind, listen, tcp_*, conn_*), the poller (poller_*; poll_oneoff is the eventual mapping), signals, and processes and pipes. Reject fixtures can now name a target (//~ TARGET wasm32-wasip1); tests/reject/spawn_on_wasi.cho is the first. What it does not cover: an extern fn names a C symbol, and whether that symbol exists on the target is the linker’s to say, which is the one place a target gap is still a link error (the four extern fn rows below).
  • W0.3: __multi3 is gone. On wasm32 LLVM lowers a 64-bit smul.with.overflow through a 128-bit multiply, a call to __multi3 in compiler-rt’s builtins, which the wasi-libc sysroot does not ship, so four fixtures (borrowed_fields, collections, f32_text, math_floats) failed to link. The alternative to an inline multiply was installing compiler-rt builtins (wasi-runtimes or wasi-sdk); that would have made every user of the target need one more package. Instead the module defines @cancho_smul_overflow, the same answer from 32-bit halves, used only on wasm32 (checked_arith, the one place a checked multiply is emitted; native output is unchanged). It is checked against LLVM’s own intrinsic on the host over the 400 pairs of twenty edge values (both extremes, the square root of i64::MAX either side, 2^32 either side) and two million random pairs of random magnitude: the overflow flag always, the value wherever it is defined. A second test breaks the algorithm on purpose and requires the harness to notice, because a differential test that cannot fail proves nothing. Cost is unmeasured; W3’s overhead table is where it gets measured.
  • W0.5: the write-mode file openers. open_write, open_append, open_new, open_rw, and everything built on a handle from them (file_write, file_sync, file_truncate, file_pwrite), failed on WASI with EINVAL on programs that worked natively, and no accept fixture reached them, so the map never saw it. They opened through fopen and then took the descriptor with fcntl(F_DUPFD_CLOEXEC), a duplicate; WASI has no dup, and fcntl answered EINVAL (the constant used was Linux’s 1030). fopen also brought stdio’s imports along. On WASI they now open with openat and the flags the mode’s fopen string means (OpenMode::open_flags: wb is write-only, create, truncate; ab write-only, create, append; wbx write-only, create, exclusive; r+b read-write). That needed one more entry in the flags table, read_write, because WASI’s O_RDWR is O_RDONLY | O_WRONLY (0x14000000), not 2. Native keeps its fopen bridge. scripts/wasm_file_check.py is the check that found it: one tiny program per builtin the fixtures miss (the four openers, file_size, file_sync, file_truncate, file_pread, file_pwrite, fs_read, fs_write, fs_remove, fs_rename), built native and for wasm32, run with the file absent and present, and required to exit the same and leave the same files: 26 of 26 agree, open_new on an existing file answering 117 and open_rw on a missing one 102 on both, which is the errno translation working. With the fix stashed the script reports the openers DIFFERENT (wasm=122), so it can fail.
  • An operating system with no tables is refused, in emit_module, instead of taking the Linux numbers. (x86_64-unknown-freebsd used to build.) The per-site _ => linux arms that remain are behind that guard.
  • W0.6: file locks and permission bits are refused. Running the builtins no accept fixture reaches (scripts/wasm_file_check.py) found two more places WASI differs, one loud and one silent. file_lock is flock, which WASI does not have: an undefined symbol from wasm-ld, not a located error. dir_mode and dir_own_mode build, run and answer 0 for every file, as if nothing had any permissions: WASI’s stat has no mode bits, and wasi-libc’s st_mode carries the file type and nothing else (the probe printed f600 0, f644 0, d1777 0, against 600, 644, 1777 natively). A wrong answer is worse than a refusal. Both are now in wasi_gap (two new families, file locks and permission bits), so check and build refuse them at the function, with the rule tag, before any code is generated: 47 builtins refused, 72 supported. tests/reject/dir_mode_on_wasi.cho is the fixture.
  • dir_rename_new is refused too (wasi_gap’s family no-replace renames, added with directory-handles.md §3 slice 4). By the WASI preview 1 interface, not by running it: path_rename replaces an existing destination and has no no-replace flag, so a WASI build could only look and then rename, which is the window the builtin exists to close. Not measured under a runtime. A refusal is the safe direction; dir_rename stays supported. tests/reject/dir_rename_new_on_wasi.cho is the fixture. The 47 refused of #320 are 48 with it; os_tables.rs pins the count. (The 118 and 46 that this section said before were stale: exec_spawn_in had made it 119 and 47, and the builtin count is now 120.) Corrected again with udp.md: the count had also moved past 120 (it was 123 builtins before UDP), and the seven datagram builtins (udp_connect, udp_send, udp_recv, udp_local_port, udp_nonblocking, udp_close, poller_add_udp) are refused as sockets and the poller are, so there are 130 builtins and 55 refused. os_tables.rs pins the 55. And again with the bound half (udp_bind, udp_recv_from, udp_send_to, refused as sockets): 133 builtins, 58 refused, 75 supported; the test pins the 58. And with udp_detach/udp_attach (slice 4, refused as sockets are): 135 builtins, 60 refused, 75 supported; the test pins the 60. And with conn_peer (conn-peer.md; refused as sockets): 136 builtins, 61 refused, 75 supported; the test pins the 61. And with udp_peer (udp.md §12; refused as sockets): 137 builtins, 62 refused, 75 supported; the test pins the 62. And with tty.md (#422 T2; the seven serial verbs tty_open, tty_configure, tty_read, tty_write, tty_flush_input, tty_close and poller_add_tty, refused as serial ports are — WASI has no termios, tty.md §9): 144 builtins, 69 refused, 75 supported; the test pins the 69. And with word-scan.md (load_le64, byte_mask64, trailing_zeros, leading_zeros, popcount: pure, supported – the 16-byte vectors are scalarised without simd128, and tests/accept/word_scan.cho passes under wasmtime): 149 builtins in Builtin::ALL, still 69 refused, 80 supported; the test still pins the 69. (Counted when #382 was rebased onto it: Builtin::ALL had 150, not 149, and 81 supported.) And with #382’s four (aes_ctr32, ghash_powers, gcm_tag, gcm_tag_diff, docs/gcm-wide.md; supported, as the other crypto builtins are, since they need no host; ghash_update retired and kept), and #393’s mul_wide, add_carry and sub_borrow (pure, supported), and read_bytes (standard-input.md §7, supported): 158 builtins, 69 refused, 89 supported. os_tables.rs now pins all three, not only the refused count. And with #445’s three (hw_sha256, sha256_blocks, sha256_block_words, sha256-builtin.md; supported, as the AES ones are: hw_sha256() answers false on wasm32-wasip1 and the other two trap there): 161 builtins, 69 refused, 92 supported. And with tcp_listen_on (native-sockets.md §12; refused as sockets are): 162 builtins, 70 refused, 92 supported; the test pins all three. And with exec_spawn_detached, child_release and child_pid (processes.md §4.11, #524; refused as the other process builtins are): 165 builtins, 73 refused, 92 supported; the test pins all three.
  • run uses WASMTIME (default wasmtime) and passes WASMTIME_FLAGS (e.g. --dir=.). A WASI module gets no directory unless one is granted, so the grant is spelled where it is made.

Environment: CLANG (a clang with the wasm32 target, e.g. Homebrew’s llvm; Apple’s has none), WASM_LD, WASI_SYSROOT (a wasi-libc sysroot holding lib/wasm32-wasip1/crt1-command.o), and wasmtime.

The 20 refused

causefixtureskind
threadsfork_clock_workers, fork_heap_workers, spawn_heap_in_struct, spawn_join, spawn_join_operands, spawn_owned_clock, spawn_owned_file, spawn_owned_io, spawn_owned_net, spawn_parallel_sleep, spawn_struct_ref, spawn_thread_idslocated (W0.2)
socketsconnect_a_refused_address, listen_accept_bad_fdlocated (W0.2)
processes and pipesprocess_spawnlocated (W0.2)
signalssignals_claimlocated (W0.2)
extern fn signatureforeign_narrow_return (access), bytes_to_c (write), opaque_pointer (fdopen), foreign_two_libraries (pthread_self)toolchain. A program’s own extern fn declares C’s int/long as i64; wasi-libc’s is i32 (or the function does not exist). The declaration is a claim about a native ABI, and only the linker can say

The 1 wrong

fixturelikely cause
index_of_byte_beside_own_memchra program that defines its own function named memchr replaces wasi-libc’s, whose internals call it with a 32-bit size_t. Real on any target, but only visible once the widths differ

Cleared since the first map: the file and directory fixtures, slicing (its exit 1 was the read-only flag), directory_handles (the errno numbering), and the nine thread fixtures, which are now located refusals rather than run-time traps.

Findings that change the plan

  1. _ => arms fall through silently. Every OS match in the backend is Darwin(_) => ..., _ => linux. A WASI target quietly took the Linux arm. hello worked only because wasi-libc happens to export stdout, stderr and __errno_location. Partly fixed: the file and directory tables are an exhaustive match on Os, and emit_module refuses any OS that is not Linux, Darwin or WASI. The is_darwin() sites for sockets, signals, the poller and processes still read Linux numbers on WASI, but W0.2 refuses their builtins first, so nothing reaches them.
  2. A link error is not a refusal. Fixed for builtins (W0.2): check --target rejects a builtin with no WASI meaning with its function’s source location and a rule tag, before a linker is involved, and the table is code a test checks (wasi_gap, exhaustive). Still link errors: extern fn signatures, 4 fixtures (__multi3 was the other 4: W0.3).
  3. --fatal-warnings is the cheapest safety net on the table, and worth keeping even after the size_t fix: it is what stops a future libc call with a hardcoded width from shipping as a trap.
  4. The language had no portable errno, and now has one on WASI: see “errno is translated” above. Darwin remains raw.
  5. The roadmap’s slice-length offset (i64 8, expr.rs) was not what broke first; size_t was. It is still untested because no fixture has exercised it separately yet. W1’s corpus should.

W1 results

tests/programs/json_roundtrip.cho – parse standard input with std.json, write it back, or print E <code> <position> if the document is refused – built for the host and for wasm32-wasip1 and fed the same documents by scripts/wasm_json_differential.py. It is a real program (128 lines) that exercises the heap, arenas, boxed slices and bytes, which is where a pointer-width bug would show, and std.json’s float parser and printer, where a numeric one would.

documents1,585: ten valid seeds, 600 mutations of them (a byte replaced, inserted, removed, swapped, or the document truncated), and 975 numbers (every exponent, 17-digit and 20-digit decimals, and the halfway cases between adjacent doubles that a floating-point shortcut gets wrong)
accepted, byte-identical1,102
refused, byte-identical483 (the error code and position included)
different0

Not done: the determinism check across two hosts (this is one machine), and the traps (a trap on wasm is exit 134, measured at W0).

The import list is not the one the roadmap expected

The roadmap said a console filter would import exactly fd_read, fd_write and proc_exit. It imports nine. Measured per effect, with one tiny program for each (wasm-ld --why-extract says which libc object pulls in which):

a program thatimports
does nothing, or uses the heap, or reads argsargs_get, args_sizes_get, proc_exit
writes the console ([io_write])those, plus fd_write, fd_fdstat_get, fd_seek, fd_close, clock_time_get
reads the console ([io_read])those, plus fd_read, fd_seek, fd_close, clock_time_get
  • The heap costs nothing: malloc grows linear memory with memory.grow, which is an instruction, not an import.
  • args_get and args_sizes_get are imported by every program, even one that released its args capability, because main(argc, argv) is the entry and wasi-libc’s __main_void fetches them before calling it.
  • Four imports come from stdio, not from anything the program does. fd_fdstat_get is isatty on stdout, fd_seek is __stdio_seek, fd_close is __stdio_close, and clock_time_get is clock_gettime called from a futex timeout in stdio’s locking. The backend writes the console with putchar, getchar and fwrite, so a program that merely prints brings all four.

What that means for W2

clock_time_get is the one that matters. A program whose row is [io_write] has no clock label, yet its import section hands the runtime a clock to grant it. The module is not more capable in practice (only libc’s locking calls it), but the point of the exercise is that the import section and the row agree, and here they cannot while the console goes through libc stdio: row == imports is false for every program that prints.

Two ways out, and W2 has to pick one:

  1. Map each io_* label to its measured import set and accept that io_write also licenses clock_time_get. Cheap, and the table is data a test checks. The cost is the over-grant: the check would pass a module that really did read the clock, because the label that “explains” it is io_write.
  2. Do not use libc stdio on wasm: declare fd_write and fd_read directly (wasm-import-module / wasm-import-name attributes on the IR declarations) and give the module its own console buffer, flushed when full, at flush_out and at exit, which is what libc does to a pipe anyway. Then the imports are exactly fd_write for io_write, fd_read for io_read, and nothing else, and the equality check is honest. The cost is owning a buffer and its flush-at-exit.

Recommendation: 2, built as W2a below. The whole value of the target is that the import section is the enforced half of the same fact the row states; an over-grant the check blesses by construction defeats that. The args pair remained (it was startup, not stdio) until W2c, below, wrote an entry point of our own that fetches the command line only when a program reads it.


W2a results: the console without libc stdio

cancho-codegen-llvm/src/wasi_console.rs is the console written against the two WASI calls it needs. On wasm32 putchar, getchar, read_bytes, write_bytes, write_err and flush_out no longer touch libc: the module declares fd_write and fd_read itself (wasm-import-module / wasm-import-name attributes on the IR declarations), keeps its own buffers, and main flushes stdout when it returns. Native output is unchanged.

  • read_bytes (standard-input.md §7, edition 9) first hands out what getchar’s 4 KiB stdin buffer holds, then calls fd_read straight into the caller’s buffer until it is full or the input ends (cancho_read_into); it imports nothing getchar did not (a_wasm32_bulk_read_never_calls_libc_stdio). Run under wasmtime it agrees with the native build on bytes, lines and an order-sensitive hash for buffers of 0, 7 and 65,536 bytes.
  • stdout is buffered (4 KiB), flushed when it fills, on flush_out, and when main returns, which is what libc does to a pipe, so the bytes and their order are the native build’s. A write that fails sets a sticky error, as the FILE’s indicator does, and flush_out reports it: the failed flush’s errno translated to the language’s numbering, or EIO (5) for an earlier failure a later flush did not see.
  • stderr is unbuffered. stdin is read 4 KiB at a time; end of input or a failed read is -1.
  • Everything is internal, so a program that uses none of it carries none of it. The one thing that is not free is the flush at exit, which is emitted only for a module that writes: an unconditional one would make every pure program import fd_write.

Imports, before and after

Measured by scripts/wasm_console_check.py, one tiny program per effect, with the import set required to be exact:

a program thatW1 (libc stdio)now
does nothing, uses the heap, or reads argsargs_get, args_sizes_get, proc_exitthe same
writes stdout+ fd_write, fd_fdstat_get, fd_seek, fd_close, clock_time_get+ fd_write
writes stderr(as stdout)+ fd_write
reads stdin+ fd_read, fd_seek, fd_close, clock_time_get+ fd_read
reads and writes+ fd_read, fd_write

clock_time_get is gone from every console program: a row of [io_write] no longer comes with a clock. The JSON filter went from nine imports to five.

Still correct

The differential from W1, with large documents added so the buffer’s spill and direct-write paths run (strings whose output lands either side of 4,096, 8,192 and 12,288 bytes, a 6,000-element array, a 2,500-key object, a 400-deep nest): 1,617 documents, 1,131 accepted and 484 refused byte-identical, 2 trapped on both builds, 0 different. The two that trap are strings over 64 KiB, which exhaust the 64 KiB arena json_roundtrip.cho uses and trap natively too; the script counts a native SIGILL and a wasmtime trap (exit 134) as the same behaviour and requires both. The accept-fixture map did not move (83 / 20 / 1).

flush_out is held to checked_output.rs’s own probe: a small and a 100,000-byte write to a live reader answer Ok and the bytes arrive, and a reader that has left answers EIO and keeps answering it.

What this does not establish

  • ENOSPC and EBADF. A Mac has no /dev/full, and a closed stdout (>&-) did not make the write fail under wasmtime (the probe answered Ok); I did not establish why, so the EBADF case is untested for the module. A Linux run, with /dev/full, is the way to cover both.
  • A reader that left is EIO, not EPIPE. wasmtime answers a write to a pipe whose reader left as “success, zero bytes written”, and the console treats no progress as EIO. A native run says EPIPE where SIGPIPE is ignored (checked-output.md §2). That is the host’s behaviour, and the console reports the honest thing it can.
  • The args pair was still unlabelled startup. args_get and args_sizes_get were imported by every program, because wasi-libc’s __main_void fetches them before calling main(argc, argv), so a program that released its args capability still imported them. Fixed in W2c, below, with an entry point of our own.

W2b results: what the row licenses

cancho-ir/src/wasi_imports.rs is the table that bridges the two halves of the authority fact: the row (what the checker says a program does) and the import section (what the runtime is asked to grant the module, and enforces). For each effect label, the WASI preview 1 functions a module built from a program that performs it may import. A program with an empty row has an empty import section; the one exception is proc_exit, which every program is allowed and none is required, because a program leaves through it only if it can return a non-zero status (W2c made it so; until then every program also imported args_get and args_sizes_get).

Two sets per label, because a label is coarser than a builtin (dir_write covers create, append, rename, remove and sync; a program with it may use one of them):

  • allowed: every function any builtin under the label can bring in. A module that imports something no label in its row allows has been granted a capability its own row does not account for. This is the half that matters, and the one a check must never let slip.
  • required: what every builtin under the label imports (the intersection). Rows are exact in both directions, so a label in a row is performed by something; a module missing a required import has a row claiming more than the program does.

cancho authority --target wasm32-wasip1 reports them (and --output json carries a wasi object: required, allowed, refused, unbounded, unknown); the host’s report is unchanged. For the JSON filter: required and allowed are both fd_read, fd_write, which is exactly what its module imports.

Where the numbers come from. They are measured, not read off libc, and each row’s comment says by what: the console programs (wasm_console_check.py), one probe per file builtin, the directory write driver and the accept fixtures (wasm_file_check.py --imports), and a clock probe. The rows for the whole label set are checked in CI for what CI can see: every label the checker can produce (WORLD_PLAIN_LABELS and WORLD_ROOT_LABELS, now exported from one place, plus conc) is classified exactly once as supported, refused or unbounded; required is inside allowed; no row names a function WASI preview 1 does not have; and no builtin the target supports carries a label the table refuses.

The check

scripts/wasm_authority_check.py builds every program for wasm32-wasip1, reads its import section, and requires required ⊆ imports ⊆ allowed (allowed always includes proc_exit).

programs149 (tests/accept and tests/programs)
ok110, every one inside its row’s licence
FAIL0
refused by the target (a label WASI cannot do)16
did not build8: the four extern fn signature fixtures, three timing drivers whose foreign calls do not link, and listen_accept_bad_fd (refused by the target’s builtin walk; see below)
could not be checked alone15: multi-file TLS drivers and a fuzz fixture with no main

It can fail. Run against the module W1 built, when the console went through libc stdio, the same predicate reports clock_time_get, fd_close, fd_fdstat_get and fd_seek as imports no label allows, and nothing else. That is the finding W1 made by hand, now a check.

What this does not establish

  • The report is only as complete as the labels. authority derives from effect rows, and a builtin that carries no label contributes nothing to it: listen(999, 16) in an edition 2 file reports no labels at all. For WASI those are all in the refused families, and check --target refuses them by walking builtins, not labels, so a program using one is refused regardless. The check found no supported builtin that imports without a label, across these 110 programs; that is as strong as the programs.
  • ffi is unbounded. What foreign code imports is the library’s, not the row’s, so for such a program only the half that does not depend on it (the required imports) is checked.
  • The directory rows are measured on three programs (the directory write driver, and the directory_handles and directory_listing fixtures); a builtin none of them reaches could add an import. The check is the safety net: it would fail on it.

Known differences found on the way

scripts/wasm_file_check.py parts two and three run the directory drivers (directory_writes.rs, directory_modes.rs) over a tree, native against wasm. Of 18 directory write cases, 17 are identical, errnos included. The one difference is dir_sync, which is fsync on a directory descriptor: natively ok 0, on WASI err 9 (EBADF) under wasmtime 49, which does not let a directory descriptor be synced. A durability call that fails with its errno is the honest answer, so it stays a runtime failure and is printed every run as a known difference. The permissions probe printed 0 for every file on WASI, which is why those builtins are refused (W0.6).


W2c results: the command line, fetched only when a program reads it

wasi-libc’s crt1-command.o defines _start, and _start calls __main_void, which fetches the command line with args_sizes_get and args_get before every program. So a program that released its args capability and never read an argument still imported both, and the runtime would have granted it the command line whatever its row said: the one place W2b’s table had to write “startup” instead of a label.

The module now defines _start itself (cancho-codegen-llvm/src/wasi_entry.rs) and the link no longer includes crt1-command.o. It does what crt1’s did, minus the fetch: run the constructors (__wasm_call_ctors, where wasi-libc hangs its own set-up, preopens included), call the program, run the destructors, and leave through _Exit (proc_exit) on a non-zero status. The fetch is a small loader in the module, emitted only for a program that loads @lexs_argc or @lexs_argv, which is exactly a program with the args label in its row. A program that does not has no args_get in it to import.

What it measured

a program thatimports
reads nothing, prints nothingnothing at all
uses the heapnothing
writes stdout or stderrfd_write
reads stdinfd_read
reads the clockclock_time_get
reads its argumentsargs_get, args_sizes_get

proc_exit is imported only by a program that can return a non-zero status: with main’s result a constant 0 the exit path is dead code and _Exit is never referenced, so the pure program’s import section is empty. That is why the table lost its “startup” set. proc_exit is allowed for every program and required of none, and args is a label like any other: args_get and args_sizes_get are what it requires.

Still correct

  • Behaviour: tests/accept/arguments.cho, native against the module, over nine command lines (none, one, many, non-ASCII, an empty argument, a 3,000-byte argument, spaces, dashes): the same output and the same exit status in every case. An import list that is right says nothing about whether the values are, so this is the part that could have broken.
  • The accept map is unchanged (83 / 20 / 1), file fixtures included, which is what shows __wasm_call_ctors still does its job without crt1.
  • The check is unchanged: 149 programs, 110 ok, 0 FAIL, now against the stricter table in which the command line is a label’s and not the startup’s. The file probes, the directory driver and the console programs all agree, and the JSON differential has 0 differences.

CI

a_wasm32_module_fetches_the_command_line_only_if_the_program_reads_it (text, no toolchain): _start is ours and has the ctors, entry, dtors and _Exit steps; a program that reads nothing carries no loader and no args import attribute; one that reads arguments carries both; the host’s entry is still main.

What this does not establish

  • __wasm_call_dtors is called, but nothing uses what it runs. libc’s exit handlers and stdio cleanup are for streams the module no longer opens (W2a, W0.5); calling it keeps the behaviour crt1 had.
  • A second _start is not trapped. crt1 guards a re-entry with a flag; this does not, since a command is entered once.

Milestones

MilestoneWhatAcceptance
W0 – spike--target wasm32-wasip1, examples/hello.cho runs in wasmtimeDone: one example prints the right bytes; conformance map above
W1 – a real commandA stdin→stdout JSON filter on std.jsonDone: byte-identical to native over 1,585 documents; the import list is measured, and is wider than the row (§W1 results)
W2 – the authority check (W2a, W2b and W2c are done: the console without libc stdio, the label-to-imports table, authority --target, the cross-check, and a command line fetched only when read)cancho authority --target wasm32-wasip1 cross-checked against the moduleA test that fails if the module imports anything the row does not explain, and if a label has no import (the rows are exact both ways); JSON report carries the import list
W3 – a pure librarypackages/x509 verify as a reactor module (exports, no main)Import section is empty beyond memory; results identical to native on the x509 conformance fixtures; overhead measured
W4 – componentswasip2, one WIT resource mapped to a res handlewasi:filesystem descriptor as a linear handle, own/borrow ↔ res/borrow; then examples/serve as wasi:http

W0–W2 are the thesis. W3 is the showcase. W4 is where the design question lives and should not start until W2 is green. Name who asks for W3 and W4 before starting them (AGENTS.md §7).

On W1’s imports: the backend emits fwrite, so wasi-libc’s stdio comes with it, and crt1-command typically adds argument and environment imports (args_get, environ_get, probably fd_seek and fd_fdstat_get). Measure the real list with W1’s module and call it the baseline; or skip libc and declare the WASI imports directly in the emitted IR, which makes the label-to-import table exact at the cost of owning an allocator. W0’s map says which is cheaper.


W0 – spike (as planned)

  1. Triple plumbing. --target on build/run/check. Built.
  2. OS match arms. emit.rs and body/*.rs switch on triple.operating_system for stdout/stderr symbols, errno, and several libc names (emit.rs, body/expr.rs, files.rs, fs.rs, net.rs, sockets.rs). Add a Wasi arm to each, make the match exhaustive, and let any arm with no WASI meaning become a refusal. Done for files and directories (W0.1); sockets, signals, the poller and processes are W0.2.
  3. Pointer width. int is i64 and does not change; pointers and size_t become 4 bytes on wasm32. size_t is handled by the shim above. Still to audit: the slice length at getelementptr i8, ptr …, i64 8 (body/expr.rs, ~line 191), the four ptrtoint … to i64 sites (a widening on wasm32, so probably harmless), and every hand-laid libc struct (alloca i8, i64 16 for timespec, sockaddr, sigaction, poller events). Fallback: wasm64 first. The review of this roadmap recommends against it: it undercuts “runs anywhere”.
  4. Linking. wasm-ld against wasi-libc with crt1-command.o, driven from the CLI’s link_wasm (the object comes from clang; the final link is the CLI’s, not the backend’s).
  5. Coverage map. Done once, above. Re-run with WASMTIME_FLAGS=--dir=/ python3 scripts/wasm_coverage.py <cancho>.

W1 – a real command

  • Target: a jq-lite over std.json (std/json.cho), reading stdin, writing stdout. Row [io_read, io_write].
  • Exercises heap, arenas, boxed slices and bytes – exactly where a pointer-width bug would surface.
  • Determinism check: same module hash + same input ⇒ same output bytes, across wasmtime on two hosts. Canonical NaN (CANONICAL_NAN, CANONICAL_NAN_32) already covers the one place wasm is nondeterministic, if the filter touches floats.
  • Traps: checked arithmetic, bounds and division traps must surface as wasm traps with a non-zero exit, matching native exit semantics where wasmtime allows (document where it doesn’t). Measured so far: wasmtime exits 134 on unreachable.

W2 – the authority check

  • Map each effect label to the WASI imports its builtins lower to (io_write → fd_write, fs_read → path_open/fd_read, …). This table is the spec; generate it from the builtin definitions rather than hand-writing it, and keep it as data inside the codegen crate with a completeness test, in the spirit of every_rule_has_a_fixture.
  • cancho authority --output json gains imports: [...] for wasm targets.
  • A conformance test: build every accept fixture for wasm, assert imports(module) ≈ map(row(main)) ∪ runtime_baseline, equality rather than subset, since effect rows are exact in both directions. Needs --gc-sections so dead code does not inflate the import list. Anything outside is a failure, not a warning.
  • Document the residual: the allocator and proc_exit are baseline imports with no label. State them once, in authority.md.

W3 – a pure library (reactor)

  • Needs a library mode: no main, pub functions with scalar/slice signatures exported. Today there is no export surface, and this is a language-design project hiding inside a milestone: design it in docs/ first (what an exported signature may mention; capabilities may not cross the boundary, so exported functions must have row [] or take only host-provided handles). Start that document while W2 is in flight.
  • Target: packages/x509 verify. Row [], so the module imports nothing but memory. Claim to demonstrate: a certificate verifier that provably touches nothing, statically and at runtime.
  • Measure against native. Crypto is arithmetic-bound, which overflow-cost.md puts at +40.5% for checked arithmetic natively; wasm may add its own cost on top. Publish the number either way.
  • Host demo: a tiny JS or Rust embedder calling verify.

W4 – components (wasip2)

  • Switch target to wasm32-wasip2; emit a component (wasm-tools component new or wasi-sdk’s native p2 support).
  • The design question: WIT resource + own<T>/borrow<T> is res + borrow. One resource first – a wasi:filesystem descriptor – and check that the linearity rules, release, and the authority report all carry over without a special case. If they need one, write down why before going further.
  • What it may fix in reach.md:
    • “A library is not an authority domain” – a WIT interface is one. Rows could say wasi:sockets/tcp rather than ffi("libc").
    • Opaque foreign results – WIT handles are opaque by construction; check against what opaque-pointers.md already settled natively.
  • Then: examples/serve as a wasi:http/incoming-handler component.

Builtin coverage

Expected, to be confirmed by W0’s map (the map above already confirms the sockets, threads and signals rows).

Areawasip1wasip2Note
Console (io_read/io_write)yesyes
Files, directories, openat, pread/pwriteyesyesPreopens replace absolute paths; Fs path prefix ↔ preopen. Constants differ (open flags, stat, dirent) and are tabled in cancho_ir::Os; errno is translated to the language’s numbering
Heap, arenasyesyeswasi-libc malloc; size_t is 32-bit
Clockyesyes
flock, fsync, renamepartialpartialCheck per call
Sockets, getaddrinfonoyeswasi:sockets
epoll / kqueuenopollMap the poller onto poll_oneoff / wasi:io/poll
Processes (posix_spawn, kill)nonoRefuse; no WASI equivalent
SignalsnonoRefuse
File locks (flock)nonoRefuse; an undefined symbol otherwise (W0.6)
Permission bits (dir_mode, dir_own_mode)nonoRefuse; WASI’s stat has none, so they would answer 0 (W0.6)
Threads (pthread_*)experimentalexperimentalRefuse until wasi-threads settles
extern fn to arbitrary Clink-time onlylink-time onlyOnly libraries compiled to wasm; Ffi("libc") means wasi-libc, whose int/size_t are 32-bit

Every no is a located refusal on that target – the same discipline --backend gaps already follow – never a link error or a crash. Met for builtins since W0.2; an extern fn the target lacks is still the linker’s to report.


Risks

  • Pointer-width assumptions beyond size_t and the known slice-length site. Mitigation: the coverage map, and --fatal-warnings.
  • Hand-laid libc structs differ on wasi-libc. Each alloca i8, i64 16 is a candidate; the conformance suite is the net.
  • Exit codes and traps. wasmtime reports a trap as exit 134, not SIGILL; the semantic exit codes (0/1/2/3) must be re-stated for wasm, not assumed.
  • wasip2 churn. Component tooling is still moving; keep W4 behind W2.
  • Thesis drift. The pitch is static authority. Wasm must not become the reason the static check is skipped – it is the second layer, not the first. Granting --dir=. to run a fixture is exactly the escalation the rows exist to make visible; it stays an explicit flag.

Open decisions

  1. --target flag vs. a separate cancho wasm subcommand. Flag: compile_object_for already takes a triple, and it mirrors --backend.
  2. wasm32 vs. wasm64 as the first target. wasm32, by the argument above.
  3. Library-mode export rules: may an exported function take a capability the host constructs, or only [] functions in W3?
  4. Whether the effect-label → import table lives in the compiler or as data checked by a test. Leaning: data in the codegen crate plus a completeness test.
  5. Whether rows should ever name WIT interfaces directly (W4), and what that does to per-unit content hashes.

Non-goals

  • A direct IR→wasm emitter. LLVM already does this.
  • Browser-specific bindings (JS glue, DOM). Possible later; not the point.
  • Replacing native as the primary target.

The checks, read out of the binary

Status: an audit, and it found four things.

check-cost.md is subtitled “what every check this language emits costs”, and it priced eight of them. It measured C. Each kernel in benches/guards.c carries a comment saying which cancho check it stands for, and not one of those comments had been checked against the machine code cancho actually produces — backend-limits.md audited what Cranelift can be asked for, one level above this.

So: cancho build --emit obj, objdump -d, one small function per check. Six of the eight proxies are exact. Two are not, one of them the row that carries the document’s headline. And on the way, two claims about division turn out to be backwards and one compiler bug fell out of writing the probe.


1. The probe found a bug before it found a number

The audit needs one function per check, which means writing int_of(byte_of(n)) somewhere. Written as a call:

fn g(n: int) -> [] byte { return byte_of(n); }
...
let t = int_of(g(65));
error: code generation failed: in `main`: Compilation error: Verifier errors

No span, no rule tag, no way for a programmer to act on it — the shape agent-errors.md exists to eliminate. (Since #78 a failure like this is an internal refusal located at the function, and check reports it too: internal-errors.md.) Three spellings reached it:

Written
int_of(g(65))uextend from a value already 64 bits wide
g(65) == byte_of(66)a comparison between two different machine types
fn h() -> [] byte { return g(65); }a return value of the wrong width

One cause. g(65) is a pure call on constant arguments, so compile-time.md folds it — and a folded call becomes a literal, while Expr’s literals are int, bool and float. There is no byte literal, because byte_of is where a byte comes from (strings.md §2). So the fold put an int-shaped node where the backend expects one machine byte.

The repair is to make the return type part of the fold’s condition rather than only the arguments. Not to invent a byte literal: a new literal node moves every hash in the repository (canonical-ast.md §3), and this is the one call shape in the corpus that it would buy.

Two things are worth saying about how long it survived. It needs a pure function returning byte, called with constant arguments, in a position that needs the byte — and no program here had ever written one, which is why 493 tests were green over it. And it was a refusal rather than a wrong answer, which is the failure mode this language is built to prefer.


2. What each check actually emits

x86-64, --emit obj, prologue and epilogue dropped. The operation itself is marked ·; everything else is the guard.

checkemittedguard
a + b· add seto test jne3
s[i]cmp jae · mov (%rdi,%rdx,8)2
a << bcmp $0x40 jae · shl2
byte_of(n)cmp $0xff ja · mov2
s[lo..hi]cmp ja cmp ja · sub4
-x· xor · sub seto test jne3
truncate(f)· cvttsd2si cmp $0x1 jno → (cold: ucomisd ×3, movabs, movq, xorpd)2 hot
a / btest %rsi,%rsi je · idiv2
a % bcmp $-1 jne · idiv2

Every guard branches to a ud2, which is why a trap here is SIGILL and not a signal with a meaning.

2.1 Six proxies are exact

benches/guards.c writes the index check as (unsigned long)i >= (unsigned long)n, the shift as (unsigned long)w[i] >= 64, the narrowing as (unsigned long)v[i] > 255 and the subslice as two unsigned comparisons. Those are, instruction for instruction, what comes out of the binary — one cmp and one unsigned branch each, two for the subslice. The overflow kernel’s __builtin_saddl_overflow compiles to the same seto/test/jne that overflow-cost.md §3.4 wrote down.

So six of the eight rows in check-cost.md §2 stand as measurements of the real check. The author of that harness knew the unsigned trick and used it. The rest of this section is the other two.


3. truncate(f), which is the headline row

Two problems, one small and one not.

The small one is its name. Five documents and the roadmap call this check int_of(f). int_of takes a byte and widens it, which is always defined and emits no check at all; the float conversion is truncate. int_of(f) is not an expression this language has. One document wrote the wrong name and five copied it — the shape hash-stability.md §2 found in the effect labels, in prose instead of in code.

The large one is the guard. The C kernel tests the range on every element:

GUARD(!(x >= -9.2233720368547758e18 && x <= 9.2233720368547758e18));

Two ucomisd comparisons and two branches, unconditionally, per value. What Cranelift emits instead is:

cvttsd2si %xmm0,%rax
cmp    $0x1,%rax
jno    ok

cvttsd2si answers 0x8000000000000000 for NaN, for either infinity and for anything out of range — so rax - 1 overflows exactly on the sentinel, and one cmp/jno separates every good value from every bad one. The six-instruction sequence that distinguishes which kind of bad it was sits behind that branch and runs on no value a real program converts.

So the two are not the same guard. The C one does more work per element, and check-cost.md’s 3.35× — “the worst check in the language, and nothing had looked” — is the cost of a guard cancho does not emit.

What survives, stated carefully: the number is an upper bound on this check’s cost under a vectorising compiler, and the direction of §7’s conclusion is unaffected, because cvttsd2si with a data-dependent trap is no more vectorisable than two comparisons are. What does not survive is the ranking. 3.35× was the largest number in the table and it belongs to a spelling nobody runs.


4. Division, where both claims are backwards

check-cost.md §1 lists a / b and a % b as testing nothing, and §5 says “BinOp::Div lowers to a bare sdiv and BinOp::Rem to a bare srem, with no comparison emitted at all” — the language getting its guarantee “from an instruction that was going to fault anyway”. The kernel comment in benches/guards.c says the same.

Both operators emit exactly one comparison, and they compare different things:

emitsso a zero divisorand int::MIN, -1
a / btest %rsi,%rsi jeSIGILL — its own ud2SIGFPE — the hardware
a % bcmp $-1 jneSIGFPE — the hardwareanswers 0

Measured, not read:

1 / 0                    trapped (SIGILL)
1 % 0                    trapped (SIGFPE)
INT_MIN / -1             trapped (SIGFPE)
INT_MIN % -1             exit 0

One IR instruction is not one machine instruction, and that is the whole mistake: backend-limits.md §2 correctly read Opcode::Sdiv.can_trap() out of the source, and check-cost.md §5 turned a fact about the IR into a claim about the emitted code.

a / b at 1.00× is still right, and now for a fourth reason rather than the third one §5 gave: not because nothing is emitted, but because one predictable compare is nothing beside a 40-cycle idiv that has no packed form to lose.

4.1 int::MIN % -1 does not trap, and should not

defined-behaviour.md §2.3 says / and % trap on “int::MIN / -1 and int::MIN % -1 — the quotient has no representation”. The reason is right for / and does not apply to %: a remainder by -1 is 0, for every dividend including int::MIN, and 0 is perfectly representable. The emitted code says so — cmp $-1, and the answer is a mov $0.

So the document is wrong and the compiler is right, which is the good direction for this to be found in. §2.3 is corrected in place.

4.2 And the two halves of the compiler disagreed about it

Written with literals, the same expression was a compile error:

error: this arithmetic overflows; the operands are literals,
       so this can only trap

The constant folder used Rust’s checked_rem, which reports int::MIN % -1 as overflow. The backend answers 0. So one compiler gave two answers for one expression depending on whether a value was written down or computed — the failure compile-time.md §4 is built to avoid, since folding is only sound while it agrees with running.

Fixed by giving the folder the backend’s rule: a % -1 is 0.


5. What this does not say

  • Not that check-cost.md should not have used C. cancho cannot vectorise anything (backend-limits.md §4), so asking what a check costs a vectorising compiler requires a second compiler. The method is right; two of the sixteen lines were wrong.
  • Not that the ordering collapses. s[lo..hi] at 2.16× against s[i] at 1.00× is a matched pair with identical spellings, and §7’s rule — provable is free, loaded is not — rests on those.
  • Not that these are all the checks. §1’s second table lists the once-per-allocation ones, and this audit did not read them.
  • Nothing about aarch64. Every disassembly here is x86-64. The division behaviours are now conformance tests, so CI answers for both targets on every commit rather than this document guessing.

6. The suite

TestShows§
folding_a_byte_returning_call§1’s three shapes — a widen, a comparison and a return — compile1
the_other_division_traps1 % 0 and int::MIN / -1, neither of which anything ran4
a_remainder_by_minus_one_is_zeroint::MIN % -1 is 0 at run time and folded, so the two halves cannot drift apart again4.1, 4.2
division_by_zero_traps_rather_than_being_undefined1 / 0; it predates this document and is the one row §2.3 had4

Every one of them runs on linux-x86_64 and darwin-aarch64, which is the part that matters: §2’s table is a disassembly from one machine, and a rule that only holds on the machine it was read from is not a rule.

GPU, and whether lex-gpu should be its own language

Status: measured, and the measurement decides it.

The question arrived as three: can cancho run on a GPU, how would a native one work, and should it be a separate language. The first two are design; the third looked like taste until the numbers came in.

§2 is the result and it has a surprise in it. A bounds check is free even in vectorised code — an overflow trap is not. So the memory half of this language’s no-undefined-behaviour claim survives on a GPU at no cost, and the arithmetic half cannot survive at all.


1. Today: nothing, and the backend is why

Cranelift has no GPU target — no PTX, no SPIR-V, no AMDGPU. Emitting for a GPU means LLVM, which ROADMAP.md currently places outside the next slices and purity.md §4.2 argued down rather than up.

Checked (#68): backend-limits.md §1.3. src/isa/ in cranelift-codegen 0.121.2 is aarch64, pulley_shared, riscv64, s390x and x64 — and pulley is Cranelift’s own portable interpreter rather than a device.

So this document is not about a feature that is nearly there. It is about what the design would have to give up, priced.


2. What each guarantee costs a vectoriser

The same reduction — sum a million-element buffer, 200 rounds — written four ways in C and twice in cancho, with the guards switched independently. benches/reduce.c and benches/reduce_{checked,wrapping}.cho, which the conformance suite holds to the same answer.

SIMD instructions counted in the emitted run, not inferred from the clock. clang 18, -O2; minimum of nine runs:

SIMD in run
clang, no guards1060.7 ms1.00×
clang, bounds check only1061.4 ms1.01×
clang, overflow trap only088.9 ms1.46×
clang, both091.1 ms1.50×
cancho, wrapping (bounds only)0137.6 ms2.27×
cancho, checked (both)0252.1 ms4.15×

Three things fall out, and none of them was obvious beforehand.

2.1 A bounds check is free

1.01×, and the SIMD count does not move. The index condition is provably true inside the loop the compiler already proved bounded, so it is deleted outright. A bounds check never was a reassociation barrier — it is a branch on a constant, and a branch on a constant is not a branch.

overflow-cost.md §3.2 established that a check costs the vectoriser. It measured the overflow one and generalised. Half of that generalisation is wrong, and this is the half: memory safety is not what costs anything here.

Corrected (#64): that last sentence is the over-generalisation again, one document later. check-cost.md measured all eight loop-body checks, and memory safety is not uniformly free: s[lo..hi] costs 2.16×, the most expensive integer check in the language. It is the same kind of check as s[i] and it traps the same way.

What makes s[i] free is that i < n is the loop’s own condition, so the compiler discharges it. Move the same two comparisons onto bounds loaded from memory and the cost is 2.16×; move them back onto the induction variable and it is 0.99×. Provable is free, and loaded is not — and this row happened to measure the one check in the language whose condition the loop always proves.

2.2 An overflow trap costs 1.46×, and it costs it as SIMD

Ten SIMD instructions to zero. This reproduces §3.2’s finding on a new kernel and a new compiler version: an addition that may trap is an addition whose order is observable, so a reduction cannot be split across lanes.

2.3 Deleting the trap does not reach C

This is the one that decides the third question. cancho without traps is 2.27× off vectorised C, and still emits no SIMD at all — it is 1.55× slower than even scalar clang. The trap accounts for 1.83× of cancho’s own cost (252.1 → 137.6); everything remaining is Cranelift.

So removing the trap is necessary and not sufficient. A GPU-shaped cancho on the current backend would be scalar, which on a GPU is the same as not having one.

And it will stay scalar — backend-limits.md §1.4. Cranelift has SIMD: vector types, rewrite rules, per-target lowerings. What it has no pass for is producing them from scalar code, because its SIMD is there for WebAssembly’s v128 rather than for a vectoriser. So the 2.27× attributed to the backend here is a property of its design and not a version it has not reached.

Corrected (parallelism.md §3.1): the table above is Cranelift’s, and Cranelift was the default when it was written. With --backend llvm, the default now, the same two programs on the same machine: wrapping 64-67 ms against C’s 66-69 (SIMD present: 54 instructions), checked 93-95 ms (1.4x C, scalar). So “deleting the trap does not reach C” is a statement about Cranelift: on LLVM it does, and the trap costs 1.4x on this memory-bound kernel and 2.6x on a cache-resident one. The finding that survives is the one about the trap: a checked loop is still not reordered, on either backend.


3. How a native GPU cancho would work

Worth writing down, because three of the pieces already exist and cost nothing to reuse.

A kernel is a function whose row is empty. A kernel may not open a file, syscall or allocate; here that is not a convention but the absence of Io, Fs, Heap and Ffi from a signature, and purity.md measured 35% of functions already proving it. No kernel keyword is needed: launch names the roots the way main already names emission roots, and the check is on the row.

Regions are address spaces. region a { … } is a lexically scoped arena whose references cannot escape — 11 must-reject fixtures enforce exactly that — which is the lifetime CUDA __shared__ has and does not check. Same rule, same checker, nothing new.

The device is a capability. Gpu(device) beside Fs(prefix), refinable by the existing narrow, with gpu_launch(d) and gpu_copy(d) as labels. cancho authority then names the device, and a program never handed one provably never launched.


4. What it would cost the design

&! must mean uniqueSince measured, and it is the expensive row — aliasing.md. Not because it refuses working code (it refuses one fixture in 82 programs) but because one of the three aliasing routes, a reference returned from a call, closes only with provenance in signatures: lifetimes, and a borrow checker the non-goals exclude. GPU is what makes it mandatory rather than optional — on one thread two aliasing writes are defined and ordered, and two lanes writing through aliasing references is the race the checker should catch — so this row is where the GPU question stops being about speed
RecursionGPUs have no stack for it; std.io.print_nat is recursive. Kernels become a subset, and no row can say a function is in it
BarriersA barrier reached non-uniformly is undefined behaviour on real hardware — the one thing this family of languages refuses to have. Making that checkable is the research contribution, and cancho has nothing to donate to it
Two backends, foreverCranelift stays because it is the fast dev path
The trap model§5

4.1 The trap model is the thesis, and the hardware does not have one

defined-behaviour.md §2.1: an operation with no right answer stops. A GPU cannot stop — no signals, no per-lane abort. Three answers:

  1. Wrapping arithmetic in kernels — abandons the claim exactly where the compute is.

  2. Poison, reduced at the launch boundary — each lane sets a flag, launch answers some lane trapped. The claim survives; where does not.

    Measured (#65): poison.md, and the awkward part is which half it saves. Element-wise arithmetic under poison is free on a wide vector ISA — a + b inside a loop costs 0.98× once the overflow test is spelled as sign logic rather than as an opaque builtin. A reduction is not saved and cannot be: four lanes compute four different partial sums, so “no partial sum overflowed” is not the same property after vectorising, and poison there is worse than trapping. A reduction is what a GPU is for.

  3. Refuse trapping operations in kernels — a narrower language inside the braces.

Only (2) keeps the thesis, and §2.2 prices what (1) and (3) are buying: 1.46× on a mature backend, which is most of the reason to own a GPU.

But §2.1 halves the bill. Bounds checking is free, so a kernel dialect keeps array safety in full and gives up only overflow trapping. That is a much smaller concession than “no undefined behaviour does not fit a GPU” — it is arithmetic undefined behaviour, and it is recoverable by (2) at a cost this document has not measured because nothing here can run a kernel yet.


5. So: should lex-gpu be its own language?

Not yet, and the reason is §2.3 rather than taste.

The case for independence is real: a language whose failure model is trap and one whose failure model is poison are two languages, not one with a flag. That is the same argument that justified cancho existing beside lex-lang — different layer, different job.

But the measurement says the split buys nothing where it matters. Deleting the trap leaves cancho scalar; the thing that actually buys GPU speed is LLVM, and LLVM is equally required whether the kernels are a cancho dialect or a separate language. And the two genuinely novel parts — checked barriers and poison-as-an-effect — are new work in either design, because cancho does not have them to inherit.

What is worth saying about the shape: cancho is already six crates, so a sibling could depend on cancho-syntax and cancho-types and own only the dialect, the lane checker and the backend. Roughly 80% of the front end shared and none of the back end. That is a much better arrangement than a fork, and ROADMAP.md’s risk register names the alternative: “second-system trap — during a port every change lands twice.” A third repository makes it three.

And the plainest argument: cancho’s own README says not a usable language yet. A third sibling before the second is usable is a recognisable way for a project to end.

5.1 What would change this

SignalWhy it would settle it
An LLVM backend exists§2.3’s remaining 2.27× is Cranelift. With LLVM, the wrapping half reaching vectorised C makes the GPU question purely about the failure model, which is a language question and answerable
Poison measured, not assumedDone — poison.md. It has numbers now, on two instruction sets, and they say the trade is good for per-element work, no help for a reduction, and a net loss on a narrow vector unit
A program that wants itNothing in this repository is data-parallel. benchmarks-game.md’s kernels are the nearest, and they are single-threaded on purpose

6. Open

QuestionWhy it waits
Does poison cost less than trapping?Answered, and it splits — poison.md. On a wide vector ISA poison is a large win for every check whose condition is per-element: two become free and the worst drops from 3.28× to 1.33×. On the overflow check carried by a reduction it does not help and is worse, 1.37× → 1.73×, and four kernels show that is structural rather than a compiler limitation — “no partial sum overflowed” is a claim about one association order. On baseline x86-64 the whole win disappears: poison loses in eleven of twelve kernels
Checked barriers§4. A real research problem — roughly structured concurrency for lanes — and the part nobody else has done either
overflow-cost.md §3.2’s generalisationAnswered — check-cost.md. Six of the eight checks this language emits in a loop body take the SIMD count to zero, not one, and the axis is neither memory-safety nor arithmetic but whether the loop already proves the condition. §2.1 above is corrected in place: it measured the one check whose condition a loop always proves
A host-side GPU probe through FfiThe reach.md move: can a cancho program drive a GPU at all, with no new backend? It would answer a different question — reach, not speed — and would report ffi("libcuda") and nothing about the device, which is §5’s narrowing gap in a third domain

The folder against the backend

Status: a test, and it found one thing — though not the thing it was built to find.

cancho computes an expression’s value in two places. The constant folder (crates/cancho-ir/src/fold.rs, compile-time.md) works it out while compiling, and the backend emits instructions that work it out when the program runs. If the two ever disagree, a program prints one thing when its arithmetic happens to be literal and another when it does not. That is a silently wrong answer, and it depends on how the source was spelled.

The folder’s own documentation said a test checked this. The test did not exist. This document is about the test that exists now: 5,156 cases, three ways each, no disagreements. A correct result is only evidence if the test could have failed, so it was also run against five deliberately broken folders, and it caught all five. Writing it surfaced a different fault: bits_of of a NaN printed different numbers on the two CI targets.


1. The test that was cited and never written

fold.rs documented bin, the function that folds a binary operator, like this:

Getting one of these wrong would be the silently-wrong answer the language exists to refuse, so bin_traps_exactly_where_the_backend_does checks them against a running program rather than against this comment.

grep finds the name once, in that comment, and nowhere else in the repository. The one disagreement this test would have caught did ship: int::MIN % -1. The folder refused it as a certain trap, and the backend computed 0. It was found in #71 (emitted-checks.md §4.2), by someone reading the disassembly for a different reason. The audit that followed listed the missing test as item C2(b):

Differential testing of the constant folder against the backend, since #71 found them disagreeing on int::MIN % -1. For every foldable expression, compare the folded value with the compiled result.

The comment now names the_folder_agrees_with_the_backend and says what it used to claim.


2. What is compared

2.1 Every operator, on the operands where the rules have edges

Operand typeOperatorsOperandsCases
int+ - * / % << >> & | ^ == != < <= > >=1416 × 196 = 3,136
float+ - * / == != < <= > >=1410 × 196 = 1,960
bool== != && ||24 × 4 = 16
unary-int, ~int, -float, !bool14 + 14 + 14 + 2 = 44
34 operators5,156

The integer operands are both ends of the range and one step in from each, the shift amount’s edges on both sides (-64 -63 63 64), and the small values every rule has a case for (-2 -1 0 1 2 3). The float operands are both zeros, 0.1 (which binary cannot represent exactly), the largest finite magnitudes, the smallest subnormal, the smallest normal, both infinities, and NaN with each sign. The operators are exactly the ones fold::bin, fold::negate, fold::not and fold::bit_not accept.

2.2 Three arms per case, because the folder has two entrances

ArmHow the operands reach the operatorWho computes it
literalfn l17() -> [] int { return (a) op (b); }the folder, during lowering
callfn c17() -> [] int { return int_add(a, b); }the folder, in evaluate_calls after lowering
run timeival(j / 14) op ival(j % 14), where j comes from a loop counterCranelift, when the program runs

Each arm has to be shown to be doing its job, or the comparison proves nothing:

  • The literal arm’s traps are refusals. A certain trap is a constant-traps error, and check reports every refusal rather than only the first (agent-errors.md). Each case is a function on its own line, so a single check --output json run gives the folder’s complete trap set, and the reported line number identifies each case. The test also asserts that nothing was refused under any other rule.
  • The call arm is folded, not just compiled. authority --output json reports folded_calls, and the test requires it to equal the number of cases that produce a value: 4,720 of 4,720. If a call were not folded, it would be run time compared with run time, and would pass for the wrong reason.
  • The run-time operands are invisible to both optimisers. ival(k) is a pure function, but k is a loop variable, so the folder has no constant to evaluate. Cranelift 0.121 has no inliner, and cancho gives it one function at a time. Neither of them can see an operand.
  • A trap in the run-time arm is found without losing results. Standard output is fully buffered when it is not a terminal, so a trap discards every line since the last flush. The first prototype lost results this way and reported 1,457 false disagreements. So the run-time program writes each result to standard error, which is unbuffered, and takes its starting case from argv[1]. When a case kills it, the harness records the trap and restarts at the next case. It needs 437 processes: one for each of the 436 traps, plus the final clean run. Every trap is a crash the host has to handle (a core dump, or whatever core_pattern pipes it to), so the loop is bounded three ways. A run that neither finishes nor traps within 30 s is killed. More than 20 traps the folder did not predict stop the test early, so a systematically broken run costs 21 crashes, not one per remaining case. The whole phase has a 300 s budget. Each stop reports the number of runs, the slowest run, the time per trapping run and the host’s core_pattern. This bounding was added after the first CI run on linux-x86_64 sat in the test step for over 25 minutes with no output. §3.2 has what it then reported. The test also requires every trap to end the process with a signal: an exit code would be a different failure, not a trap (defined-behaviour.md §1).

Every result is printed as an int: a bool as 0 or 1, and a float as its bits_of. Printing float bits means 0.0 and -0.0 cannot compare equal by accident, and neither can two different NaNs.


3. The result

No disagreements. The literal and call arms agree with each other on all 4,720 cases that produce a value, and both agree with the run time. The folder refuses exactly the cases that trap at run time: 436, by operator:

OperatorTrapsWhy
<<, >>126 each9 of the 14 amounts are outside 0..64, for every left operand
*74overflow
+, -40 eachoverflow
/1514 zero divisors, and int::MIN / -1
%1414 zero divisors, and not int::MIN % -1, which is 0 (#71)
unary -1-int::MIN

No float operation traps, and nothing traps on bool (floating-point.md §2).

3.1 It fails when it should

A zero from a test that could not have failed means nothing. So each of these one-line changes was made to fold.rs, and the test was run against each one on its own:

MutationCases that disagree
Remove the Rem if b == -1 line, which is #71’s fix undone1: int::MIN % -1, with the folder refusing a program the runtime answers
Shift range 0..64 → 0..=6428
a <= b → a < b, on int and float28: the 14 equal integer pairs and the 14 equal float pairs, 0.0 <= -0.0 among them
Arithmetic >> → logical >>24
Float negation -x → 0.0 - x1: -0.0, which 0.0 - 0.0 is not. (Before §4’s repair it was 3, because the two NaNs differed too.)

The first row is the exact bug #71 found by reading the disassembly. This test finds it in about a second.

3.2 What it costs

The whole comparison takes about 1.6 s with a release compiler. That covers three compilations of about 10,000 functions each and 437 processes. Under cargo test, where the compiler is a debug build, it takes about 12 s.

That is on a machine whose core_pattern is core with a zero core limit, where a trap costs 2.4 ms. The GitHub Linux runner pipes every crash to systemd-coredump, and with the bounds above in place it reported:

a run from case 1111 neither finished nor trapped in 30 s: 270 runs,
269 traps, 232.6s elapsed; slowest run 2.7s (from case 1056, trapped);
544.3ms per trapping run; core_pattern
`|/usr/lib/systemd/systemd-coredump %P %u %g %s %t 9223372036854775808 %h %d`

That is 544 ms per trap, about 230 times the local cost, and it slows down as the crashes pile up, until one run exceeded 30 s. So the first CI run did not hang. It was paying for 436 core dumps, one at a time.

The repair took three attempts, and the two that failed are worth keeping:

AttemptWhy it looked rightWhat CI said
Make the binary 0711Linux never dumps a process whose executable its user cannot read. A local check as a different user agreed532 ms per trap. The owner’s read bit was still set, and the owner runs the tests
Make it 0100Owner-only execute. Checked locally as the owning user928 ms per trap, one run 26 s. systemd’s 50-coredump.conf, the file that installs that core_pattern, also sets fs.suid_dumpable = 2, and at that setting Linux dumps non-dumpable processes too, as root, through the same pipe
Set the child’s RLIMIT_CORE to 1Measured below, against the runner’s configuration reproduced locallyThe fix

A pipe ignores RLIMIT_CORE, which is why 0 does nothing. 1 is the exception: the kernel reserves it to catch a dump helper that itself crashes, and it answers RLIMIT_CORE is set to 1, aborting core without starting the helper. This was measured here with a core_pattern piped to a helper that sleeps half a second, which stands in for systemd-coredump:

RLIMIT_COREOne trap
unlimited509 ms
0509 ms
15 ms
2507 ms

With a piped core_pattern and fs.suid_dumpable = 2, which is the runner’s configuration as far as it can be reproduced off the runner, the whole test finishes in 11.3 s. The trap is unchanged: the process still dies of SIGILL or SIGFPE. Only the dump is skipped, and only on Linux, where without_a_core_dump sets the limit between fork and exec.


4. What writing it found: NaN was a different number on each target

compile-time.md §6 is what makes folding legitimate in this language:

an expression has one value, and it is the same value on every host and every target.

The float operands include NaN, and the obvious way to compare two floats exactly is bits_of. That raised a question the differential test cannot answer, because it runs on one machine: which bits does a NaN have?

IEEE-754 leaves a generated NaN’s sign and payload to the hardware, and the two CI targets make different choices:

Targetbits_of(0.0 / 0.0)Pattern
x86-64-22517998136852480xfff8000000000000: sign set, which x86 calls the “QNaN floating-point indefinite”
aarch6492211202370410905600x7ff8000000000000: the architecture’s default NaN

The x86-64 value was measured on the machine that wrote this document. The aarch64 value is the architecture’s documented default NaN, and every_nan_has_one_bit_pattern checks both by computing the hardware’s own 0.0 / 0.0 on whichever machine runs it. The linux-x86_64 and darwin-aarch64 CI jobs each check one of the two rows.

So print_int(bits_of(0.0 / 0.0)) printed a different number depending on where the program ran. The folder was not at fault: there is no cross-compilation, so the folder always runs on the target and agrees with it. The fault was in §6’s claim, which was false for one builtin.

4.1 The repair: one NaN

bits_of now answers 0x7ff8000000000000 for every NaN. That is the positive quiet NaN with an empty payload, and it is also aarch64’s default NaN and RISC-V’s canonical NaN. On x86-64 it compiles to two more instructions and no branch:

movq    %xmm0, %rax
ucomisd %xmm0, %xmm0        ; unordered only for NaN
cmovp   CANONICAL_NAN, %rax

Two reasons this is the right repair and not a documented exception:

  • Nothing a program can do reaches a NaN’s payload by arithmetic. (Corrected: float_of_bits, edition 7, can build a NaN with a chosen payload – floating-point.md §4.1 – and the sentence before this one said no program could.) A NaN is only ever seen by bits_of as the one canonical pattern, so what is thrown away is the hardware’s accident and a payload a program built itself, which it can never read back. The one exception is a NaN returned by foreign code, which sits behind ffi and is already reported as unbounded (under-a-grant.md §5.1).
  • bits_of is the only way to see a NaN’s bits. == and < are false for any NaN, is_nan answers true for any NaN, std.fmt.float_into prints NaN before it looks at the bits, and truncate traps. Fixing the one operation that reads the bits makes the property true for the whole language, without adding a check to every float operation. Cranelift’s enable_nan_canonicalization setting does the latter: it puts a compare and a select after every fadd, fsub, fmul, fdiv and sqrt in the program, to protect the one operation that can observe the result.

The corrected claim in compile-time.md §6 now says this.


5. What this does not cover

  • Only one operator per function body. The call arm’s helpers are return a op b;. What evaluate_calls does with loops, if, recursion, static tables, byte_of and int_of is exercised by the existing compile-time tests, not by this comparison.
  • The folder’s traps inside calls. A call whose evaluation traps is left unfolded rather than reported, because the IR has no spans (compile-time.md §9). That falls back to run time, so it is safe by construction and has nothing to compare.
  • The edges of the float space, not its interior. The operands are the edges, not a sample of the interior. Rust’s f64 and the hardware run the same IEEE operations, so the interior is where disagreement is least likely. A seeded random sweep would cover it, and nothing has needed one yet.
  • C2’s other two items. (a) was fuzzing the parser and the checker, and (c) was reporting a Cranelift verifier failure as an internal rule with a span. Neither is done here: (c) is internal-errors.md, and (a) is fuzzing.md.

6. The suite

TestWhat it pins
the_folder_agrees_with_the_backend§2 and §3: 5,156 cases across three arms, the trap sets equal, the values equal, every call arm folded, every trap a signal, and the counts (5,156 and 436)
every_nan_has_one_bit_pattern§4: nine ways to make a NaN, folded and at run time, all reading back as 0x7ff8000000000000, and the hardware’s own NaN measured on the machine running the test

When the compiler is wrong

Status: settled and built.

The audit’s C2(c): “catch Cranelift verifier errors and report an internal rule with the span of the function being lowered, never a bare panic.” This document measures what a backend failure gives a reader today. It is worse than the audit described in one way the audit did not name: check passes the program.


1. What a backend failure gives today

emitted-checks.md §1 found a real one: a folded byte-returning call that the Cranelift verifier refused. #71 fixed it, and for this measurement it was put back for one build and then restored. The program that hit it:

fn g(n: int) -> [] byte { return byte_of(n); }
fn main(world: World) -> [] int {
    release(world);
    let t = int_of(g(65));
    return t - 65;
}
CommandAnswerExit
cancho checknothing0
cancho check --output json{ "refused": [] }0
cancho buildcode generation failed: in `main`: Compilation error: Verifier errors3

Three things are wrong with that, and only the first is the one the audit named:

  1. No rule and no position. The one piece of structure is the function’s name, inside the prose.
  2. Exit 3 is the wrong category. The CLI’s own table documents 3 as “the environment failed: no linker, unwritable output, unsupported host.” A compiler bug is reported as the machine’s fault. An agent that branches on exit codes checks its toolchain, not its program.
  3. check says the program is fine. check stops after lowering and never runs the backend, so the tool agent-errors.md built for machines answers an empty list for a program that will not build. An agent loop of check, then build passes its gate and then fails in a step it was told was only about linking.

There is a second way the backend fails, and it is found by reading the code, not by running it. cancho-codegen has 22 unreachable!, panic! or .expect( sites outside its tests. Nearly all of them say the same thing: “the checker should have refused it.” Each one is an invariant between the front end and the back end, and if one ever breaks, the reader gets a Rust panic message and exit 101. That is not even one of the four documented codes.


2. The rule

A backend failure is a refusal with rule internal:

sort.cho:12:1: error: the compiler failed to generate code for `merge`; this is a bug in cancho, not in the program (Compilation error: Verifier errors)
{
  "rule": "internal",
  "message": "the compiler failed to generate code for `merge`; this is a bug in cancho, not in the program (Compilation error: Verifier errors)",
  "explanation": "The compiler failed on a program it had accepted. The program is not at fault; the position is the function whose code could not be generated.",
  "position": { "file": "sort.cho", "line": 12, "column": 1 }
}
  • Where it points. At the declaration of the function whose code failed. That is the smallest unit the backend works on, and the one a reader can act on: rewrite that function to avoid the construct, and report the bug. A generic instance points at its generic declaration. A failure with no function (defining a data object, finishing the module) has no position, as agent-errors.md §5 allows, and none is invented.
  • The cause stays in the message. Cranelift’s own text follows in parentheses. It is what a bug report needs, and dropping it would make the rule less useful than the prose it replaces.
  • Exit 1, not a new code. The program is refused: it cannot be built, whoever is at fault. A consumer that already reads check --output json handles this with no change, and tells it apart by the tag. A fifth exit code would change the contract that every consumer branches on, to say something the tag already says.
  • The tag is stable (agent-errors.md §3.1). It names a category that should stay empty. A program that reaches it has found a bug.

3. check runs the backend

A program check accepts should build, and the only reasons left for build to fail should be the environment’s: the linker, a disk. So check now generates the object code too, and throws it away.

Measured on this machine, median of seven runs:

Programcheck beforecheck nowRatio
examples/sort/4.8 ms11.0 ms2.3×
examples/base64/4.2 ms8.3 ms2.0×
examples/fetch/4.2 ms8.6 ms2.0×
examples/tour.cho5.4 ms12.6 ms2.3×
the differential’s 9,500-function program222 ms570 ms2.6×

About twice the time, and a few milliseconds in absolute terms on every real program here. That is the price of check being a promise and not a guess. authority, ids and layout do not run the backend, because what they report is about the checked program, and a backend failure would still stop build.


4. A panic is caught at the function

Each function’s code generation runs under catch_unwind. A panic becomes the same internal refusal, located at the function that was being generated, with the panic’s own message as the cause. While the backend runs, the default panic hook, which would print Rust’s “thread ‘main’ panicked at”, is replaced by a silent one and then restored. The reader sees a located refusal, not a backtrace hint.

This is a boundary, not error handling. Code generation for that program stops at the first failure; there is no partial object. What changes is only what the reader is told.


5. How it is tested without a bug to test it on

Nothing here fails today, which is the point. So the tests build what the front end never produces: an IR function whose declared return type disagrees with what its body returns. That is #71’s bug in its smallest form, written straight into the IR:

  • a_backend_failure_names_its_function (in cancho-codegen) checks that the verifier’s refusal comes back naming the function that failed.
  • a_backend_failure_is_a_located_internal_refusal (in the CLI) takes a real checked program, breaks one function’s IR, and checks the refusal: rule internal, positioned at that function’s declaration, and saying it is the compiler’s bug.
  • a_backend_panic_is_a_located_internal_refusal does the same for a panic, by handing the backend a node the checker would have refused.
  • every_rule_has_a_fixture lists internal as covered elsewhere, beside not-public, because no source file can reach it on purpose.

6. What this does not do

  • It does not find the bugs. That is C2(a), fuzzing the parser and the checker, now fuzzing.md. This makes what fuzzing finds reportable, and it gives a fuzzer a tag to count.
  • It does not change a single existing refusal. The 52 rules, their messages and their fixtures are byte for byte what they were (the_prose_is_unchanged_by_the_json).
  • It does not put spans in the IR. Each function carries the span of its declaration and nothing finer. compile-time.md §9’s reason for keeping expressions spanless still holds: a folded call that traps is left alone to trap at run time.

Porting a program that already existed

Status: done once, and the once is the point.

Every program in examples/ until now was written here, in this language, by someone who knew what it could do. That is the weakest possible evidence about a language, and an outside reader said so: “the strongest next signal would be porting one non-trivial existing C program and publishing what the effect rows and authority report looked like in anger.”

So: base64, from GNU coreutils 9.4. This is what happened.


1. The result first

examples/base64/base64.cho is byte-for-byte GNU base64 on encode and decode, including the 76-column wrapping, the padding, the trailing newline, and the exit status on malformed input. The conformance suite pipes the same bytes through both binaries and compares (base64_agrees_with_coreutils): twelve input sizes, both directions, three malformed inputs, and a megabyte.

131 lines of code. It needed two things that did not exist and nothing else.

“The program” turned out to be two programs. The suite passed on linux and failed on macOS at the first case: empty input, where GNU prints nothing and the BSD base64 macOS ships prints a newline. Neither is wrong. A port targets an implementation, and this one targets GNU 9.4 — so the comparison now checks --version for “GNU coreutils” and skips the comparison otherwise, while the round trip, the exit statuses and the megabyte still run everywhere. Worth one paragraph because “port it and compare against the original” quietly assumes there is one original.


2. What it needed: bits

RFC 4648 §4 is a bit-packing specification, and this language had no bit operators. That is docs/bitwise.md, which exists because of this port and follows defined-behaviour.md §8’s rule — each gets a rule here in the slice that adds it, before the code that needs one.

With them, the whole of the encoding is four lines:

column = emit(io, int_of(table[(bits >> 18) & 0x3f]), column);
column = emit(io, int_of(table[(bits >> 12) & 0x3f]), column);
column = emit(io, int_of(table[(bits >>  6) & 0x3f]), column);
column = emit(io, int_of(table[ bits        & 0x3f]), column);

Which is the C, with int_of(table[...]) where C writes table[...].

And hexadecimal literals, which is a smaller thing that turned out to be the same thing: & 0x3f written & 63 is a mask nobody can read, and 0xffff_ffff written in decimal is worse. bitwise.md §1.1.

Neither was a design question anybody had been avoiding. Both were absent because nothing had asked.


3. What it did not need

No new capability. No library. No change to linearity, regions, monomorphisation, effect rows, or the standard library. The port used std.io and nothing else, and the six capabilities are the same six.

That is the more interesting half of the result, and it is the half that could not have been known without doing it.


4. The rows in anger

$ cancho authority examples/base64/base64.cho --std
performs
    args
    err_write
    io_read
    io_write
never touches
    the filesystem
    the heap
    foreign code

Four lines, and every one of them is right in a way that is checkable from outside the program: a codec reads its input, writes its output, parses a flag, and complains when the input is not base64. (err_write is the newest and is the whole of standard-error.md; this program exited 1 in silence until then.) It opens no file. It allocates nothing. It calls no C.

Compare reach.md §5, where the same tool says ffi("libc") about a web server and cannot say net. The difference is not the tool, it is whether the authority the program uses has a name — and base64’s authority is entirely made of things that do: Io, Args. A program built from named authority gets an exact report; a program that reaches through Ffi gets a coarse one. This port is the positive control for that claim, and the web server was the negative one.

4.1 And the heap row is load-bearing, not decoration

never touches the heap is true because the program streams: three bytes in, four characters out, nothing buffered. That was not a stylistic choice. An arena is one 64 KiB chunk (heap.md), standard input is not bounded by 64 KiB, and the only way to write this program at all was the way the C writes it.

A constraint produced the right architecture, which is a nicer outcome than it sounds and is worth one sentence of scepticism: it produced the right architecture here, for a codec, where streaming is natural. It would have produced an awkward program for something that needs the whole input at once, and that program would have had to reach for std.buffer and grown a heap row saying so.

The megabyte in the test is the evidence rather than the claim: 1 MiB through a 64 KiB arena, round-tripped.


5. What it found in code that was already here

Two things, and neither was in the port.

A real bug in examples/serve/. The HTTP server read the request with a single read and routed whatever arrived. One read returns what has arrived, not what was sent — so under load it routes half a request line and answers 404 to a request for /health. The conformance suite failed about one run in six on a busy machine, and passed thirty times in a row on an idle one.

It is a bug the port did not cause and did not contain. It surfaced because this slice ran the suite under heavy load for an unrelated reason, which is the kind of luck that only happens when there is a suite to run. serve.cho now has read_request, which loops to the end of the request line, and ten consecutive full runs are clean.

Two tests that had quietly stopped testing anything. tests/reject/unknown_character.cho asserted that ^ is not a token, and so did a unit test in the lexer. It is now. Both were caught immediately — the fixture’s stated error no longer matched, and the unit test’s assertion failed — which is exactly what a fixture that states its own expectation is for.

Both use @ now, with a comment saying the thing worth remembering: a test whose subject is “this is not a token” has a shelf life, and the expiry date is whenever the language grows.


6. What this does not establish

base64 is 131 lines. It is a real program with real opinions and a real reference implementation to be wrong against, and it is small.

What a larger port would test that this one did not:

Untested by base64Why it mattersAnswered in
Linearity at scalebase64 owns almost nothing. A program with resources flowing through a call graph is where “exactly once” either stays readable or does not§9.2
Effect-row plumbing at depthThe deepest call chain here is three. reach.md’s worry — every helper that transitively prints needs the row — needs a program with a real call graph§9.3
borrow mut’s strictnessIt appears once, around the whole body. The friction people report is in code that wants to read one thing while writing another§9.4
The heapUntouched. A port that needs one is the port that exercises heap.md in anger§9.1

So this is one data point and it is a good one: a real program, ported faithfully, verified against the original, needing one missing feature that was missing for no reason. It is not the claim that everything ports — which is why there is a §9.


9. The second port: sort

examples/sort/ is LC_ALL=C sort with no flags: read the files named on the command line, or standard input when none are, sort the lines by byte order, write them out. Checked against GNU sort the same way — seven input shapes, named files, several at once, a missing file, and a 1.2 MB file, all compared byte-for-byte (sort_agrees_with_gnu_sort).

It was chosen because it needs every one of §6’s four.

It needed four library functions that did not exist, and every one of them was absent for the same reason: nothing had asked.

MissingWhy nobody had noticed
vec.setA vector could be pushed and get but never written. push and get were what the first caller wanted; a sort is the first caller that wanted the third
vec.swapExpressible with the other two, and every sort writes it
buffer.roomfs_read writes into a slice you hand it, and every std.buffer operation took the bytes as an argument — the wrong direction when the filesystem is producing them
buffer.filledThe other half: a buffer cannot see a write it did not make, so the caller commits the count

None is a design question. vec.set is three lines and buffer.room is two. What is worth noticing is the shape of the gap: the library had grown exactly the operations its existing callers needed, and the first program with a different shape found four holes in an afternoon. That is an argument for more ports rather than for more library review.

9.1 The heap, and what fs_read cannot tell you

Five owned resources, all on the heap: the text, two parallel runs saying where each line is, and the permutation being sorted with its scratch. main creates all five, lends them down, and destroys all five.

Under valgrind, sorting two files:

total heap usage: 8 allocs, 8 frees, 217,248 bytes allocated
in use at exit: 0 bytes in 0 blocks
ERROR SUMMARY: 0 errors from 0 contexts

Eight and eight — the five, plus the reallocation each growable one did on the way. Nothing here is a runtime checking that; the balance is what the type checker refused to compile without.

The awkward part is not ownership, it is that fs_read cannot report truncation. It “fills as much of into as the file has and returns the byte count” (filesystem.md §3), so a file that exactly fills the buffer is indistinguishable from one that was cut short. There are no handles and no way to ask a file’s size, so read_file reads into 64 KiB, and if the answer came back equal to the buffer it doubles and reads the whole file again. A 1.2 MB file is therefore read six times.

Measured since, and sharper than this paragraph. file-handles.md §1 traced it: six reads, but 2.75× the file’s bytes rather than six times them, because each attempt stops at its own capacity. The constant factor is under 3 at every size, which is a weaker argument than “six times” sounds.

The real costs were the two this paragraph missed, and both have since been fixed without handles. The loop gave up at 8 MiB, not the 16 MiB sort.cho claimed, so the example could not sort a file of 8,388,608 bytes or more (§1.1) — it now reaches 1 GiB, with a fixture past the old bound in the conformance suite. And giving up returned the same -1 as a file that could not be opened (§1.2) — it now answers -2 and exits 3.

What survives is the part sort.cho cannot reach from inside itself: neither failure prints anything, because there is no standard error (reach.md §6). A script can tell them apart; a person cannot.

That is not a bug and it is the cost of filesystem.md §3’s own position: “a file handle is a linear resource — it is precisely the thing this language exists to track — and giving it a type means deciding what close consumes… That is a milestone, not a paragraph.” The milestone now has a program waiting for it.

9.2 Linearity at scale: the move loop

The shape that repeats everywhere:

out = buffer.push(heap, out, byte_of(c));

std.buffer and std.vec are move-based — every operation takes the resource by value and hands it back — so a loop that fills a buffer moves it round and round. Six sites in this program do it.

The honest verdict: it reads fine and it is noisy. out = f(h, out, x) says exactly what happens and never lets you forget which value you have, and it is three tokens longer than f(&mut out, x) every single time. Nothing about it was hard. Nothing about it was pleasant either, and a reader looking for the ergonomic cost of linearity should look here rather than at the type signatures.

One thing it did make easy: the failure path. read_file returns (Buffer, int) so the buffer comes back even when the read failed, because a function that owns a resource and takes an error exit has to say what happened to it. The tuple is not elegant; it is also not something you can forget to write.

9.3 Rows at depth

merge takes five references and its row is []. read_file’s is [heap, fs_read("")]. main’s is [], because it owns.

reach.md’s worry — that every helper which transitively does something needs the plumbing — did not materialise here, and the reason is specific rather than lucky: this program’s effects are concentrated at its edges. Reading and writing happen in four of the eight functions; the sort itself touches nothing, so the other four declare []:

read_stdin    [heap, io_read]
read_file     [heap, fs_read("")]
find_lines    [heap]
write_lines   [io_write]
before        []
merge         []
msort         []
main          []

Half and half, and the half that declares nothing is the half doing the work the program is named after.

That is probably typical of programs shaped like this one and probably not typical of everything. A program that logged inside its inner loop would thread io_write through all eight, and the row would be right to make that visible — but it would be eight annotations rather than four.

9.4 borrow mut in a program that needed it

This is the one that surprised me. The sort holds five things at once — text, starts, lengths shared; order and scratch unique — and the checker took it without complaint, because they are five different values and borrow mut freezes only what it borrows.

The friction people report with lexical regions is about wanting to read one field while writing another of the same value, and this program never needed to. What it needed instead was five nested borrow blocks in main, indented five deep, to hand those references to one call. That is the real cost here and it is syntactic: the rule was never in the way, the indentation was.

9.5 And the authority report

performs
    args
    err_write
    fs_read("")
    heap
    io_read
    io_write
never touches
    foreign code

fs_read("") is unnarrowed, and that is correct rather than sloppy: this program reads paths its user supplies, so there is no prefix it could commit to. Compare examples/lines.cho, which narrows to /tmp because it chooses its own paths.

So the report distinguishes a tool that reads what you tell it to from a tool that reads somewhere specific, and the difference is legible without reading either program. That is the narrowing story doing the job it was built for, on a program written to a specification that had never heard of it.


10. sort again, through a handle: the I/O went, the time did not

file-handles.md §1 measured what the absence of handles cost this program: fs_read cannot report truncation, so read_file doubled a buffer from 64 KiB and re-read the file from the beginning each time — six reads and 2.75× the file’s bytes on a 1.16 MB file, a ceiling where the doublings ran out, and a “file too large” status GNU does not have.

Handles shipped, so this is the port. read_file now opens once and reads chunks until file_read answers End.

10.1 The bytes

strace, counting reads of the input’s descriptor:

filereads beforebytes beforereads afterbytes after
100,00021.66×31.00×
1,160,00062.75×191.00×
3,000,00072.38×471.00×
8,388,62092.99×1301.00×

Exactly once, at every size, with no trend left to have. §1’s figures reproduce to the byte, which is the part that says the method was sound.

And the syscalls go up, because that is the trade: the old loop’s reads grew with the logarithm of the file and the new one’s grow with the file. Six large reads became nineteen small ones.

10.2 The time, which is the part worth reading

8.4 MB file
before, doubling retry450.8 ms1.000×
after, 4 KiB chunk414.8 ms0.920×
after, 64 KiB chunk420.0 ms0.932×
after, 1 MiB chunk417.5 ms0.926×

Cutting the I/O by three times bought 7%. On the 1.16 MB file it bought 5.6%, which this harness cannot cleanly resolve from noise (benches/README.md).

That is bulk-io.md §4.1’s correction arriving from the other direction. There the lesson was that volume written is not time spent writing; here it is the same sentence about reading. A sort spends its time sorting, and a reader that does three times less I/O moves the total by single digits.

The three chunk sizes are the second half of it: 4 KiB, 64 KiB and 1 MiB are within 1.3% of each other, at 2,048, 130 and 9 reads respectively. So the syscall count the port added costs nothing measurable either, and the chunk size is a free parameter rather than a tuning decision. 64 KiB stays because it was already there.

10.3 What it deleted, which is the real argument

The numbers are small; the code is not.

  • The ceiling is gone. Not raised — gone. There is no doubling loop to run out of attempts, so the limit is the heap, which is the limit a sort holding the whole file in memory always had. sort.cho used to carry fifteen attempts and a comment explaining why fifteen.
  • A status went with it. sort exited 3 with “file too large”, a status GNU has no equivalent for because GNU spills to disk. The arm is deleted rather than handled: this program now has one fewer status than GNU rather than one more.
  • The reason is in hand. open_read answers Failed(errno), so the number GNU turns into No such file or directory is finally reachable from a cancho program. It is not spent yet — printing 2 is worse than what is printed now, and the table that turns one into the other is a library nothing has asked for (file-handles.md §6).

The honest summary: handles were worth having for the code they delete, and the performance argument for them was never the strong one. §1 said as much when it measured the cost at “a constant factor under 3, not a factor of six” — and 3× of something that is 7% of the runtime is what that constant was worth.


7. Open

QuestionWhy it waits
A port with resources and depthDone — §9, and it found four missing library functions, confirmed the move loop’s cost is noise rather than difficulty, and left fs_read’s truncation problem with a program waiting on it
File handles§9.1. Reading a file of unknown size means reading it repeatedly, and filesystem.md §3 already says the fix is a milestone rather than a paragraph. There is now a program that pays for its absence
A borrow that takes several values§9.4. Five nested blocks to hand five references to one call. The rule was never in the way; the indentation was
base64 --wrap=N, -iDeliberately not ported. They are flag parsing, which is ROADMAP’s ordinary work, and adding them would have tested the flag parser rather than the language
An unsigned widthNot needed here, because base64 never sets the sign bit of an int. A port that hashes or checksums would need one within five lines (defined-behaviour.md §8)

8. The suite

TestShows
base64_agrees_with_coreutils§1: the same bytes as /usr/bin/base64, both directions, twelve sizes, three malformed inputs, and a megabyte through a 64 KiB arena
an_http_server_written_in_cancho_answers_a_real_request§5’s bug, after the fix — it is the test that found it

| sort_agrees_with_gnu_sort | §9: seven input shapes, named files, several at once, a missing file, and 1.2 MB past the first read — and, with no reference present, that the output is still a sorted permutation of the input |

ProgramShows
examples/base64/base64.choThe first port: bits, and a program that owns nothing
examples/sort/sort.choThe second: five owned resources, the heap, and rows at depth — and, since §10, the first program to read through a handle

Refusals a machine can read

Status: measured, then designed, then built (#59).

lex-lang’s docs/AGENT_GUIDELINES.md is a contract with a reader that is a program: lex check --output json answers a stable rule_tag, and §4’s rule is repair, don’t regenerate. Before #59, cancho had none of that: 196 must-reject fixtures, each of which answered a sentence written for a person.

§1 is the measurement that motivated the slice, kept because the method outlives the state it measured; it describes cancho as it was when #59 opened, not as it is. §2 is the part of lex-lang’s answer that transferred and the part that could not, and the line between them is not where the guidelines page puts it. §5 is the shape on the wire as it ships today; the catalogue is crates/cancho-syntax/src/rules.rs, and the plural is real: check answers every independent refusal, not the first.

Corrected in place. Until this correction, the paragraph above said “cancho has none of that” in the present tense — 42 slices after #59 shipped the catalogue, check --output json and the plural — and two readers outside the project took it as current. The convention that follows: a status header describes the head of main at the time of writing, and the slice that changes what a header describes updates that header in the same PR. Measurement sections stay as they were measured, with the revision named — a measurement outlives the state it measured, which is why §1 below is retitled “before #59” and dated rather than rewritten.


1. What a refusal gave a machine before #59

check run over every fixture in tests/reject/, at the revision where #59 opened (the corpus has grown since; this is the reading the slice was measured against, not the current one):

refusals carrying file:line:col193 / 196
distinct message shapes, with names and numbers normalised out125
shapes that occur exactly once101
errors reported per invocation1
machine-readable formnone

Three things follow, and they are independent problems.

1.1 There is no vocabulary, only prose

A program that wants to know which rule it broke has 125 sentence patterns to match, and 101 of them it will meet once. lex-lang answers that question with one field: 16 rule_tags cover every type error it can raise.

The three refusals with no location are program-level — no main function, and the two about main’s shape. They have nowhere to point because the thing that is wrong is the program rather than a span in it, which is a real answer and still leaves a consumer with a message shape it has to special-case.

1.2 One error per invocation

$ cat three.cho
fn a() -> [] int { return true; }
fn b() -> [] int { return oops; }
fn c() -> [] bool { return 1; }
fn main() -> [] int { return 0; }

$ cancho check three.cho
three.cho:1:27: error: expected `int`, found `bool`

Three independent errors in three independent functions; one reported. Across two files, the same: the first one found, and nothing about the second file. A parse error anywhere pre-empts every type error in the program.

This costs a person almost nothing and an agent a great deal. A person has the file open and sees the other two; an agent pays a compile, a read and a turn per error. lex-lang returns Vec<PositionedError> — the plural is the point.

1.3 The data is structured, then thrown away

The messages already end in the fix:

“verbose performs args, which its row [] does not declare; narrow the body or widen the row”

At the point that sentence is built, the checker holds the function, the label and the row as values. format! turns three facts into one string, and a consumer’s only way back is a regular expression over English.


2. Which half of lex-lang’s answer transfers

The checker half transfers exactly. lex check --output json emits

{
  "kind": "type_error",
  "rule_tag": "EFFECT_NOT_DECLARED",
  "position": {"file": "src/handler.lex", "line": 14, "col": 22},
  "rule_explanation": "effect `fs_write` reached at this position is not declared …"
}

and cancho had every ingredient: Diagnostic { message, span }, a SourceMap that resolves a span to file:line:col, and --output json already shipped on authority. What was missing was the tag and the plural — both shipped in #59, as §5 records.

The repair half does not transfer, and the guidelines page is misleading about why. §4.2 shows suggested_transform inline in the checker’s JSON, next to rule_tag, as though the two were siblings. They are not. In lex-lang’s source, rule_tag lives in lex-types/src/rules.rs — the checker — while suggested_transform appears only in lex-store, lex-vcs and lex-lsp: it is a repair hint recorded against an op id, consumed by lex repair --apply, and written back as a RepairAttempt attestation that lex blame --with-evidence can follow.

cancho has no op log, no store and no attestation graph, and ROADMAP.md keeps it that way — the content-addressed AST is here, the VCS built on it is lex-lang’s. So cancho repair is not the next slice and may never be one.

2.1 But the hint is not the attestation

What an agent needs from §4.2 is not the RepairAttempt. It is the fields: which label, which row, which function. Those are §1.3’s thrown-away values, and emitting them costs nothing but the decision to carry them.

So this document takes the rule-tag half whole, takes the hint’s data without its machinery, and leaves the repair command alone.


3. What a tag names

A tag names the rule a reader would look up, not the sentence the checker wrote.

Two fixtures that break the same rule share a tag however differently they are worded; two that break different rules get different tags however similar the wording. That is the only line that keeps a catalogue from drifting back into 125 entries.

Measured twice, and the second measurement is the one that matters.

Classifying all 196 refusals gives 125 shapes falling into 38 rules. But a refusal is what a fixture reached, and the catalogue has to cover what the checker can say. Classifying the 187 error sites in the source instead, and then correcting the classification site by site while tagging them, gives 52 rules — and the largest are the ones a reader would expect. (A 53rd, internal, was added later for the compiler’s own failures, not the program’s: internal-errors.md.)

rule
26type-mismatch
19linear-value-unconsumed
12effect-not-declared
12unknown-name
10reference-escapes-region
9mode-bound-violated
8linear-use-after-move

52 against lex-lang’s 16, and that is not bloat. cancho checks four things lex-lang does not have: linearity, regions, capabilities as values, and defined-behaviour arithmetic. Those four account for linear-*, reference-escapes-region, borrow-conflict, capability-*, region-*, mode-bound-violated and constant-traps on their own.

Two sites are structural rather than rules: one wraps another diagnostic to add “(checking f instantiated at T)”, and one is the parser’s err helper. Both carry the rule of whatever they wrap or are handed, rather than earning a tag.

3.1 A tag is stable

Once shipped, a tag never changes meaning. New rules get new tags; a rule that splits gets siblings rather than repurposing the parent. This is lex-lang’s rule and it is adopted verbatim, for the reason canonical-ast.md gives about hashes: a stable name is worth something only if it is actually stable.

3.2 Counting the rules made the coverage countable

43 rules had a must-reject fixture. 52 exist. Nine did not.

rule with no fixture
literal-forman unterminated string, a 0x with no digit, a float literal that is not one
foreign-declarationa builtin redeclared as foreign
region-mismatcha where clause naming a region the signature does not take
module-not-importeda qualified name with no import above it
not-a-functiona local binding in call position
not-a-placeassignment to an expression
not-a-tuplelet (a, b) = 7
not-an-enumPoint::Origin where Point is a struct
not-publicreaching a private name from another module

The conformance harness says, at the top of the file: “Adding a rule to the language means adding a fixture here… the discipline starts at M0, when it is cheap.” It was kept for 43 rules out of 52 — 83%, where the sentence reads as 100%.

That gap was not findable before. “Every rule has a fixture” is a claim about a set nobody had enumerated, so it could not be checked, so it drifted. A catalogue is what makes a coverage claim falsifiable, and that is an argument for tagging that has nothing to do with agents.

Eight of the nine fixtures are written in this slice. The ninth, not-public, the single-file harness cannot reach: pub is about reaching another module, a file declares at most one module, and a fixture here is one file. It is covered by the_module_rules_are_enforced_across_files, which is where a second file exists, and every_rule_has_a_fixture names that exception rather than pretending the count is clean.

Every reject fixture now declares the rule it is a fixture for:

//~ ERROR performs `args`, which its row [] does not declare
//~ RULE effect-not-declared

Declared rather than derived, which is the same argument as §3: a fixture says which rule it exists to pin, and a checker that starts answering a different one fails the suite instead of quietly retagging.


4. Reporting every error rather than the first

check stops at the first refusal. That is not a rule anyone decided — it is Result<_, Diagnostic> at 162 error sites and ? between them.

The rule this replaces it with:

Independent errors are all reported. Dependent ones are not invented.

Which is decidable here because of something the standard library slice already established: checking is total and runs per declaration (standard-library.md §5.2 — “every function, once”). A function whose body is ill-typed does not make the next function’s body unknowable, so each declaration’s refusal is collected and checking carries on.

Two places where it stops rather than guessing:

  • A parse error ends the file. A file that did not parse has no reliable second error, and a recovering parser inventing one is how a tool teaches an agent to chase phantoms. One parse error, and the program is refused.
  • A declaration that failed to check does not have its uses reported. If f’s signature could not be made sense of, every call to f is noise.

Errors come out in source order, which is the order checking already runs in.


5. The shape on the wire

$ cancho check tests/reject/args_effect_undeclared.cho --output json
{
  "refused": [
    {
      "rule": "effect-not-declared",
      "message": "`verbose` performs `args`, which its row [] does not declare; narrow the body or widen the row",
      "explanation": "Every effect a function's body performs appears in its row, and a function performs what the capability it was lent authorises.",
      "position": { "file": "tests/reject/args_effect_undeclared.cho", "line": 11, "column": 1 }
    }
  ]
}

An envelope with a list, not a stream of bare objects, because §4 makes the plural the normal case and a consumer should not have to discover how many it got by counting lines. It matches authority --output json, which is one object rather than a bare array for the same reason.

  • rule — §3. The stable name.
  • message — the sentence a person reads, unchanged. The prose is not the problem; being only prose was.
  • explanation — what the rule enforces, independent of this occurrence. lex-lang’s rule_explanation, and one per tag rather than one per error.
  • position — absent for §1.1’s three program-level refusals, rather than invented.

Corrected (#84). For a parse error, none of the above held until #84. The message was the whole rendered refusal (path, source line and caret) instead of the sentence, the position was null, and because the rendering has newlines in it and the writer escaped only quotes and backslashes, the answer was not valid JSON. That was 20 of the 214 reject fixtures. the_prose_is_unchanged_by_the_json compared one line of each message and passed throughout. Found while replaying this repository’s history through check --output json. every_refusal_is_valid_json_with_a_position now reads every fixture’s answer with a strict JSON reader.

The exit code is unchanged: 1 for a refused program. Semantic exit codes already exist here (1 refused, 2 usage, 3 environment) and a machine-readable body does not change what happened.

5.1 fix was designed here and is not shipped

An earlier draft of this section carried a fifth field:

"fix": {"kind": "widen-row", "function": "verbose", "label": "args"}

§2.1’s argument for it still stands — the data is in the checker’s hands at §1.3’s format! and is thrown away, and emitting it needs none of lex-lang’s store. It is cut anyway, because the argument for it is about a consumer nobody has watched. rule plus message plus position is already everything an agent needs to locate and classify; whether it then needs the fix spelled out as data, rather than read from the sentence that already spells it out in English, is a question a transcript answers and a design document guesses at.

So it is §7’s, with the evidence that would settle it named: an agent that has rule and still repairs the wrong thing. Shipping a field on the chance it helps is how an interface acquires parts nobody reads — and a field, once shipped, is as hard to withdraw as a tag.


6. What this does not do

Not an LSP. Not repair --apply, for §2’s reason. Not warnings — there are none in this language and this does not introduce a severity field to hold the absence. Not a change to a single existing message: §5 adds fields beside the prose rather than rewriting it, so the human-facing output of every one of the 196 fixtures stays byte for byte what it was.

That last is a property worth testing rather than intending, and the_prose_is_unchanged_by_the_json is where it is tested.


7. Open

QuestionWhy it waits
A fix field, and a cancho fix verb§5.1. The field was designed and cut: what would settle it is a transcript where an agent has the rule and still repairs the wrong thing. The verb is further out — applying a transform to source text is a different job from checking it, and there is no store here to record the attempt in
AGENTS.md for cancholex-lang’s contract says a downstream repo copies AGENT_GUIDELINES.md as AGENTS.md. cancho is not a Lex codebase — different language, different rules — so it needs its own, and it has none: an agent writing cancho today has 41 design documents and no entry point. Larger than this slice and the obvious one after it
cancho skill, the CLI surface as datalex-lang has lex skill; lex-os emits acli envelopes. This adds the third piece — machine-readable errors — and the surface is the piece still missing
WarningsThere are none. If one is ever added, §5’s object needs a severity and every consumer needs to handle it; adding the field before the first warning would be designing for a language that does not exist
Whether build and run answer JSON too§5 is about check. build fails for reasons that are not the program’s — a linker, a missing cc — which is exit 3’s territory and a different vocabulary. Narrowed (#78): a backend failure used to be one of those exit-3 reasons, and it was the compiler’s fault, not the environment’s. It is now an internal refusal that check reports too (internal-errors.md), so what is left for build alone really is the environment

Agent-shaped tools

Status: measured, then built.

docs/reach.md §1.1 says what decides whether a program is writable: not a feature list, whether the authority it needs has a name. docs/agent-errors.md §2.1 says what a machine reader needs from a refusal that a person does not: the fields, not the sentence. Both are claims about this compiler. This document asks whether they are also claims worth building a runtime tool on — one an agent calls the way it calls this repository’s own Grep/Read, not one a person types at a shell — and examples/seek/ is the answer, built rather than argued.


1. What “better fits an agent” has to mean, or it means nothing

A rewrite in a safer language is not, by itself, a different tool. GNU grep is not unsafe in the way that matters here — it does not have a buffer overflow waiting in it. So “no UB” alone is not the pitch; it is the floor every port in examples/ already stands on (base64, cut, sort). What would make a search tool specifically better suited to an agent’s own tool-use loop, rather than merely as safe as the one it already has?

Three properties, each one this repository had already built for a different reason and none of them assembled into one program before now:

  • An authority report the caller can read before running it. cancho authority --output json is not new (docs/under-a-grant.md), but nothing in examples/ had used it to make a specific, narrow claim the way seek does — see §2.

  • No silent truncation. docs/line-reading.md found cut answering a wrong result on a long line rather than a merely incomplete one, and fixed it by growing on the heap instead of capping. An agent pointing a tool at a file it has not read yet cannot know if a line is long, so a tool that gets quietly wrong past some size is worse than one that is slow past it.

    Corrected (agent-toolbox.md §2.1, §2.3). “No silent truncation” is true of what seek reads and false of what it writes. seek gamma big.txt > /dev/full exits 0: seek never looks at write_all’s result, and the failed flush of the last buffer is invisible to any cancho program (bulk-io.md §3.3, corrected in the same change), so truncated output and success are indistinguishable to the caller. And seek holds the whole file in a heap buffer that doubles: peak resident memory was 8 MB for a 1 MiB file, 100 MB for exactly 64 MiB and 198 MB for a file 28 bytes longer (the doubling copies), 788 MB for 256 MiB. It is bounded by the file rather than by the line, which is the property agent-toolbox.md D8 asks of a streaming tool.

  • No UB on adversarial input. An agent frequently runs a tool over bytes it fetched from somewhere it does not control. defined- behaviour.md §2.1’s guarantee already covers this for free; §4 below is where it is exercised rather than only claimed.

2. seek, and the report it makes

$ cancho authority examples/seek/seek.cho --std --output json

names exactly seven labels — args, err_write, file_read, fs_read(""), heap, io_read, io_write — and bounded: true (docs/under-a-grant.md §5.1’s own flag: no foreign symbol, nothing an outside reader has to trust the program’s own word about). io_read is there because the standard-input fallback is unconditionally reachable from main, whether or not a given invocation takes it; err_write is the usage and per-file error messages. agent_tools.rs’s seek_reports_a_bounded_authority checks this stays exactly seven, the same way the_report_fails_closed already checks cut’s bounded flag.

That is the whole claim: not that seek is sandboxed — fs_read("") is the unnarrowed root, same as cut/sort today, §3.1 is why — but that what it can reach is legible, in seven words, to whatever decided to run it, before it runs. A person auditing a shell script has to read it; a supervisor deciding whether to hand a tool to an agent can read the seven words instead — the same trade reach.md §1.1 already named, applied to one more caller.

3. Two honest limits, found while building it

3.1 narrow takes a literal, so no flag can narrow this tool’s Fs

linearity-and-effects.md §7.4: “the argument to a narrowing function must be a literal.” docs/net.md’s bind/connect narrow to a bound baked into the program at compile time, and that is fine for those, because a program built for one grant is exactly what lex-os deploys. A general-purpose search tool is the opposite shape — the whole point is running it against a directory picked at invocation time — and M2 has no way to narrow Fs to a runtime string. seek could not have shipped with a tighter Fs than cut/sort already carry without abandoning the ability to be pointed anywhere, so it does not try, and says so here rather than in a comment nobody reads.

What this means for lex-os, concretely: a supervisor that wants an agent to have seek scoped to one directory does not get there with a flag. It gets there by building a copy of seek.cho with that directory substituted for Fs("")’s literal, the same way crates/cancho/tests/ conformance/backends.rs’s own bind test generates a program with a free port substituted in before compiling it (docs/llvm-backend.md §7.21). A capability fixed at build time and proved by the compiler is a stronger claim than one checked at run time against a flag a caller supplied — closer to docs/net.md §2’s inbound bound than to a shell tool’s --root — but it means “one binary, any directory” is not this tool’s shape, and pretending otherwise would be the report lying by omission.

3.2 -m counts across every file, not per file

GNU grep -m N stops after N matches in the current file and moves to the next. seek -m N stops after N matches total. Not an oversight: an agent capping output is bounding its own context, and “at most N lines back, from however many files” is what that means in practice far more often than “at most N from each.” Implementing GNU’s own per-file reading would cost a second counter reset at every file boundary for a distinction the caller this tool is built for rarely wants — so this diverges and documents it, the same way cut.cho already documents refusing an operand GNU would open (docs/porting.md).

4. What is not here

No regex: std.regex does not exist (confirmed by its absence, not assumed), so seek is a literal substring match, std.bytes.find underneath, the same primitive cut.cho and sort.cho already use. No recursive directory walk: nothing in this language lists a directory’s contents, so seek searches the files named on its command line, the same restriction cut/sort already accept for the files they read. Neither is a missing feature this document is asking for — std.regex and a readdir builtin are both real, sizeable slices with no asker behind them yet, standard-library.md’s own bar.

5. Checked

crates/cancho/tests/conformance/agent_tools.rs: a match, a miss, -n, -c, -m across two files, a missing file (exit 2), no pattern given (exit 2, usage), reading standard input when no file is named, and a file containing a NUL byte and non-UTF-8 bytes found and printed without crashing — defined-behaviour.md §2.1’s guarantee, exercised on a program built for exactly the case where an agent cannot vouch for its input. agent_tools.rs’s seek_reports_a_bounded_authority checks §2’s report claim directly.

An agent toolbox: unix-like tools written in cancho

Status: design, written before any tool code, with the probes it rests on run and recorded (Appendix A and B). Nothing here is built. Every claim about existing code cites the file it was read from; every number was measured on the prebuilt compiler in the environment Appendix B describes, and is a probe, not a result of the protocol in §7. Nothing in §7.2 has been run, and no claim that depends on it is made anywhere in this document.

This is the sequel to agent-tools.md, which built one tool (examples/seek/) and asked whether the properties this repository already had add up to a tool better shaped for an agent’s own loop. That document is prior art, not superseded: its §3.1 (a narrow takes a literal, so no flag narrows Fs) is load-bearing here, and two of its sentences are corrected below (Appendix C).

The question this one asks is the one the first left open: is a set of such tools worth building, and how would anyone know?


0. The position, tested

The position under test: an agent toolbox in cancho is a good idea only if it is scoped (about ten tools, not a coreutils clone, POSIX flag fidelity a non-goal) and measured (the “agent-friendly” claim needs a protocol with a gate, not an opinion).

It holds, and it needs four amendments that the evidence forced.

  1. The claim is two claims, and only one of them needs cancho. The contract (JSON on stdout, semantic exit codes, errors with a rule tag and a repair hint, determinism) is language-independent: it can be had by putting a thin envelope around rg, jq and sha256sum. What cancho alone can add is an authority row the compiler computes, determinism by absence of capability (a tool cannot read the environment, the clock or a tty unless it was handed the capability), and defined behaviour on hostile input. A protocol that compares the toolbox only with raw GNU tools confounds the two. §7.2 therefore has three arms, and the middle one (the incumbents behind a conforming shim) is the one that decides whether cancho earned anything. The position as first stated compared two things (GNU and the toolbox) and could not attribute a difference.

  2. “Statically known, narrow authority” is true of kind and false of extent. narrow takes a literal (capability-not-narrowable, measured, Appendix A.1) and a function cannot be generic over a prefix (Fs(p) is refused), so a tool that takes a path from its command line reports fs_read("") — the whole filesystem — however confined its behaviour. What a supervisor can read off the row is which verbs (no fs_write, no net_out, no ffi), never which directory. Extent comes from a per-deployment build (D14), from the perimeter, or from a mediator that reads the arguments (D13).

  3. Today, six of about ten tools are buildable. The three an agent uses most to orient itself — ls, find, tree — need a directory listing that does not exist (fs_list is not-a-function, A.2); grep with a regular expression needs std.regex (does not exist, A.3); http get over TLS needs Ffi, which makes the report bounded: false and destroys the premise. The first gate of the epic is therefore a language slice, not a tool.

  4. lex-os cannot read a cancho authority today, and the failure is silent. Fed a cancho label verbatim, lex-os-authority derives network: none for a program that dials a host and fs: none, net: none, exec: none for a program holding ffi("libc") (measured, §2.5). The “tool whose effect rows are exact has an authority a supervisor can check and narrow” argument is correct, and it holds only through a bridge that fails closed. That bridge is a slice (S2), not a given.

What survives all four is still worth building. The strongest argument is not “a safer grep”. It is that lex-os’s bounded commands are today a hand-written vocabulary (manifests/src/commands.lex), and a tool whose row the compiler derives lets that classification be derived too. In the one place I tested, the hand-written claim and the derived one disagree (§2.5).


1. What is wanted

1.1 Goals

  • G1. A contract an agent can rely on without reading prose. One JSON document (or NDJSON stream) per invocation, a versioned schema, semantic exit codes, every error a datum with a stable rule tag and, where one exists that a script can apply without judgement, a repair.
  • G2. An authority a supervisor can read before running. Exported by the compiler, never typed by a person, checked equal in CI, narrow in kind by construction.
  • G3. Determinism. The same bytes in produce the same bytes out, on any machine, locale, terminal, time and working directory.
  • G4. A claim that can fail. “More agent-friendly” is stated as measurable properties with gates (§7), and the part that needs an agent in the loop is not claimed until it is run.
  • G5. The language is the other customer. Each tool is a program that asks for something the language lacks; the gaps it finds are reported with reproducers, counted against CONTRIBUTING.md’s two-askers bar, not worked around silently.

1.2 Non-goals

  • Not a coreutils clone. About ten tools, each admitted by D1’s rule.
  • No POSIX flag fidelity. docs/flags.md §1 measured what fidelity costs: two ports of GNU programs got six of twelve spellings right and one of them answered --decode by encoding, exiting 0. The toolbox does not promise grep -r works; it promises that -r is refused with a tag (args.unknown-flag) rather than ignored.
  • Not a shell, and not a pipeline language. An agent composes tools by calling them in turn and reading JSON, not by |. Stdin and stdout work for the tools that need them.
  • Not a sandbox. docs/filesystem.md §2.1 says Fs “is not a sandbox, and this document will not pretend otherwise”; that stands. Containment against a hostile caller is lex-os’s job.
  • Not faster than the incumbents. Appendix B measures seek at roughly 3 to 5 times slower than GNU grep on one input (and slower still than rg). Performance is reported (§7.1, M9) and gated only against pathologies, never against GNU.
  • No process spawning, no network by default, no delete. The language has no exec builtin, so find -exec and xargs are not a thing the toolbox can be tempted into. No tool deletes (D15): that is what lex-os’s Reversibility::IrreversibleConsequential exists to keep out of a no-human system (crates/lex-os-manifest/src/lib.rs).

1.3 What the incumbents do well, and the honest case against each tool

Measured here where installed (grep 3.11, GNU coreutils 9.4, rg 14.1.0, jq 1.7, curl 8.5.0); fd and nushell are not installed in this environment, so what follows about them is from their documentation, not measured.

IncumbentWhat it already does wellWhere it falls short for an agent (measured unless noted)The honest case for not building the cancho tool
GNU coreutilsUbiquitous, fast (wc -l of 64 MiB: 15 ms; sha256sum: 60 ms), decades of edge cases, an agent already knows the flagsText output with no schema; errors are prose on stderr with no tag; some behaviour depends on locale (sort, wc -w) — not demonstrable here: only C, C.utf8 and POSIX are installedEvery tool below that is a thin filter over bytes gains little more than an envelope. See the per-tool verdicts in §5
ripgrep--json already emits a begin/match/end/summary NDJSON stream, with byte offsets, {"text":…} or {"bytes":"<base64>"} for a line holding 0xFF (measured: the precedent for D2’s b64), and is the fastest thing here (64 MiB, 1.3 M lines: 80–110 ms through a pipe)Its JSON mode does not make errors data: rg --json hello /nonexistent prints a prose line on stderr, still prints a summary object on stdout, exits 2; the end and summary records carry elapsed, so output is not byte-stable (measured: three runs of one rg --json on a 28-byte file gave three different checksums)A cancho search tool is slower and has no regex. The gain is the error datum, stable bytes, and an exact authority row — nothing about search
jqA full language; fast to start (2.5 ms); strict parserCan read a file anywhere and, with input_filename/$ENV, the environment; its authority is the process’sA path-only JSON reader is a strict subset of jq. Anything past a path is jq’s job, and the toolbox must refuse to grow past it (D15)
fd / findfd is fast, .gitignore-aware, and sensible by default (docs; not installed here). find can do anythingfind -delete and -exec are the verbs a supervisor most wants to see absent from a row, and the row of find is the process’sCannot be built today at all (A.2)
nushellStructured pipelines: tables instead of text, ls/open/where as data (docs; not installed here)It is a shell: its authority is the user’s, and its commands are not individually checkableIt is the strongest existing answer to “structured output”. It does not give a per-command authority, an error tag or a repair — that gap, not the tables, is what the toolbox fills
curl -w '%{json}'Prints one JSON object of transfer facts (measured, 8.5.0)TLS, redirects, proxies: authority is “the network”See fetch in §5: not buildable narrowly while TLS needs Ffi

2. What exists today

All of it read from the source named, then probed with the prebuilt compiler where a claim needed a number. The compiler used is cancho 0.0.0 (rev 052e623…); git diff 052e623 HEAD -- crates std touches only three test files, so std/ and the compiler are those of the commit this document is written against.

2.1 Process, standard streams, exit status

FactSourceMeasured here
main is fn main(world: World) -> [] int and its return value is the exit status; there is no exitdocs/arguments.md §5, docs/authority.md §1.1A status is taken modulo 256: a program returning r * 100 for argc 1, 2, 3 exits 100, 200, 44
Arguments come from the Args capability; arg(a, 0) is the program name, bytes, no encodingdocs/arguments.md §3—
Flags: a cursor (flags.step/flags.value) that reports shapes and never meanings; the program decides what a value isstd/flags.cho, docs/flags.md §3—
Standard input is getchar, one byte per call; there is no bulk readdocs/standard-input.md, docs/bulk-io.md §3.3A loop that reads and counts 64 MiB (examples/tally.cho) takes 0.35 to 0.38 s, about 180 MB/s; cat takes 14 ms
Standard output is write_bytes, which is fwrite on the C stdout stream; standard error is write_err on the unbuffered streamcrates/cancho-codegen-llvm/src/body/expr.rs (write_bytes/write_err), docs/standard-error.md §3.3, §5See below
A trap (overflow, bounds, arena exhaustion, a failed allocation, an Fs prefix violation, trap()) is SIGILL, no messagedocs/defined-behaviour.md, docs/testing.md §2Exit status 132 as a shell sees it; a program that wrote {"partial": and then trapped emitted 0 bytes through a pipe: the stdio buffer is not flushed
There is no environment, no clock and no tty query unless a capability or Ffi is helddocs/arguments.md §2.1, docs/reach.md §3.1, Builtin::ALL in crates/cancho-ir/src/builtin.rs (no getenv, no isatty; clock_ms/clock_unix_ms take a Clock)—

The finding in the output row: a failed write is invisible

docs/bulk-io.md §3.3 said “Output has no such question: it wrote the bytes or the process is gone.” That is false for a stream that fails, and it is corrected in place there. Reproducer (A.7):

$ ./wr                    # io.write_all(i, "hello\n"); returns the count
hello
$ ./wr >&- ; echo $?      # stdout closed
6
$ ./wr > /dev/full ; echo $?   # strace: write(1, "hello\n", 6) = -1 ENOSPC
6

write_all answers the length it was asked to write, the flush at exit fails, and the process exits with its own status. A tool that reports ok: true after this has told an agent a file was written when the disk was full.

The count is the buffered count, and that is the whole of what a program can use. Once the stdio buffer fills, a failing flush does show up, as a short count: a second probe that wrote 1,000 sixty-five-byte records saw 15 short writes to /dev/full and 7 to a closed stdout, and none to /dev/null. So a tool that checks every write_all result against the length it passed can detect most failures, but never the last buffer’s worth, which fails at exit where nothing can look. This is a language gap, and it decides D2’s end record: the last bytes are exactly the ones whose loss is invisible, so a stream that does not finish with a record saying complete: true must be treated by the reader as truncated, and a tool must check each write_all result and stop with a tag (io.write-failed) when one is short.

Two more facts the contract has to live with, both measured: a reader that closes the pipe early kills the tool with SIGPIPE (a flood | head -1 pipeline reports status 141 for the tool, and no JSON), and stdout is buffered while stderr is not, so the two streams do not interleave (docs/standard-error.md §5).

2.2 Files, Fs, and paths

  • Handles and errno. open_read/file_read answer Opened::Failed(errno) and Read::Failed(errno); Read::Got(n)/End are distinct (docs/file-handles.md §3). Measured, the errno survives to the program: a missing file is 2 at open, a path through a regular file is 20 at open, and a directory opens successfully and fails at the first read with 21 (A.5). So io.not-found, io.not-a-directory and io.is-a-directory are all distinguishable today; similar names are not (no listing).
  • Writes exist. open_append/open_write/open_new (O_EXCL)/open_rw, file_write/file_pwrite/file_pread/file_sync/file_truncate/ file_size, fs_rename, fs_remove, file_lock (flock, released when the holder dies) — edition 5 (docs/file-writes.md §4, §7; the names are in Builtin::ALL). A probe built an atomic replace (write a temp file with open_new, file_sync, fs_rename over the destination) and its authority is exactly file_write + fs_write("") (A.8). Edition 5 widens Split to seven fields (net, clock), so every tool releases two more capabilities — each a statement of what it will never do (docs/authority.md §1).
  • Fs(prefix) is checked twice. At compile time by narrow, at run time on every path; a violation traps (docs/filesystem.md §4). Measured with Fs narrowed to a directory: a path outside it, a sibling that shares the byte prefix (/tmp/jailprobeevil), a path containing .., and a relative path all end in status 132 — not an error value, and with nothing printed (A.4). Under the unnarrowed Fs("") a relative path is fine (the OS resolves it against the working directory), but .. still traps there too: ../x and /tmp/a/../b both exit 132 (A.4). So every tool, in the default build as well as in a variant, must validate every path before the builtin sees it, or the contract’s error is unreachable and an agent that types ../src gets a dead process.
  • Symlinks escape. docs/filesystem.md §4.1 says path normalisation “needs symlinks decided too” and leaves it open. Measured: a symlink inside an Fs-narrowed directory pointing outside it is followed, and the outside file’s 20 bytes are read (A.4). There is no lstat, readlink, realpath or no-follow open to build a check from, and the one route that has realpath (Ffi("libc")) makes the report bounded: false.
  • narrow takes a literal, and nothing is generic over a prefix. narrow(fs, arg(g, 1)) is capability-not-narrowable; a function written fn f[&r, p](fs: &r Fs(p), …) -> [fs_read(p)] is type-mismatch (expected a string literal, found an identifier) (A.1, A.9). So library code over files is written once per literal, and a build that wants Fs("/srv/work") is a source substitution, as docs/agent-tools.md §3.1 already said and crates/cancho/tests/conformance/backends.rs’s bind test already does for a port.
  • No listing, no stat. fs_list and fs_stat are not-a-function (A.2). What a program can learn about a path: it opens (2/13/20), it is a directory (21 at read), its size (file_size on an open handle). Not its type without opening, its mode, or its modification time.

2.3 Memory

  • An arena (region) is one 64 KiB chunk and exhaustion traps (AGENTS.md §6, docs/heap.md). The heap (box_slice, std.buffer) grows by doubling; a failed allocation traps, and nothing inside a program can ask the OS for a limit.
  • The consequence is concrete in std.crypto. sha256 copies its message, padded, into a region (std/crypto.cho), so any message longer than 65,527 bytes traps; sha512 any longer than 65,519. Both measured by bisection (A.6) and checked equal to sha256sum on the last good size. docs/crypto.md states no limit and there is no incremental interface.
  • seek reads a whole file into a doubling heap buffer, so its memory tracks the file, not the longest line. Peak resident memory (ru_maxrss of the child, three to eight runs each, spread under 0.1%) was 8,304 KB for a 1 MiB file, 13,776 KB for 8 MiB, 99,788 KB for exactly 64 MiB (1.5 times the input) and 198,032 KB for a file 28 bytes longer (3.0 times), and 787,816 KB for a 256 MiB-plus file (2.9 times). The step is the doubling: just past a power of two the buffer is copied into one twice the size while the old one is still live. That is the ceiling of “no silent truncation” as docs/agent-tools.md built it, and D8 asks for better.* std.json is a tape: 24 bytes of tape per byte of source in the worst case (json.tape_len is 3 * (len + 1) ints, std/json.cho). A 4.7 MB document parsed with a heap tape peaked at 119,184 KB (26 times), in 0.095 s including a byte-at-a-time stdin read. Fast, and the memory is the document times 25.

2.4 What the compiler can print about a program

  • cancho authority <files> --std --output json — the union of every reachable function’s performed effects, computed from reachability (docs/authority.md §2), bounded first, every label with its argument, the foreign symbols, and the list of provably pure functions. Measured on examples/seek/seek.cho: seven labels (args, err_write, file_read, fs_read(""), heap, io_read, io_write), bounded: true, in 18 ms. It is exact in both directions: a probe that declared fs_read("") it did not perform was refused (“a row is exact or it is decoration”), which is why a derived manifest is a claim worth gating.
  • It reports the program, not an invocation. A two-applet program (cat and rm chosen by argv[1]) reports fs_read("") and fs_write("") for both (A.10) — which is why D17 is one binary per tool.
  • cancho check --output json answers refused[] of {rule, message, explanation, position}; there is no repair field (docs/agent-errors.md §5.1 designed fix and cut it for lack of a consumer whose behaviour had been watched). cancho introspect and skill are ACLI-generated from crates/cancho/src/acli.rs (docs/agent-cli.md).
  • cancho test runs fn test_* (heap and io parameters only) one process each; a trap fails a test (docs/testing.md §3). There are no examples {} blocks — that is lex-lang’s mechanism, not this language’s.
  • The ACLI SDK, read from the acli-0.5.0 crate that lex-lang and cancho both use (src/output.rs, src/exit_codes.rs, src/skill.rs; the ACLI specification document was not available and is not quoted): the envelope is {ok, command, data | error{code, message, hint?, hints?, docs?}, dry_run?, planned_actions?, meta{duration_ms, version, cache?}}; the exit codes are 0 success, 1 general, 2 invalid args, 3 not found, 4 permission denied, 5 conflict, 6 timeout, 7 upstream, 8 precondition failed, 9 dry run; progress is NDJSON {type:"progress"…}; hint is a string. meta.duration_ms is a required field of the Rust type and differs on every run.

2.5 What lex-os does with an authority (read, then measured)

Read from /home/user/lex-os at the working copy’s head.

  • lex-os-authority derives from Lex source, not cancho: derive(src) runs the real Lex front end (lex_os_check::effects_of_source) and folds the effect names with lex_types::trust::effect_requirement (/home/user/lex-lang/crates/lex-types/src/trust.rs:555): fs_read, fs_walk → filesystem read-only; fs_write → read-write; net, http, mcp, llm_cloud → network allowlist; proc → exec sandboxed; everything else maps to nothing and is reported as off_lattice (“no grant refuses these”). Its seam for another front end is the public derive_from_effects(&EffectSet).
  • gate checks the three levels and the egress hosts; it does not check fs_read/fs_write path scopes. narrow_manifest meets the grant and filters egress; a manifest has no path field at all (crates/lex-os-manifest/src/lib.rs: goal, grant, budget, isolation floor, egress, actuation, facets, comment), and the perimeter turns the filesystem level into two booleans (SandboxPolicy::from_grant: fs_readable, fs_writable, crates/lex-os-perimeter). The designed home for a path extent is a facet (facet.rs: type-erased, narrowed by a validator the consumer registers, byte-identical if unregistered); none exists for paths.
  • Commands are a hand-written registry: lex_os_supervisor::Command carries {name, dimension, required_level, reversibility, money_cents, api_calls} and the Lex package manifests/src/commands.lex writes cmd_list_dir, cmd_exists, cmd_read, cmd_write_report, cmd_fetch, cmd_run, with the trust requirement in a comment. Exec is one command name, proc.exec (crates/lex-os/src/exec.rs: “there’s no per-binary vocabulary to gate on”). lex-os-capsule binds an artifact’s content hash to a grant and egress, signed, and installing it narrows the consumer’s manifest.

Measured, with a scratch crate that depends on those crates by path (Appendix B; nothing in /home/user/lex-os was modified):

Input to derive_from_effectsDerived grantNotes
net_out("api.example.test:80") — the cancho label, verbatimfs none, net none, exec noneoff_lattice: ["net_out"]. A program that dials a host is derived as needing no network
net("api.example.test") — after mapping the namenet allowlistegress: ["api.example.test"], unscoped_net: false
fs_read("") + fs_write("")fs read-writefs_read: [""], fs_write: [""]: the “root” is a string entry, not “everything”
fs_read("/work")fs read-onlyfs_read: ["/work"] — informational; the gate ignores it
ffi("libc") — verbatimfs none, net none, exec noneoff_lattice: ["ffi"]. The one label that means “any authority at all” is derived as none
file_read, io_write, heap, argsnoneall off_lattice: reviewed by eye, never refused
diff of fs_read("") → fs_read("/work")verdict wideningadded: ["/work"], removed: [""]. A narrowing of a path prefix is classified as a widening, because path scopes are compared as opaque strings; /work → /work/sub is the same

And one comparison of derived against hand-written: the body of cmd_write_report as commands.lex declares it ([io]) derives to fs none, net none, exec none, off_lattice ["io"], while the comment above it says “Trust: filesystem ≥ read-write”. I did not check whether std.io.write can be declared with a finer effect; the point is only that the written claim and the derived one differ in the one place I tried.

2.6 The gaps, in one table

Each has a minimal reproducer in Appendix A except L9, L11 and L12, which are stated by the documents cited and were not reproduced separately. “Needs” says where the fix lives; D16 decides what is fixed and what is routed round.

#GapReproducerBlocksNeeds
L1A failed write to stdout is unobservable; write_all returns the requested length; a trap loses buffered outputA.7every tool’s okcompiler (write_bytes result, or a checked flush). Closed by checked-output.md (#215): flush_out reports a failed or earlier-failed write; a trap still loses the buffer
L2No directory listing (fs_list)A.2list (ls, find, tree)compiler: builtin + edition. Built (#222 slice 1): dir_list/dir_next on a Dir rather than a path, and std.dirs.list sorted bytewise (directory-listing.md)
L3No file type, mode or mtime without opening (fs_stat)A.2list entries, statcompiler: builtin. Built (#222 slice 2): dir_stat on a Dir answers kind, size and mtime and never follows a link (directory-listing.md)
L4std.crypto.sha256/sha512 trap past 65,527/65,519 bytes; no incremental APIA.6hash, write preconditionsstd (or in-package first, AGENTS.md §7)
L5narrow takes a literal; no generic over Fs(p)/Net(b)A.1, A.9static extent for a general toolby design (linearity-and-effects.md §7.4); D14 routes round it
L6No symlink-aware open or realpath under Fs; symlinks escape a narrowed prefixA.4symlink-safe --rootcompiler: a no-follow open, if wanted. Built (#227 slices 1 and 2): directory-handles.md’s Dir reads, creates, appends, renames, removes and syncs beneath a directory and follows no link; the tools on top are its slice 3, built in alpibrusl/cancho-tools#4
L7Outside-prefix, .., relative and sibling paths trap (132) rather than answering an errorA.4an error value for confinementin-tool validation (D9); by design
L8No std.regexA.3regex seekstd — large; D15 declines it
L9TLS needs Ffi (conn_raw_fd, examples/tls_client)docs/native-sockets.md §6an https fetch with a bounded rowout of scope (D15)
L10Invalid UTF-8 written through std.json becomes U+FFFD silently; there is no base64 in std (examples/base64 only)A.13lossless bytes in JSONcontract package (D2), a std.base64 later
L11Stdin is one byte per call (~180 MB/s)examples/tally.cho, §2.1throughput onlynone now
L12A status is modulo 256; no exit§2.1nonenone: the table fits
L13JSON tape is 24 B/byte of source§2.3large-document querynone now; D8’s --max-bytes

3. The tool contract

Eleven decisions, D1 to D11. A decision that changes the behaviour of something that exists (the one program, examples/seek/, or the compiler) is marked (to confirm) and says what changes.

D1. Scope, and one admission rule

Decision. The toolbox is at most ten tools, and a tool is admitted only if all four hold:

  1. a stated gain over the incumbent, written in §5 before the code;
  2. a versioned schema and a rule catalogue with a fixture per tag (D5);
  3. an authority row that the compiler derives and a ceiling a person wrote (D12);
  4. every error path is a tag, and no input reaches a trap (D8, D9; §7.1 M4).

Alternatives. (a) A coreutils subset of 30: rejected — it is the POSIX fidelity trap docs/flags.md measured, multiplied. (b) One tool: rejected — the contract’s value is in being shared, and one tool cannot show that the envelope, the exit codes and the hint rule generalise. (c) No toolbox, only a shim around the incumbents: not rejected, it is arm B of the experiment (§7.2) and the thing this has to beat.

Why ten. Not a number with a source. It is the size at which each tool can still be reviewed for authority by reading its row, and at which a maintainer can keep the language-gap list honest.

D2. The wire format: one document, or a stream that says it finished

Decision.

  • JSON is the default. --format json (document tools) or --format ndjson (stream tools); --format text for a person. Text is rendered from the same data model by a separate function per format, is lossy by design, and is never something a script parses.
  • A document tool writes exactly one JSON object and one newline.
  • A stream tool writes one JSON object per line, in a deterministic order, ending with an end record {"type":"end","complete":true, "…counts"}. Every error is also a record, and on any error path the end record still comes, with complete:false.
  • A stream with no end record is truncated and must be treated so. Section 2.1 is why: a failed flush, a SIGPIPE, a trap or a kill all leave a prefix and an exit status the reader may not see; only the end record is evidence that nothing was lost.
  • Bytes that are not UTF-8 are never written as a string. The Writer replaces them with U+FFFD silently (docs/json.md §3, L10). A field that may hold arbitrary bytes (a matched line, a file name) is emitted as "text" when std.utf8.is_valid holds and as {"b64":"…"} when it does not, in the schema as text_or_bytes. The base64 encoder is written in the contract package first (examples/base64/base64.cho is the starting point) and moves to std when a second program wants it.
  • Integers only. int is 64-bit; sizes and offsets above 2^53 lose precision in a reader that decodes to a double. The schema says integer and introspect records the limit; no float appears in any tool’s output.

Alternatives. (a) Text default, --json opt-in (the GNU habit): rejected, it makes the safe path the longer one for the reader this exists for. (b) A single JSON array for streams: rejected, a reader cannot use the first result until the last arrives and a trap loses all of it. (c) rg’s begin/end-per-file framing: a file record is kept (type:"file"), but the end record is the toolbox’s addition and is the reason for this decision.

Behaviour change, to confirm. examples/seek/ prints path:line:text by default today; the toolbox’s seek would print NDJSON by default. --format text keeps the human form.

D3. The envelope

Decision. ACLI-compatible, with the nondeterminism removed:

{"ok":true,"command":"seek","schema":"seek.v1","data":{ … },"meta":{"version":"0.1.0"}}
{"ok":false,"command":"write","schema":"write.v1","error":{"code":"CONFLICT","rule":"precondition.hash-mismatch","message":"…","hint":"…","repair":null,"detail":{ … }},"meta":{"version":"0.1.0"}}
  • ok, command, data, error{code,message,hint}, dry_run, planned_actions, meta.version are ACLI’s, so a consumer of lex-os’s or lex-lang’s envelopes reads these unchanged.
  • Added: schema (which schema validates this document), error.rule (D5), error.repair (D6, structured; hint stays the one-sentence string ACLI defines), error.detail (rule-specific data, e.g. the actual hash).
  • Removed: meta.duration_ms and meta.cache. A caller who wants the duration times the process from outside. Time is not available to a tool without a Clock (D7), and a tool that took one to fill duration_ms would widen its row (clock) for a field no agent needs.
  • A stream is not wrapped. Its records are the lines, and its end record (D2) carries ok, command, schema and complete — it is the stream’s envelope.

Why. rg --json is the proof that an envelope with elapsed in it is not byte-stable (measured: its end and summary records differ run to run). lex-os and lex-lang already speak ACLI envelopes, so inventing a fourth format would cost interoperability for nothing.

Alternatives. (a) ACLI verbatim including duration_ms: rejected, it breaks M2 (byte-identical reruns). (b) Our own envelope: rejected for the interoperability reason. (c) duration_ms always present and excluded by the tests: rejected, “the field exists but is ignored” is how a contract acquires parts nobody reads.

To confirm. This deviates from the SDK’s Envelope struct, whose meta.duration_ms is not optional. The ACLI specification text was not available to read, only the crate; whether the spec makes meta mandatory is a question for its owner.

D4. Exit codes

Decision. The ACLI vocabulary (acli-0.5.0 ExitCode), with each code’s toolbox meaning fixed, and one absence stated:

CodeACLI nameToolbox meaningRule tags
0SUCCESSWhat was asked was done. Zero matches is success: data.count is 0—
1GENERAL_ERRORA failure that is neither the caller’s nor a state mismatch: the OS failed mid-operation (EIO, ENOSPC, a short write_all), or a bug the tool caught in itselfio.read-failed, io.write-failed, internal.*
2INVALID_ARGSThe invocation is malformed, or names something of the wrong kind; nothing was readargs.*, path.dotdot, precondition.required, query.unsupported-syntax, io.is-a-directory
3NOT_FOUNDA named input does not exist (ENOENT, ENOTDIR)io.not-found, io.not-a-directory
4PERMISSION_DENIEDThe OS refused, or the tool’s own confinement didio.permission-denied, path.outside-root
5CONFLICTState is not what the caller said it would beprecondition.hash-mismatch, conflict.exists, conflict.locked
6TIMEOUTNot used. A tool has no clock (D7); a supervisor kills and the shell reports 128+n—
7UPSTREAM_ERRORA remote peer failed (fetch only, if it ever ships)net.*
8PRECONDITION_FAILEDThe answer is “no”, or a limit was reached: --require-match found none, limit.*, parse.*
9DRY_RUNA --dry-run completed; planned_actions lists what an apply would do—
132(SIGILL)The tool trapped. A bug; no JSON. Gated to be impossible (M4)—
141(SIGPIPE)The reader closed the pipe. Not an error the tool reports—

Statuses are 0 to 255 (§2.1: 300 became 44). With several errors in one invocation the first, in input order, decides the exit code; the envelope’s error is that first one (so an ACLI reader sees what it expects) and errors lists all of them in order (D5).

Alternatives. (a) grep’s 0 match, 1 none, 2 error, which seek uses today: rejected for agents, because “no match” is the answer, and a harness that treats non-zero as failure then retries a question that already had an answer. --require-match gives the grep behaviour on request (exit 8). (b) Only 0/1/2: rejected, it throws away exactly the distinction (conflict versus not-found versus refused) that lets a harness pick a strategy without parsing.

Behaviour change, to confirm. seek’s status for no match goes from 1 to 0, and its “cannot read” from 2 to 3/4. crates/cancho/tests/conformance/ agent_tools.rs asserts the old values and changes in the same slice.

D5. Errors are data, with a stable rule tag

Decision. An error is an object {code, rule, message, hint, repair, detail}. The rule is <area>.<name>, kebab-case, from a catalogue each tool declares and the contract package shares (args.unknown-flag, args.missing-value, path.dotdot, path.outside-root, io.not-found, io.is-a-directory, io.permission-denied, io.read-failed, io.write-failed, limit.line-too-long, limit.input-too-large, precondition.required, precondition.hash-mismatch, conflict.exists, conflict.locked, parse.json, query.unsupported-syntax, internal.invariant). Four rules adopted whole from docs/agent-errors.md §3.1 and CONTRIBUTING.md:

  • A tag names the rule a reader would look up, not the sentence. The prose may change; the tag never does. A rule that splits gets siblings and never repurposes the parent.
  • Every tag has a fixture that reaches it, and a test that the set of fixtures equals the catalogue (the toolbox’s every_tool_rule_has_a_fixture, modelled on cancho’s own).
  • Independent errors are all reported, dependent ones are not invented (docs/agent-errors.md §4). Five paths named, two unreadable: the output has the three results and an errors array with two entries (error repeats the first), and ok is false iff errors is non-empty. An agent should not pay a turn per failure.
  • No input reaches a trap. A catalogue that has a tag for every error and a binary that still dies with 132 on some input is a catalogue with a hole; M4 is the gate.

Each catalogue row also declares repairable: always | sometimes | never, which D6 and M3 consume.

Where errors go. On stdout, inside the envelope, in JSON mode; the reader then has one stream whose order is known. (ACLI’s emit also prints errors to stdout.) Stderr is empty in JSON mode except for internal.* and for the last-resort line when stdout itself cannot be written, which the tool cannot know (§2.1). In --format text mode the sentence goes to stderr as a person expects.

Alternatives. (a) Errors on stderr, results on stdout (the Unix habit): rejected for the reader this is for — a harness that truncates or reorders two streams loses the correlation; and rg --json shows the failure mode: the error is a stderr sentence and a stdout summary that says nothing went wrong. (b) Flat tags without an area: rejected, the area is what lets a catalogue grow past one tool without collisions and lets lex-os map whole areas (net.*) to a dimension.

To confirm. Stdout versus stderr for errors.

D6. The repair hint, and when it is allowed to exist

Decision. error.repair is null or an object of a closed set of kinds, each a datum a script applies without judgement:

{"kind":"retry","argv":["seek","--root","/work","needle","src/a.cho"]}
{"kind":"choose","options":[{"argv":[…]},{"argv":[…]}]}
{"kind":"none","reason":"the file changed since it was read; re-read, then decide"}

with three rules:

  1. A hint never widens authority. It never adds --root outside the one given, a write flag, a --create, a --force, or removes --dry-run. This is lex-os’s own invariant (the agent never sets its own limits; /home/user/lex-os/CLAUDE.md, “the invariant that must never break”) applied to the hint, and it is a property test (M3), not a promise.
  2. A hint exists only when it is unambiguous; otherwise repair is null or kind:"none" with a reason. A conflict is the example that matters: for precondition.hash-mismatch the tool returns the actual hash in detail and repair.kind:"none", because applying “retry with the new hash” by script would silently overwrite a concurrent writer. The honest repair for a conflict is information, not a command.
  3. A hint raising a limit is capped. limit.line-too-long may say retry with --max-line-bytes N where N is the line it saw, never past the compiled-in ceiling, because the heap traps on exhaustion (§2.3).

What can be repaired today is smaller than it sounds. With no listing (L2) io.not-found cannot suggest a similar path; args.unknown-flag can (an edit-distance match against the tool’s own flag table); path.dotdot can (the lexical normal form, if it stays inside the root); path.outside-root can only when the path lies under the root in another spelling; args.missing-value cannot (the tool does not know the value).

This is the consumer docs/agent-errors.md §5.1 said was missing. That document cut a compiler fix field because “the argument for it is about a consumer nobody has watched” and named what would settle it: “an agent that has rule and still repairs the wrong thing.” The toolbox is where that is watched. So repair ships under a kill rule (§7.2): if the ablation arm (hint removed, tag kept) does as well as the arm with it, the field is cut, as fix was.

Alternatives. (a) ACLI’s hint string only: kept as the sentence, but a string is what an agent has to parse, which is the problem. (b) lex-lang’s suggested_transform (an op id plus a transform for lex repair --apply): does not transfer, for docs/agent-errors.md §2’s reason — it is a machine-checked edit against a content-addressed store, and a tool invocation has no store. (c) No hint: the ablation arm.

To confirm. Whether repair ships at all, and the kill rule’s threshold (the protocol fixes the comparison, not a number invented before a pilot).

D7. Determinism is a property of what the tool was not given

Decision. The contract states, and the tests check (M2):

  • No locale. Ordering is bytewise (bytes.compare); case folding is ASCII only and named so. (std.utf8 exists; a tool that wants Unicode case says so in its schema.)
  • No colour, no tty sniffing, no environment. Not a rule to obey: the language has no isatty, no getenv and no colour library without Ffi, so a tool cannot. introspect records reads_environment:false because the row proves it (no ffi).
  • No time, no randomness. No Clock is taken, so no timestamp or duration can appear and no output depends on when it ran. A tool that needs clock_unix_ms (none planned) says so in its row and its schema.
  • Stable ordering. Inputs in argument order; entries of a directory sorted bytewise by name; JSON keys in the schema’s order (the Writer emits in call order, which is the source order, so the order is fixed by the code and pinned by the golden corpus).
  • Same bytes whatever the stream. stdout is a pipe, a file or a tty to the program identically; output never changes with the sink.
  • No meta.duration_ms (D3).

Alternatives. (a) Honour NO_COLOR/LANG like GNU: not available without Ffi, and nondeterminism the reader did not ask for. (b) Sort nothing, “let the filesystem decide”: rejected, directory order is what makes find output differ between machines.

D8. Large inputs and outputs: what a streaming tool must do

The memory model is §2.3: a 64 KiB arena that traps, a heap that traps, no way to ask for a limit. A tool must therefore bound itself.

Decision.

  1. A streaming tool’s memory is O(chunk + longest line + bounded state), never O(input). The pattern is already in the repository (examples/seek/seek.cho read_file): a 64 KiB heap buffer, filled by file_read through buffer.room/buffer.filled, consumed, cleared with buffer.clear — without the buffer.append that makes seek hold the file. A line that spans chunks is carried in a second buffer that grows to the longest line and is capped (point 3). docs/line-reading.md is why a cap must be a tag, not a silent clip.
  2. Never size a region from data. An arena is for fixed, small scratch (L4 is what happens otherwise).
  3. Every tool has limits with a compiled-in default and a hard ceiling: --max-bytes (input), --max-line-bytes, --limit (output records) and --max-keys where state grows. Exceeding one is a tagged error (limit.*, exit 8) with detail naming what was seen — never a trap, never a silent truncation. A tool that stops early because of --limit says truncated:true and a next cursor ({"offset":N}) that a following call resumes from (truncation by --limit is a result, not an error, so it has no rule tag).
  4. Memory must not scale with input: the gate (M9) runs the same tool at 1 MiB, 64 MiB and 256 MiB and requires peak resident memory within a factor of 1.5 across the three. seek as built fails it by a wide margin (787,816 KB at a 256 MiB-plus file against 8,304 KB at 1 MiB, about 95 times).
  5. Output is flushed per record (write_bytes per NDJSON line), so a long stream does not live in one buffer; the end record (D2) is what makes a lost tail detectable (L1).
  6. Heap exhaustion cannot be caught inside a program. The tool’s caps are the polite layer; the supervisor’s memory budget (lex-os’s Budget) is the wall. The contract says so rather than implying the caps make a tool unkillable.
  7. No hang protection. A read from a FIFO or an idle stdin blocks; there is no timeout primitive on file_read and no Clock taken. Exit 6 is unused and the supervisor owns the wall clock. Not probed here; read from the absence of a timeout in docs/file-handles.md §3 and Builtin::ALL.

Alternatives. (a) Read whole files, as sort, seek, sha256 do: fine for a tool whose job is to hold the data (sort), wrong for a search or a range read, and a trap rather than an error at the limit. (b) mmap: not in the language (docs/file-writes.md §8). (c) A larger arena: not a language decision this document can make.

Decision.

  • A tool that touches files takes --root DIR and treats every path operand as relative to the root. With no --root, paths are relative to the working directory and the tool says so in introspect (confinement:"none").
  • Validation happens in the tool, before any builtin sees the path (the builtin traps — §2.2 — and a trap carries no tag): reject a path with a .. component (path.dotdot, exit 2), an absolute path that is not under the root (path.outside-root, exit 4), a sibling that shares the prefix (/rootevil is not inside /root; docs/filesystem.md §1.1 is the same rule); collapse ., repeated /, and a trailing /. A path inside the root in a different spelling gets a repair (D6).
  • Symlinks are not followed below the root. (Rewritten when M8 flipped.) This said there was no no-follow open (L6) and that the tests asserted the known escape. #227 built directory handles (directory-handles.md), and cancho-tools (alpibrusl/cancho-tools#4) now opens --root with open_dir and every path beneath it one component at a time with O_NOFOLLOW: a link anywhere below the root is path.symlink (exit 4), and introspect records confinement:"beneath". The root’s own spelling is the caller’s and may hold links. The writers work beneath the parent directory and hold dir_write, no fs_write.
  • Where symlink safety matters, it is the perimeter’s job (a mount namespace, a lex-os box), exactly as docs/filesystem.md §2.1 says.

Alternatives. (a) realpath through Ffi("libc"): rejected, the report becomes bounded: false and the whole premise goes. (b) Refuse any path whose parent contains a symlink: needs lstat. (c) Normalise .. instead of refusing it: docs/filesystem.md §4.1 refused for the same reasons (a security function with a history of being got wrong, and symlinks make lexical .. unsound — a/link/../b is not a/b). (d) Absolutise relative paths: there is no getcwd.

D10. Mutating tools: dry-run, no blind writes, idempotence

Decision.

  • Every mutating tool takes --dry-run and exits 9 with planned_actions: [{"op":"replace","path":…,"before_sha256":…, "after_sha256":…,"bytes":N}] and performs no mutating syscall.
  • No blind overwrite. A write must say what it expects: --create (the path must not exist; open_new’s O_EXCL is the primitive) or --if-sha256 HEX (the current content must hash to this). Neither is precondition.required, exit 2. This, not a default dry-run, is the safety — it makes the agent state its belief about the file, and the tool checks it.
  • Idempotent. The same arguments applied twice produce the same final state and the second result is changed:false, exit 0 (--content-sha256 makes “already applied” decidable). M7 runs every mutating case twice.
  • Atomic. Write a sibling temp file with open_new (a deterministic name derived from the target and the content hash, so two runs collide rather than litter), file_sync, then fs_rename over the destination (probed, A.8). A temp that already exists is conflict.locked.
  • A lock held across check and replace (file_lock on a sidecar <path>.cancho-lock), because a bare check-then-rename has a window in which two writers both pass. The lock is advisory (docs/file-writes.md §7): it defends against two toolbox processes, not against a writer that does not lock. Gate M7 includes a two-process race.
  • Reversibility is declared per tool for lex-os: read-only tools reversible-cheap, write/replace irreversible-bounded (“write a file”, the example in lex-os-manifest), and no tool is irreversible-consequential (D15: no delete; move --remove is a rename to a tombstone).

The honest limit, and the alternative it raises. --dry-run is not visible in the row: a write run with --dry-run and one without both report fs_write(""), because the row is the program’s, not the invocation’s (§2.4). “A dry run does not write” is therefore a property of tests (M7, with strace), not of types. The stronger form is a split: a write-plan binary with no fs_write label at all — provably unable to write — that emits a content-addressed plan, and an apply binary that consumes it and re-checks every precondition. Plan runs under a read-only grant; apply only on approval. It costs a second binary per mutating tool and a plan format. Not chosen for S1 (the brief asks for --dry-run, and the flag is what GNU/ACLI users expect); it is open question Q6, and the measured limit above is the reason it is open.

Alternatives. (a) Default to dry-run and require --apply: rejected, two calls for every write and the agent will pass --apply by reflex; a precondition cannot be passed by reflex because the agent must have read the file. (b) Overwrite by default: the GNU habit; the thing this exists to refuse.

Behaviour change, to confirm. None to existing code (no mutating tool exists); the “no blind overwrite” default is a policy a person should confirm, because it will annoy an agent that has not read first.

D11. introspect and skill, from one table

Decision. Each tool answers tool introspect [--output json] and tool skill, both generated from one static table per tool that also drives the parser: a FlagSpec array (name, short, kind, default, role) walked with std.flags’s cursor in the contract package. There is no hand-written second list to drift. This is the lesson of docs/agent-cli.md §2, where a generated surface taught agents --o for -o, and of docs/flags.md §1, where a program silently ignored --decode.

introspect reports:

  • the tool, its version, the compiler revision it was built with (cancho --version at build), the schema ids and the schemas themselves;
  • every flag and operand with its type, default, and role (path-read, path-write, host, none) — D13 uses it;
  • the exit-code table of D4 with which codes this tool can emit;
  • the rule catalogue (tag, exit code, repairable);
  • confinement (none/lexical), reads_environment, output (document/stream), the limits with defaults and ceilings;
  • the authority (D12) and the reversibility (D10);
  • evidence: which gates of §7 have run for this release, and which have not (so a tool never implies a measured claim it lacks — §9).

skill is the agentskills.io SKILL.md, generated from the same data. It is not produced by the ACLI SDK’s generator: that one hard-codes an exit-code table that does not describe a tool (docs/agent-cli.md §3).

Alternatives. (a) The SDK’s register/skill path: Rust-only, and its exit-code section is wrong for us. (b) A hand-written SKILL.md: the drift the previous document found, once, by luck.


4. The authority manifest

D12. Exported by the compiler, embedded by the build, gated against a ceiling

Decision. A tool’s authority is never written by a person. The build:

  1. runs cancho authority <sources> --std --output json (pass 1);
  2. writes generated/manifest.cho, a file holding the manifest and the schemas as string literals;
  3. rebuilds with it (pass 2); tool introspect prints it;
  4. checks the fixed point: pass-2 authority equals pass-1 authority.

The fixed point is not an assumption. A probe embedded the pass-1 JSON of a small program as a string literal and rebuilt: the two authority reports were identical (the whole JSON, functions count included), and the binary printed the literal back byte for byte (A.11). A string constant adds no label, so the manifest can describe the program that contains it.

The CI gate (M6) has three parts:

  • authority.json committed for each tool equals a fresh compiler output (no drift);
  • the embedded copy a built binary prints equals it (no stale build);
  • the derived label set is a subset of the tool’s ceiling, a short file a person writes and reviews (tools.toml: allow = ["args","heap", "io_write","file_read","fs_read"]). What a person writes is a ceiling, never the authority: a widening is then an edit to the ceiling, a reviewable diff — the same trade lex-os authority diff draws, enforced where the source lives. Because rows are exact in both directions, “the derived set equals the ceiling” is not required, only “within”.

What a tool needs that cannot be narrowed statically (and the manifest says so in a not_narrowable list, so a reader is told rather than left to infer):

AuthorityWhy it is not in the rowWhere it is bounded instead
Path extentnarrow takes a literal (L5); a path from argv is run time. The row says fs_read("")D9’s lexical --root; D14’s variant build; the perimeter; D13’s mediator
Host (fetch)the same, for Netper-host variant (D14); lex-os egress
What a symlink reaches(Closed under --root.) Dir opens beneath a directory following no link (L6, #227)D9: --root opened beneath, links refused; without --root, the perimeter
What stdout carriesa tool can print anything it readlex-os’s audit chain, not a type
Resource use (memory, wall time, bytes)heap and arena trap; no clockD8’s caps; lex-os Budget
Per-invocation behaviour (dry-run vs apply)the row is the program’sM7 (strace); the D10 split if wanted
Contenta row says a tool can read, not what—

D13. How lex-os consumes it

Decision. A bridge maps a cancho authority report onto lex-os’s derive_from_effects input, failing closed, and lives in lex-os (it needs lex_types::EffectSet and the lattice; reimplementing the lattice elsewhere would be a second, independent source of authority, which its CLAUDE.md forbids). The mapping:

cancho labellex-os effectNotes
fs_read(p)fs_read with scope p"" is the root, kept as an entry (§2.5)
fs_write(p)fs_write with scope p
net_out("h:p")net with scope h (the entry keeps :p)required: verbatim it is off_lattice and derives network: none (§2.5)
net_out("")bare netunscoped_net: true, the same “which host is a perimeter question” lex-os already reports
net_in(b)no mapping: refuse until lex-os decides what inbound means (docs/net.md §2)the grant has no inbound field
ffi(lib), or any bounded:falserefuseverbatim it derives no authority (§2.5)
file_read, file_write, conn_*droppedspent at open; the minting label (fs_*, net_out) is in the report (docs/file-handles.md §4.1)
io_read, io_write, err_write, heap, argsoff_latticereviewed by eye, as lex-os does for io
anything elserefuse (bridge.unknown-label)a new compiler label must not default to “no authority”

The reference test is the measured hole itself: the fixture for a dialling program must derive network: allowlist with that host, and a mapping that drops net_out must turn the test red (S2’s mutant).

Using it. With the bridge:

  • lex-os authority derive of a tool’s manifest yields the least Grant;
  • gate against a manifest says whether the tool fits;
  • narrow_manifest tightens a manifest to what the tool needs, as for Lex;
  • diff between two releases of a tool is the review artifact — after the prefix finding is fixed: path scopes are compared as opaque strings, so narrowing fs_read("") to fs_read("/work") is reported as a widening (§2.5). Until then the authority diff for cancho tools over-reports.
  • lex-os-capsule can bind a tool binary’s content hash to the derived grant and egress, signed, and installing it narrows the consumer’s manifest — the toolbox’s release artifact.

Extent through a mediator. lex-os mediates commands: it logs a request, classifies its reversibility, checks the perimeter and the budget, charges, allows (crates/lex-os-supervisor, CLAUDE.md’s “order of gates”). If each tool is registered as a command — tool.seek as (filesystem, read-only, reversible-cheap), tool.write as (filesystem, read-write, irreversible-bounded) — derived from the manifest instead of hand-written, the supervisor sees the arguments at request time. introspect’s role fields (D11) tell it which operands are paths and hosts, so the extent can be checked at the mediator against a path-scope facet, even though no type can express it. The facet (prefix-containment narrowing) does not exist and must be written by someone who registers a validator (facet.rs).

What it cannot see, said once: a bare or unbound label (net_out(""), fs_*("")) gives it a kind and no extent; off-lattice labels are reviewed, never refused; a path scope is not gated today; the perimeter’s filesystem is a pair of booleans; and everything in D12’s not_narrowable table.

Alternatives. (a) Feed cancho labels verbatim: measured, unsafe. (b) A Python bridge in this repository that computes the grant: a second derivation of authority, which lex-os forbids. (c) Make cancho emit lex-os effect names: wrong direction; cancho’s labels are its own and finer.

To confirm — and it is a cross-repository commitment. Whether lex-os wants this input at all; this document asks for it and changes nothing there. Two issues for lex-os come out of it regardless: the prefix classification and the silent off_lattice derivation of a label an unknown front end supplies.

D14. Extent through build-time variants

Decision. A deployment that wants the type-level proof builds the tool with the literal substituted: scripts/variant.py --root /srv/work --tool seek, a source transform from Fs("") to Fs("/srv/work") (and Net("") to Net("h:p") for fetch), then the ordinary build, whose authority now reads fs_read("/srv/work"). The generated variant’s manifest is derived like any other (D12) and its --root flag is fixed to the baked value. This is the mechanism docs/agent-tools.md §3.1 named and backends.rs’s bind test uses for a port.

Why a variant and not a flag. L5: nothing is generic over a prefix.

Alternatives. (a) Ship only unnarrowed tools and rely on D9 and the perimeter: this is what is shipped by default; variants are opt-in. (b) Wait for a language feature making Fs(p) generic: the design (linearity-and-effects.md §7.4) deliberately chose literals “so the refinement is checkable structurally”, and this document does not argue against it.

To confirm. Whether variants are worth their maintenance (N tools times M deployments of build output) before any deployment asks. Recommendation: build the transform in S2 and use it in the tests, ship no variants.


5. The first tools

D15. The set, and a verdict on each

The candidates in the brief, with the gain over the incumbent, the effect row expected (the gate derives the real one — D12), and what the language lacks. “Not worth it” is a verdict, and it is given where it is the honest one.

#ToolVerdictGain over the incumbentExpected rowNeeds
1seek (literal search)Build, exemplar (S1)tagged errors; stable bytes; {"b64"} for binary; per-file results with end record; no fs_write, net, ffi provable. Not speed (roughly 3–5× slower than grep, Appendix B) or regexargs, file_read, fs_read(""), heap, io_write (+io_read only if stdin is accepted; +err_write)L1 (for a trustworthy ok); D8 rewrite
2write / replace (atomic, precondition, dry-run)Build, mutating exemplar (S1)the one place no incumbent has an equivalent: sed -i/tee/> are blind; precondition hash, atomic rename, tagged conflicts, idempotence, --dry-run. Authority: fs_write + file_write, no netargs, heap, file_read, fs_read(""), file_write, fs_write(""), io_read (content on stdin)L4 for hashes over 64 KiB (see below); L1
3peek (range read: head/tail/sed -n/cat -n/wc)Build (B1)line numbers and offsets, binary detection, a next cursor, size, one call instead of wc -l; sed -n a,bp; it is the agent’s most frequent operationas seekD8
4jsonq (path query, JSON Pointer, RFC 6901)Build, small, and refuse to grow (B1)strict RFC 8259 + UTF-8 validation (docs/json.md §3), depth cap, error position as data, authority is heap + read-onlyargs, heap, io_read/fs_read(""), io_writeL13 (24× memory)
5tally (count distinct keys; top-N)Build if an asker shows (B1)sort | uniq -c | sort -rn | head is four processes whose order can depend on locale; one bounded, deterministic, byte-ordered pass. examples/wordfreq/ is the seedas seekD8 (--max-keys)
6list (ls + find + tree, depth-bounded, sorted)Build after L2 + L3 (B2) — the most valuable and the one blockedsorted bytewise; kind/size; depth and entry caps; and no -exec/-delete, the verbs a supervisor most wants absent from a rowargs, heap, io_write, fs_read("")L2, L3
7hash (sha256/sha512)Build as a by-product; not worth it alone (B2)JSON, many files, --verify. sha256sum is faster (60 ms for 64 MiB) and already right. Its worth is that write’s precondition needs the functionargs, heap, file_read, fs_read(""), io_writeL4
8diff (hunks as data)Defer; probably not worth it (B3)hunks as a datum lets an agent verify its own edit; but write/replace already return what changed, GNU diff exists, and Myers’ algorithm in a language without closures is a few hundred lines to maintainargs, heap, file_read, fs_read(""), io_writean asker
9fetch (http get)Not in the first tenOnly for plain HTTP to a baked host (D14): net_out("h:80") + conn_read/conn_write, bounded: true (A.12). HTTPS needs Ffi (L9), making the report bounded: false, so the dominant case defeats the premise. curl does it betterargs, heap, net_out("h:p"), conn_read, conn_write, io_writeL9; or an internal-HTTP asker (lex-os’s own demo egress is one)
—statNo standalone toolexists/kind/size fold into list entries and peek’s header; mode and mtime need L3 and no agent asked—L3
—cut/sort/uniq/lines transformsNot worth itexamples/cut/ and examples/sort/ exist and are checked against GNU (LC_ALL=C sort), but their gain over GNU is the envelope alone, and agents transform text in their own language. tally is the one aggregate with a real gain——
—grep with regex, head/tail/wc as separate toolsNot worth itregex: rg (and L8 is large); head/tail/wc: folded into peek—L8

No tool deletes. fs_remove exists (§2.2) and a rm-like tool would be easy, and it is deliberately absent: a delete is lex-os’s irreversible-consequential class, which in a no-human system “must be absent from the grant entirely” (manifests/src/commands.lex), and a toolbox that shipped one would be handing the supervisor the one command it is built to refuse. (write replaces atomically and removes only its own temporary file.)

Amended: a tombstone is a rename, not a delete. cancho-tools’ move --remove --if-sha256 HEX PATH (cancho-tools#25) takes a file away by renaming it to .NAME.removed-<first 8 hex of HEX> in the same directory. D15’s reason stands: the content is kept, nothing in the toolbox purges it, and renaming it back undoes the removal, so the tool is irreversible-bounded like write, not the irreversible-consequential class. It needs the hash (a file is removed only as the caller read it), refuses an existing tombstone (conflict.exists), and a retry that had landed is changed: false. Tombstones accumulate, and clearing them is a person’s job with a shell. There is still no purge, and no tool that unlinks.

That is eight tools plus a deferred ninth, under D1’s ten. Six are buildable now (seek, write, peek, jsonq, tally, and hash with a caveat); list waits on L2/L3, diff on an asker, fetch on L9.

On write and the hash. A precondition by hash needs the SHA-256 of the current file. Today that traps past 65,527 bytes (L4): an arbitrary ceiling that a large source file, a lockfile or a generated document exceeds, and a trap is not an error an agent can read. Two honest routes: port the compression loop into the contract package with chunked state (about 100 lines, the AGENTS.md §7 rule — write it in your program first), then move it to std.crypto when hash is the second program that wants it; or wait for the std slice. Recommendation: the in-package port, so S1 is not gated on a compiler release (D18), with the std slice as a follow-up that deletes the copy.

Per-tool contract sketches (each gets a schema file at S1/B-time; these fix the shape):

seek    [--root DIR] [--max-count N] [--max-line-bytes N] [--ascii-case-insensitive]
        [--require-match] [--format ndjson|text] PATTERN FILE...
        {"type":"match","path":"a.cho","line":3,"offset":120,"text":"…"|{"b64":"…"}}
        {"type":"file","path":"a.cho","matches":2,"bytes":4096,"binary":false}
        {"type":"error","error":{…}}
        {"type":"end","complete":true,"files":2,"matches":5,"truncated":false}

write   [--root DIR] (--create | --if-sha256 HEX) [--content-sha256 HEX]
        [--dry-run] (--stdin | --content-file PATH) PATH
        data: {"path":"a.cho","changed":true,"created":false,"bytes":812,
               "before_sha256":"…","after_sha256":"…"}
replace [--root DIR] --old TEXT --new TEXT [--expect N=1] [--if-sha256 HEX] [--dry-run] PATH
        data: {"path":"a.cho","replacements":1,"before_sha256":"…","after_sha256":"…"}

peek    [--root DIR] [--lines A:B | --bytes A:B] [--max-bytes N] PATH
        data: {"path":"a.cho","size":4096,"kind":"text","range":{"from":1,"to":40},
               "lines":[{"n":1,"text":"…"}],"eof":false,"next":{"line":41}}

jsonq   [--pointer /a/0/b] [--keys | --length | --type | --exists] [--max-bytes N] [FILE | -]
        data: {"pointer":"/a/0/b","kind":"string","value":"…"}
        error: parse.json with detail {"offset":N,"line":L,"column":C}

tally   [--root DIR] [--field N --delim D] [--top N] [--max-keys N] [FILE...]
        data: {"total":N,"distinct":M,"top":[{"key":"…","count":K}],"truncated":false}

list    [--root DIR] [--depth N] [--max-entries N] [--kind f|d] DIR
        {"type":"entry","path":"src/a.cho","kind":"file","size":812}   (sorted bytewise)
        {"type":"end","complete":true,"entries":N,"truncated":false}

hash    [--algo sha256|sha512] [--verify HEX] PATH...
        {"type":"hash","path":"a.cho","algo":"sha256","hex":"…","bytes":812}

D16. Language gaps: what is fixed in cancho, and what is routed round

Decision. std/*.cho is compiled into the compiler with include_str! (crates/cancho/src/main.rs:410-429), so any std change and any new builtin is a compiler release, and CONTRIBUTING.md’s bar is two askers. The policy:

  • L1 (checked output) is a correctness gap, not a feature: it makes ok:true untrustworthy for every tool. Fix in the compiler, first. The smallest honest change is write_bytes/io.write_all answering what was written and a way to flush and learn the result; the document bulk-io.md §3.3 is already corrected to say it is open.
  • L2/L3 (fs_list, fs_stat) are the largest asks and the most valuable. Askers: the toolbox’s list (one) and cancho-log, whose docs/file-writes.md §8 names fs_list as waiting for “a program that cannot” avoid it (two, if that program asks). Edition 6, both backends and both targets. Fix in the compiler, as the first gate of B2. dirent’s d_name offset differs by target (docs/file-writes.md §8): that is the design problem, not the builtin.
  • L4 (incremental hash): in-package first, std second (above).
  • L5, L7: by design or declined; D9 and D14 route round them and the tests pin the limit (M8). L6 was built (#227) and M8 now asserts the refusal rather than the escape.
  • L8 (regex): declined. seek is literal; a person who wants regex uses rg, and the seek error for a metacharacter-looking pattern is a tag with a none repair, not a guess.
  • L9 (TLS): declined; fetch stays out of the first ten.
  • L10 (base64): in-package, then std at the second asker.

Each language slice carries its own mutants (§8). A gap reported here without a reproducer would be an opinion; L1 to L8 and L10 have one (Appendix A), and L9 is stated by docs/native-sockets.md §6 and not reproduced here.


6. Distribution

D17. One binary per tool, not a busybox

Decision. One executable per tool: seek, write, peek, …

Evidence.

  • Authority is per program. The authority report is the union of what main reaches (§2.4): a multi-call binary containing cat and rm reports both fs_read("") and fs_write("") for every invocation (A.10). The single property this whole epic sells — a read-only tool provably cannot write — is destroyed by merging. A supervisor cannot know which applet will run.
  • Size and startup are no argument for merging. Busybox exists to save space and exec cost. A one-tool cancho binary is 16 KB (hello) to 22 KB (seek, 21,832 bytes); spawn cost is indistinguishable from true (0.98 ms against 1.01 ms for /usr/bin/true, 500 runs; grep 1.30, jq 2.53, rg 4.56 — a probe, §B). seek builds in 0.2 s.
  • There is no dispatcher to write. The language cannot exec. Discovery is a generated index file (tools.json: name, version, binary hash, authority, schema ids, reversibility), not a program.

Alternatives. (a) Multi-call binary: rejected on the first point. (b) A multi-call binary per authority class (read-only, read-write): legitimate and cheaper to distribute, defeats nothing the row says beyond the class, and is a later optimisation if distribution count ever matters; not needed at ten tools of 20 KB.

D18. Where the code lives: a separate repository, cancho-tools

Decision. A separate repository, cancho-tools, with a cancho.toml whose [[bin]] sections list one program per tool, the contract package as a directory of modules inside it, [package] cancho = "<commit>" pinning the compiler, and [[test]] sections for the unit tests (docs/package-system.md §8.2, §8.8). Not packages/ in this repository.

Reasons, from the repository’s own measurements.

  • A tool’s source breaks when the compiler moves. docs/hash-stability.md measured that 71% of this repository’s history stops type-checking under today’s compiler (quoted in docs/package-system.md §8.1). Tools must therefore pin a compiler by commit, which is what the project file does; in-tree packages/ are consumed at the same commit as the examples and never pin anything.
  • The precedent is the pattern the repository already set. cancho-hooks, cancho-log, cancho-web are separate repositories using §7 and §8 (docs/package-system.md §7.1); a tools repo is the same shape, and it can use vcs lock --git and cancho install unchanged.
  • Different change rates and different gates. The compiler’s file budget (crates/cancho/tests/files.rs), its two backends and its conformance suite are the wrong gate for a 20 KB tool; the tool repo has its own: M1–M9 (§7), which are slow and need GNU tools.
  • A std addition is a compiler release; a package is versioned apart. Language gaps land in cancho; the tools wait for a compiler commit and bump a pin in a PR that shows the new authority diff (docs/package-system.md §7.5 step 6 is the missing half).

What would argue for packages/: the tools’ tests would run in the same PR as a compiler change that breaks them, instead of being found by a canary. The mitigation is one canary job in cancho CI that builds the pinned tools at the compiler’s head and reports (not blocks) — cheap, and it turns the 71% from a surprise into a number.

Build requirement worth stating. The compiler needs clang (≥ 15) and cc at build time (docs/package-system.md §9.3). No compiler release has been published, so a tools repo’s CI builds the compiler from source (about 1m23s, §9.1) until one is. A user of a tool needs only the binary.

To confirm. The repository, its name and ownership.


7. The measurement protocol

Two parts, and the line between them is the point. Part 1 is offline, deterministic and runnable in a sandbox like this one. Part 2 needs a model in a loop; it cannot be run here and nothing downstream may be claimed from it until it is. This document reports no result from either part.

7.1 Offline and deterministic

Hermetic: a fresh temp directory, a fixed fixture tree, a pinned compiler revision, a recorded environment. Conventions follow scripts/bench.py: interleaved runs, minimum and median reported.

MeasuresHowGate
M1 Schema conformanceevery output is valid against its versioned schemaa corpus of invocations per tool (argv, stdin, fixture tree), each stdout validated with jsonschema (Draft 2020-12, additionalProperties:false); exit code in the declared table; a schema change classified additive/breaking by script100% valid, 0 undeclared fields, 0 codes outside the table; a breaking change without a major bump fails CI
M2 Determinismbyte-identical rerunsevery corpus case run twice and under varied LANG, LC_ALL, TZ, TERM, NO_COLOR, PATH, cwd, stdout as pipe, file and a pty100% byte-identical stdout and status
M3 Rule coverage, soundness, actionabilityevery error path has a tag and a repair that worksone fixture per catalogued rule; run; check error.rule, exit code; if repairable is not never, apply repair.argv by script, with no judgement, and rerun; assert the declared outcome and that no flag it added increases authoritycoverage (rules with a fixture / rules) = 100%; hint soundness (hints that succeed when applied / hints emitted) = 100% — a hint that fails is a bug; actionability (see below) is reported in S1 and gated once baselined, not before
M4 Fault injectionno input reaches a trapmutate argv, stdin and fixture state (missing/duplicate/oversized values, .., NULs in a file, a 1 GiB sparse file, a line longer than every cap, invalid UTF-8, a directory where a file is expected, EISDIR/ENOTDIR/ENOENT races)0 exits 132/139 over N cases (N fixed at S0 from a pilot of how fast new traps stop appearing); every case yields a valid envelope
M5 Differential vs GNUagreement on the shared semantic subsetper tool, a normaliser reduces both outputs to a comparable datum: seek vs grep -F -n -b; peek vs sed -n/wc; jsonq vs jq -c; hash vs sha256sum/sha512sum; write end-state vs cp; list vs find -printf '%P\t%y\t%s\n' | LC_ALL=C sort; tally vs LC_ALL=C sort | uniq -c | sort -k1,1nr -k2; diff: apply our hunks with patch and compare the result (not the text — minimal diffs are not unique)0 divergences over N seeded cases plus the hand-written edge corpus (CRLF, no trailing newline, NUL, long lines, invalid UTF-8, empty file). The precedent is both_ports_match_gnu_on_every_spelling (docs/flags.md §1.1)
M6 Authoritymanifest equals the compiler’s, within the ceilingD12’s three checks; the bridge totality check (every label the compiler can emit for these tools is in D13’s table)all three, for every tool; the label-mapping table is total
M7 Mutation behaviourdry-run does not write; apply is idempotent; atomic; two writers racerun --dry-run under strace -f -e trace=file,rename,write and a before/after hash of the tree; every mutating case twice; 200 trials of two concurrent writers with the same --if-sha256dry-run: 0 mutating syscalls, tree unchanged; second apply changed:false; race: exactly one success, never two
M8 Confinement--root holds, lexically and through links.., absolute, //, ./, trailing /, sibling prefix, empty, 4096+ bytes, Unicode, a link to a file and a link to a directory outside the rootevery case a tag, none a trap; (flipped, #227) every tool refuses both links with path.symlink and leaves the outside untouched
M9 Performance and memorystartup, throughput, memory flatnesssee belowno gate on speed vs GNU; a regression gate against the previous release’s minimum; memory flatness is a gate

Actionability, defined. Fault-injection cases from M4 that produce an error are the denominator for coverage of hints (how many carry a repair); the numerator for soundness is hints that work when applied. These are two different numbers: a tool can have perfect soundness and cover no case. Both are reported per tool and per rule; neither is set before a baseline exists. (The temptation to write “≥ 90%” here is exactly what this document should not do: a threshold from nowhere is the claim outrunning the evidence.)

Performance, as a method, not a result.

python3 scripts/toolbench.py --tool seek --size 64MiB --runs 15 --sink pipe
# per run: [tool, incumbent] interleaved; report min and median;
# record: machine, kernel, `cancho --version`, incumbent versions,
# locale, page-cache state (warm), sink.
# incumbents: grep -F, rg -F, sha256sum, jq, sed -n, LC_ALL=C sort|uniq -c, find
# startup: 500 spawns of an empty workload per binary, mean ms

Three pitfalls were met in this document’s own probes and the harness must defend against each:

  • GNU grep short-circuits when stdout is /dev/null. grep -c gamma on 64 MiB took 3 ms with output to /dev/null and 100–160 ms through a pipe: a 40× illusion. The harness’s sink is a pipe or file, and a self-test asserts the two differ for grep so a future change cannot silently reintroduce the shortcut.
  • The operation must match. wc (all counts) took about 0.9–1.0 s on the 64 MiB file, wc -l 15 ms, wc -c 3 ms: “faster than wc” means nothing without saying which. Each comparison names the exact incumbent command.
  • Locale. sort and wc -w depend on it; the harness pins LC_ALL=C for the incumbent and states it. (Not demonstrable on this machine, which has only the C locales.)

Memory flatness (the one performance gate). Run each streaming tool on a 1 MiB, 64 MiB and 256 MiB input and read peak resident memory (ru_maxrss of the child); require max / min ≤ 1.5. It is deterministic, it tests D8’s rule directly, and the existing seek fails it.

Mutants for the harness itself are in S3.

7.2 Agent in the loop (cannot be run in this sandbox)

This part has not been run, cannot be run here (there is no agent loop, model access or budget in this environment), and no sentence in this document, the README or any tool’s introspect may say the toolbox is “more agent-friendly” or “improves task success” until it has. What follows is the format, the harness, the metrics, the baselines and the gate. No number appears because none exists.

Three arms, because the contract and the implementation are separable (§0):

ArmWhat the agent is givenWhat it isolates
A. IncumbentsGNU coreutils, rg, jq, sed, find/ls, as a shellthe baseline
B. Incumbents + shimthe same programs behind a conforming envelope: JSON, the D4 exit codes, rule tags, D6 hints, D10 preconditions — a wrapper in any languagethe value of the contract alone
C. Toolboxthe cancho toolsB versus C is the value of the implementation (authority, determinism, defined behaviour)
D. Toolbox, hints removed (ablation)C with repair always null, tags keptthe kill rule for D6 (docs/agent-errors.md §5.1)

Task suite format. One JSON file per task, a directory of fixtures, a verifier:

{"id":"edit-precondition-03","category":"edit","goal":"…text the agent sees…",
 "fixture":{"tarball_sha256":"…","setup":["…seeded mutations…"]},
 "tools":{"A":["sed","grep"],"B":["…"],"C":["seek","peek","write"]},
 "faults":[{"after_call":2,"inject":"concurrent-edit","path":"src/a.cho"}],
 "oracle":{"cmd":"python3 verify.py","success":"exit 0","unsafe":"exit 7"},
 "budget":{"max_tool_calls":30,"max_tokens":60000,"max_wall_s":600},
 "seeds":[1,2,3,4,5]}

Categories (each with a safety twin where it makes sense): orient (find a file), search, read-a-range, edit-with-precondition, concurrent edit (the fixture changes the file between the agent’s read and write: the right behaviour is to notice, not to overwrite), recover-from-error (a fault is injected: a mistyped flag, a .., a missing file), refuse-unsafe (the goal tempts a write outside the root: success is not doing it), json-extract, hash-verify.

Harness. One runner executes (task, arm, seed) in a clean sandbox (a lex-os box where available, otherwise a container), with the model, version and sampling parameters pinned and recorded, the tool descriptions generated from each arm’s own introspect/--help (so an arm is not advantaged by better prose), and every call, response, token count and wall time logged to a transcript. The oracle, not the agent, decides success and unsafe actions.

Metrics.

  • Effectiveness: success within budget (the pre-registered primary metric); tool calls to success; tokens; wall time.
  • Recovery: after an injected fault, was the next call a success (recovery@1)? how many calls to recover?
  • Hint use: was repair.argv applied verbatim, adapted, or ignored? did the agent misparse an envelope (schema-misread rate)?
  • Safety: unsafe-action rate (the oracle’s unsafe exit), measured in a box whose grant is derived from the tools’ manifests versus a box with broad exec.

Analysis. Paired by (task, seed); bootstrap confidence intervals; the primary metric, the sample size (from a pilot) and the decision rule written down before the run. Three claims are separate and each may fail:

  1. The contract helps: B beats A on success or cost.
  2. The implementation helps safety, not necessarily success: C beats B on unsafe-action rate under a derived grant. An honest expectation is that C and B tie on success — authority is not task success — and the benefit, if any, is in the safety column and in what a supervisor can check.
  3. Repair hints help: C beats D. If not, repair is cut.

Gate for S-last. The protocol and the suite are reviewed and frozen; a pilot sizes the sample; the run happens; the result is reported with its intervals whichever way it goes. A claim is added to README.md only for the comparison that cleared its pre-registered rule.

7.3 What may be said when

StatementAllowed after
“Every tool’s output validates against its published schema”M1 green
“Every error path carries a rule tag; every hint was applied by script and worked”M3 green
“The authority a tool reports is the compiler’s”M6 green
“The tools do not crash on adversarial input”M4 green with its N stated
“…and are as fast as / faster than GNU”never claimed: not a goal
“More agent-friendly”, “improves task success”, “recovers from errors better”S-last, and only the comparisons that cleared their rule

8. Slices

Effort figures are judgement, not measurement, in working days for one person who knows the repository; they are there so a person can see the shape, not to be held to.

S0. The contract package (about 5–8 days). In cancho-tools, a directory of modules, none over 2,000 lines: the error type and rule catalogue; the envelope Writer over std.json; the D4 exit codes; the table-driven flag parser over std.flags; D9’s path validation; limits; the chunked reader of D8; text_or_bytes with the base64 encoder; the introspect/skill emitter; the incremental SHA-256 port. Gate: cancho test green; fmt --check and check clean; envelope and catalogue corpus validated (M1 for the contract itself); fuzz of the path validator and the flag parser with 0 traps (M4 slice); authority of a contract-only program within {args, heap, io_write}. Mutants (each must be killed by a named test): envelope omits schema; INVALID_ARGS and NOT_FOUND swapped; a tag renamed without its fixture; validation accepts ..; accepts a//../b; accepts an absolute path outside the root; accepts /rootevil for /root; a limit check > for >=; invalid UTF-8 written as a string instead of {"b64"}; the table says a flag is boolean and the parser takes a value; -- not honoured; the end record omitted on an error path; complete:true after a limit stop; no trailing newline; the hint adds --root /; introspect prints a stale table.

L1. Checked output (compiler; about 2–4 days). A way to learn that a write failed. Gate: cargo test --workspace plus new conformance tests: a program writing to /dev/full and to a closed descriptor observes the failure on both backends; bulk-io.md §3.3’s correction updated. Mutants: the result ignored; the failure swallowed on one backend; flush at exit errors dropped.

S1. Two tools end to end (about 8–12 days): seek v1 (read-only) and write/replace (mutating). Gate: M1–M5, M7, M8 for both, M9 memory flatness for seek, and the contract’s rule-fixture equality. Mutants — seek: the --max-line-bytes guard removed (M4 must hit a limit); line numbers off by one; CRLF handled differently; a NUL-bearing file treated as text; --max-count per file instead of total; the end record dropped on --max-count. write: precondition skipped; fsync skipped; rename before the data is written; the lock removed (the race must fail within N trials; N is calibrated by running this mutant until it fails in at least 95% of batches); a second apply not idempotent; temp not removed on failure; hash taken over the wrong range; dry-run writes (M7’s strace must fail it).

S2. The authority manifest, and the lex-os derivation (about 5 days here, plus a lex-os change of about 3–5 days). In the tools repo: D12’s two-pass embed and three-part gate; the ceiling file; D14’s variant transform. In lex-os: D13’s bridge, its fixtures, and the two issues (prefix classification; unknown labels defaulting to no authority). Gate: M6; and, across the repositories, the bridge derives the expected grant for every tool’s manifest (hand-written expectations: seek fs read-only, write fs read-write, none net or exec), refuses ffi/ bounded:false/unknown labels, and the measured net_out hole is a fixture. Mutants: the mapping drops net_out (must turn the fixture red); ffi mapped to nothing; an unknown label accepted; the :port dropped from egress; the embedded manifest patched after link (introspect differs from the compiler); two tools’ manifests swapped; the ceiling widened without a diff.

S3. The benchmark harness, offline part (about 5 days). scripts/ toolbench.py, the seeded corpus generator, the report format. Gate: the harness is reproducible (two runs, same machine, same corpus, same selection of incumbents); it has power (a planted 1.5× slowdown is detected in at least 95% of 20 trials and identical reruns are flagged in at most 5%); the /dev/null self-test of §7.1 passes. No result is published from it. Mutants: sink switched to /dev/null; runs not interleaved; the minimum replaced by the first run; the memory probe reading the parent’s ru_maxrss; locale not pinned.

B1. Batch one (about 3–5 days per tool): peek, jsonq, tally. Each passes the S1 gate set with its own M5 normaliser and mutants (peek: an off-by-one in next; binary detection removed. jsonq: depth cap off; --max-bytes ignored; a pointer escape (~0/~1) mishandled. tally: --max-keys ignored; ties not broken bytewise).

L2/L3. fs_list and fs_stat (compiler; about 5–10 days: two backends, two targets, an edition, the dirent layout). Gate: both backends; a hostile directory (10⁵ entries, non-UTF-8 names, a name 255 bytes long, a dangling link) lists identically on both and in bytewise order; the row is fs_read(p); the must-reject fixtures. Mutants: unsorted; d_name read at the wrong offset on one target; the prefix check skipped; ./.. leaked.

B2. Batch two: list (after L2/L3), hash (after the std slice or with the in-package copy). list’s M5 is find -printf … | LC_ALL=C sort.

B3. Batch three: diff only if an asker appears; fetch only as a D14 variant for internal HTTP, if one asks.

S-last. The agent-in-the-loop evaluation (about 15–25 days, dominated by building the suite and by the run’s cost; cannot be started in this sandbox). Gate: §7.2’s, including the pre-registered primary metric, decision rule and sample size.


9. Risks

RiskWhy it is real hereMitigation
Scope creepEvery Unix tool is “ten lines” in the head and a grep clone in the diff; jsonq wants to be jqD1’s admission rule; query.unsupported-syntax with a none repair that names jq; the ten-tool ceiling is in tools.toml, so exceeding it is a diff
POSIX expectationsAn agent will type grep -r. docs/flags.md found silent ignoring is the dangerous failure; docs/agent-cli.md found a generated surface teaching a wrong spellingtable-driven parsing (one table drives parse and introspect); an unknown flag is args.unknown-flag with the nearest declared flag as the hint, never ignored
The claim outruns the evidenceThe easy sentence (“a safer, agent-friendly grep”) is already in this document’s own prior art§7.3’s table; introspect.evidence lists which gates have run; the README gets nothing from S-last until S-last runs
Authority theatrefs_read("") with bounded: true reads as reassuring and means the whole filesystemnot_narrowable in every manifest (D12); §0 amendment 2; extent comes from D9/D13/D14 and the perimeter
A trap is the failure modeOverflow, bounds, Writer misuse (a bare value in an object is a trap, docs/json.md §3), arena and heap exhaustion all kill with no JSON, and unflushed stdout is lostM4; the end record; no region sized from data; caps with tags
Maintenance71% of this repository’s history stops type-checking under today’s compiler (docs/hash-stability.md); three targets’ worth of Split and edition churncompiler pin per release; a canary in cancho CI (D18); each tool is small (seek is 336 lines; cut 309; sort 359)
Two truths about a tool’s flagsA flag table and a parser written separately will driftD11: the table is the parser’s input; M1 runs every declared flag
Silent output failureL1the end record now; the compiler fix first (D16)
Cost of linear types in the toolsout = f(h, out, x) three tokens longer every time (AGENTS.md §1) and a res consumed on every patha shared contract package so each tool is mostly its own logic; reported as lines per tool against the shim’s, as a cost in S-last
Cross-repository couplingThe bridge lives in lex-os; the tools repo pins a compiler; the compiler gains builtins for the toolseach dependency is a named slice with its own gate and an owner (Q7)
Benchmarks that flatter/dev/null, mismatched operations, locale — all three bit in the probes§7.1’s pitfalls and S3’s self-tests

10. Open questions for a person

#QuestionWhy it is not decided here
Q1Is JSON the default (D2), and does seek’s no-match exit change from 1 to 0 (D4)?Both change the one existing tool’s behaviour and a test that asserts it
Q2May the envelope drop meta.duration_ms and add schema/rule/repair (D3), and are errors on stdout (D5)?A deviation from the ACLI SDK’s Envelope; the spec text was not read
Q3Does repair ship, under the kill rule (D6)? What is the comparison’s tolerance?The previous round cut fix for lack of evidence; this one produces the evidence, and a person should agree the rule before the run
Q4Is “no blind overwrite” (D10) acceptable policy?It will make an agent that has not read the file fail once
Q5Should cancho add fs_list/fs_stat (D16), and does cancho-log count as the second asker?A language-scope decision with a two-asker bar and an edition bump
Q6Dry-run flag now, or the plan/apply split (D10)?The flag is not provable by the row; the split is, and costs a binary and a plan format per mutating tool
Q7Does lex-os want a cancho authority input (D13), and will it fix path-scope narrowing and unknown-label handling? Who owns the bridge?A cross-repository commitment; lex-os is not changed by this document
Q8Separate repository and name (D18); whether to register tools as lex-os commands with a path-scope facet (D13)Ownership; and a facet nobody has written
Q9Which model, harness and budget for S-last; is a lex-os box available to run it?Not available here, and the sample size depends on a pilot
Q10Should cancho’s own check/authority output be retrofitted to this envelope, since they are already agent-facing tools whose JSON has no ok?Changes an existing, tested surface for consistency

Appendix A. Reproducers

Every one was run with the prebuilt compiler of Appendix B. Each is the smallest program that shows the thing, written to a scratch directory.

A.1. narrow takes a literal (L5). A program that narrows fs to arg(g, 1) is refused:

{"rule":"capability-not-narrowable",
 "message":"`narrow` takes a literal, so the refinement can be checked where it is written"}

A.2. No listing, no stat (L2, L3). fs_list(f, "/tmp") and fs_stat(f, "/tmp") inside a borrow fs as &f block are each refused:

{"rule":"not-a-function","message":"`fs_list` is not a function in this program"}

Through Ffi, opendir and readdir type-check (c_ptr results) and the report becomes "bounded": false, foreign_symbols: ["opendir","readdir"]; a c_ptr is never dereferenced, so no name comes out. I did not try getdents64 through syscall; it would be ffi("libc") as well.

A.3. No regex (L8). import std.regex; is unknown-name (no module std.regex in this program).

A.4. Fs confinement (L6, L7). Fs narrowed to "/tmp/jailprobe", path from argv[1], fs_read into a 64-byte slice, exit status = bytes read (100 for -1):

$ ln -s /tmp/outside/secret.txt /tmp/jailprobe/link.txt
/tmp/jailprobe/in.txt                       -> 14        (14 bytes: ok)
/tmp/jailprobe/missing.txt                  -> 100       (-1: missing is a value)
/tmp/jailprobe/link.txt                     -> 20        (the OUTSIDE file's bytes)
/tmp/jailprobe/../outside/secret.txt        -> 132       (SIGILL, nothing printed)
/tmp/outside/secret.txt                     -> 132
in.txt                                      -> 132       (relative path)
/tmp/jailprobeevil/x                        -> 132       (sibling prefix)
/tmp/jailprobe                              -> 100       (a directory: -1)

With Fs left unnarrowed (Fs(""), same program shape): a relative path that exists is read (150 = 5014 mod 256, 14 bytes), a relative path that does not exist is a value (ENOENT), and ../x and /tmp/jailprobe/../jailprobe/in.txt both exit 132.

A.5. Errno survives (open_read then file_read, status = a code mod 256: 1000+errno at open, 2000+errno at read, 5000+bytes): a regular file 5014; /nonexistent 1002 (ENOENT); /tmp/jailprobe/in.txt/x 1020 (ENOTDIR); /tmp 2021 (open succeeds, read is EISDIR); "" 1002.

A.6. sha256 past 64 KiB (L4). Read stdin into a std.buffer, call crypto.sha256(buffer.bytes(t), contents(o)), print the digest:

bytes 1000, 60000, 65527   -> exit 0  (65527: first 16 hex digits equal sha256sum's)
bytes 65528, 70000, 8e6    -> exit 132
sha512: 65519 ok, 65520 traps

The bound is (n + 9 + 63) / 64 * 64 <= 65536: the padded copy is a region.

A.7. Output failure is invisible (L1):

fn main(world: World) -> [] int {
    …release the rest…
    var got = 0;
    borrow mut io as &!i in { got = io.write_all(i, "hello\n"); }
    release(io);
    return got;
}

./wr exits 6 and prints hello; ./wr >&- exits 6; ./wr > /dev/full exits 6 and strace -e trace=write shows write(1, "hello\n", 6) = -1 ENOSPC. A loop of 1,000 sixty-five-byte write_all calls counts the short answers: 15 to /dev/full, 7 to a closed stdout, 0 to /dev/null (the failure surfaces only once the buffer fills). A program that writes {"partial": and calls trap() prints nothing through a pipe (status 132). ./flood | head -1 reports the producer’s status as 141.

A.8. An atomic replace is expressible. open_new a temp, file_write, file_sync, file_close, fs_rename over the destination (edition 5): authority is ["file_write","fs_write"], fs_write(""), bounded: true; running it twice exits 0 both times and leaves the destination intact. A first draft that declared fs_read("") it did not use was refused (“a row is exact or it is decoration”).

A.9. Nothing is generic over a prefix. fn probe[&f, p, &q](fs: &f Fs(p), path: &q [byte]) -> [fs_read(p)] int is type-mismatch: expected a string literal, found an identifier.

A.10. A multi-call binary has the union. A program that picks a cat-like function (fs_read("")) or an rm-like one (fs_remove, fs_write("")) from argv[1] reports args, fs_read(""), fs_write(""); deleting the rm branch gives args, fs_read("").

A.11. The manifest fixed point. A program returning a string constant; authority --output json → JSON J1; rebuild with the constant set to J1’s canonical text (223 bytes); authority again → J2. J1 == J2, and the built binary prints the constant byte for byte.

A.12. A narrow network row exists. edition 5; with Net narrowed to "api.example.test:80" and tcp_connect, conn_write, conn_read: effects ["conn_read","conn_write","net_out"], net_out argument "api.example.test:80", bounded: true. The row’s label is net_out, not lex-os’s net — the reason for D13’s table.

A.13. Invalid UTF-8 is replaced silently (L10). json.put_string of the three bytes 61 FF 61 writes {"text":"a\ufffda"} and nothing signals that a byte was lost; std has no base64, so a tool must pre-check with std.utf8.is_valid and encode the bytes itself.

Appendix B. Probe record

  • Compiler: cancho 0.0.0 (rev 052e623fb55a909f531456cd941bd375ab202dfc, host x86_64-unknown-linux-gnu), /home/user/cancho/target/release/cancho; std/ and crates/ identical to commit ab332d3 (only tests and scripts differ).
  • Machine: Linux 6.18, x86_64, 4 cores, a shared sandbox. Noisy. Locales installed: C, C.utf8, POSIX. GNU grep 3.11, coreutils 9.4, rg 14.1.0, jq 1.7, curl 8.5.0, Python 3.11 with jsonschema 4.26. Not installed: fd, nu, hyperfine.
  • Fixtures: a 64 MiB text file (67,108,892 bytes, 1,307,997 lines, random words from a seeded generator) and a 4,675,827-byte JSON array of 60,000 objects.
  • Timings, one probe each, not protocol results: spawn, mean of 500: hello 0.98 ms, /usr/bin/true 1.01, grep -c x /dev/null 1.30, jq -n 1 2.53, rg x /dev/null 4.56. Search for gamma on 64 MiB through a pipe, 5 runs: seek -c 0.42–0.57 s, grep -c 0.12–0.17, grep -F -c 0.11–0.18, rg -c 0.08–0.11; seek -c on stdin 0.54–0.71 (3 runs). cat 14 ms, wc -c 2.5 ms, wc -l 15 ms, wc -w 0.9–1.0 s, sha256sum 60 ms, sort 0.37–0.55 s. examples/tally.cho 0.35–0.38 s. JSON: parse of the 4.7 MB document 0.095 s with 119,184 KB peak; jq length 0.153 s. seek peak resident memory: 8,304 KB at 1 MiB, 13,776 KB at 8 MiB, 99,788 KB at 64 MiB exactly, 198,032 KB at 64 MiB + 28 bytes, 787,816 KB at 256 MiB + 112 bytes. cancho build of seek.cho 0.206 s, a 21,832-byte executable; hello 16,056 bytes.
  • The lex-os probe is a scratch crate outside both repositories, with lex-os-authority and lex-os-manifest as path dependencies and lex-types at the tag lex-os pins (v0.11.0), built with --ignore-rust-version because the local rustc is 1.94.1 and those crates declare 1.95. It calls derive_from_effects and diff on hand-built effect sets and derive on the body of cmd_write_report. It is not committed (it would be a second derivation of authority) and is described in §2.5 precisely enough to rebuild.
  • The cited lex-lang source (trust.rs:555) is the working copy at /home/user/lex-lang, which may be newer than the v0.11.0 tag the probe compiled; the probe’s measurements are of the tag.
  • No tool, library or compiler change was made. No test was run: the only changes are documents, and every_documentation_link_resolves is the only test that reads them.

Appendix C. What this changed in other documents

Corrected in place, each with a note naming this document, as CONTRIBUTING.md asks:

  • docs/bulk-io.md §3.3: “Output has no such question: it wrote the bytes or the process is gone.” False (§2.1, A.7).
  • docs/agent-tools.md §1: “No silent truncation.” True of what seek reads, false of what it writes (/dev/full exits 0), and seek holds the file (between 1.5 and 3 times its size in memory) rather than a line (§2.1, §2.3).
  • docs/agent-cli.md §3: “cancho’s real codes are 0/1/2/3”. There is a fourth, 4, for cancho test (crates/cancho/src/main.rs header, docs/testing.md §3).
  • docs/README.md, “cancho code under a lex-os grant”: “network and exec are not [enforceable], because both are libc.” Not true of network for a program built on Net (A.12); and the sentence’s “enforceable through it” does not yet hold for either dimension, because lex-os cannot read the labels (§2.5).

Found and not corrected (not documents’ claims, or not this change’s to make): examples/tally.cho prints wc-style padded columns that run together when a count exceeds the field width (1307997 11774082 67108892 printed as 13079971177408267108892; GNU wc separates them), which is the plainest argument for D2 in this repository; and the two lex-os findings of D13 (prefix narrowing classified as widening; off_lattice as the default for a label the front end does not know).

Fuzzing the compiler

Status: a test, and it found four bugs, all in the printer.

Every other suite here checks programs someone chose to write. A fuzzer writes programs nobody chose: it takes every .cho file in the repository, damages each one a little, and feeds the result through the whole compiler. Three properties must hold for every mutant. Nothing panics. What parses prints back to the same tree. What the checker accepts, the backend compiles.

Over 450,000 distinct mutants, about 41,000 of them accepted by the checker, no stage panicked and the backend never refused an accepted program. The printer failed four ways. It dropped the comma from (e,), left a label’s argument unescaped, printed -(a / 10) as -a / 10 (in every release since #43), and printed component 2 of the integer 1 as the float 1.2. Each one is fixed and pinned by a unit test that fails without the fix. The first corrected a claim in tuples.md, and the last could only be seen by comparing trees, not text.

Corrected (#176). A fifth bug, found later and not in the printer: the compile-time evaluator’s recursion-depth guard was sized without measuring what it exists to prevent, and a mutant deep enough overflows the compiler’s own stack before the guard has a chance to refuse (§4.5). It surfaced only because adding a corpus file shifted, for the fixed seed this suite runs with, which mutant a fixed iteration budget reaches — the fuzzer’s own coverage was never guaranteed complete, and this is what that looks like in practice.


1. Why, and why this shape

The audit listed three items under C2. (b) was comparing the folder against the backend (differential.md). (c) was turning a backend failure into a located internal refusal instead of a crash (internal-errors.md). (a) was fuzzing the parser and the checker, which is this document. §6 of internal-errors.md says why (c) could not replace it: (c) makes what a fuzzer finds reportable, and does nothing to find it.

The usual tool is cargo fuzz. It needs a nightly toolchain and libFuzzer, and this repository pins stable Rust 1.98.1 (rust-toolchain.toml) with no dependencies outside Cranelift. A fuzzer that needs a second toolchain would run on somebody’s laptop, and never in CI. So this one is an ordinary integration test, crates/cancho/tests/fuzz.rs, in under 400 lines. It is stable, has no dependencies and is deterministic. A seed and an iteration count reproduce a run exactly on either CI target.

It has no coverage guidance. libFuzzer’s main strength is steering towards inputs that reach new code, and this fuzzer does not do that. Instead it starts from a corpus that already reaches most of the compiler and makes small, well-formed edits to it (§2).


2. The mutator

Seeds. Every .cho file under the repository, found by walking it: 331 files and 563,500 bytes, from tests/ (273), examples/ (26), benches/ (20) and std/ (12). The reject fixtures are included on purpose. Each one is a program one edit away from a refusal, which is where a checker’s edges are. A fixture the lexer refuses contributes no tokens.

Edits. A mutant is a seed’s token stream with one edit, or two or three edits one time in four. Each edit is one of:

WeightEdit
5 in 10Replace a token with another of the same kind, drawn from the whole corpus: a name for a name, an integer for an integer, an operator for an operator
1 in 10Delete a token
1 in 10Duplicate a token
1 in 10Swap two tokens
2 in 10Splice in a run of 1–8 tokens from another file

The same-kind replacement is the edit that matters. A first version replaced tokens at random and made several edits per mutant. It reached the parser’s accept path on 3.5% of mutants, and almost never reached the checker. Keeping the token kind keeps the program’s shape, so a mutant is usually a different program, not a broken one.

The PRNG is xorshift64*, eight lines, with the same output on every platform.


3. The three properties, and how far mutants get

Each mutant goes through the pipeline the CLI runs:

  1. parse under catch_unwind. A panic is a finding. A refusal ends the mutant, which is correct behaviour.
  2. print, then parse again. The printed text must parse, to the same tree (exprs, stmts, types and items compared), and printing that tree must give the same text. Comparing trees is the stronger of the two checks. A printer that loses a parenthesis can still reach a fixed point one reprint later, on a program that means something else, and §4.4’s bug reached one at once. The trees are compared with every symbol spelled out as its name. Symbols are numbered in the order the parser first meets a name, and the printer writes a function’s generics in canonical order ([&h, T] as [T, &h]), so the same tree can come back with different numbers. Comparing raw numbers reported that as a bug in each of its first three long runs.
  3. Parse with the standard library, then lower_all (the checker) under catch_unwind. A panic is a finding. A refusal ends the mutant.
  4. The main shape check the CLI makes before the backend.
  5. compile_object under catch_unwind. A panic is a finding, and so is an Err: the checker accepted the program, so a backend failure is the compiler’s bug (internal-errors.md).

The fuzzer also counts how far each mutant got:

MutantsParsedCheckedCompiledTime (release)
150,000, seed 0x5eed1e5565,560 (44%)13,785 (9.2%)12,583 (8.4%)151 s
150,000, seed 0x0badcafe65,643 (44%)13,788 (9.2%)12,539 (8.4%)156 s
150,000, seed 1234565,477 (44%)13,759 (9.2%)12,604 (8.4%)152 s

The test asserts floors of a quarter of these: 10% parse, 2% check and 2% compile. Without them, a change that stopped mutants reaching the backend would still pass, having tested nothing past the parser. The report deduplicates findings by stage and message, because a thousand mutants hitting the same panic are one bug.


4. What it found

The first four were in the printer. None needed the checker, and none was a panic. The printer is where the canonical form is written, and canonical-ast.md needs it to be exact. Hashes are computed from the tree, not the text, so no hash was ever wrong. But cancho print output that reparses to a different program is a silently wrong answer, which the language exists to refuse. (Only the print command uses the printer. cancho-id hashes the tree directly.)

4.1 (e,) lost its comma

Found at 30,000 mutants. Minimized: let pair = (1,);.

tuples.md §2.1 says a tuple has two components or more. (e) is grouping, so it said a one-tuple cannot be written. That was false. The parser has always accepted a trailing comma, so (1,) parses as a one-part tuple, and the checker refuses it. The printer wrote that tree as (1), which reparses as the integer 1. A program the checker refuses became, after one print, a program it accepts.

The fix keeps the comma (print.rs, tuple). The design did not move: (e,) is still refused, by the same rule and now with a message that names it. tests/reject/one_tuple.cho pins the refusal, which “there is no one-tuple to write” had said could not exist. tuples.md §2.1 is corrected in place.

4.2 A label’s argument was printed raw

Found at 30,000 mutants. Minimized: -> [ffi("a\n")] int.

The tree holds a label’s argument with its escapes resolved, the same way it holds a string literal (strings.md §4). The literal printer escaped it again, but effect_row did not. So a newline in ffi("…") came out inside the quotes, and the result does not parse: a string literal may not span lines. The fix is one call to the escape function the literal printer already used.

4.3 Prefix operators bound no tighter than *

Found at 60,000 mutants. Minimized: -(a / 10) * 10, printed as (-a / 10) * 10.

The printer puts back only the parentheses a tree needs, comparing binding powers. Prefix operators were UNARY = 9 and postfix operators were POSTFIX = 10, both written as numbers. That was right until #43 added the bitwise operators and moved + to 9 and * to 10. From then on, a negation of a product was printed as a product of a negation, and (a * b)[i] as a * b[i]. The two constants are now derived from *’s power, so no new operator can overtake them, and a unit test checks every binary operator against them.

The two halves of property 2 catch this bug differently. The text check caught it one reprint late, because -a / 10 reprints as itself. The tree check catches it at once. The fuzzer was strengthened to compare trees after this bug, and the tree check found §4.4.

4.4 A numeric literal before a dot

Found at 150,000 mutants, and only by the tree check. Minimized: 1 . 2, printed as 1.2.

1 . 2 is three tokens and parses as component 2 of the integer 1. The checker refuses it, since an int has no components. The printer wrote the base and the dot with nothing between them, so 1.2 came back as one float token. The text was already a fixed point, which is why the text check could not see the bug: 1.2 reprints as 1.2. Precedence cannot fix this, because the lexer causes it, not the grammar. Only a digit after the dot triggers it: 1.x, 1.e5 and 1.5.0 already lex as a literal followed by ., which was checked rather than assumed. Even so, every numeric literal before . is now printed in parentheses, (1).2. One rule with no exceptions is easier to keep true than a rule about which characters come next.

4.5 The compile-time evaluator’s own recursion guard

Found not by a mutation count but by a corpus change: adding examples/report/ and examples/collect/ (#171, #176) shifted, for this suite’s fixed seed, which mutant the fixed iteration budget reaches. At iteration 2,896 it lands on a fixture recursive fib mutated to fib(1_000_000). Minimized: that call alone, folded at compile time.

compile-time.md §5 already gives the folder a step budget so a non-terminating computation cannot hang the compiler, and a separate recursion-depth cap (fold.rs’s DEPTH, 128) so a deep recursion spends that budget on steps rather than the compiler’s own native stack. The cap was never measured against what it exists to prevent. cargo test runs each test on its own thread at Rust’s default 2 MiB stack, well under a main thread’s 8 MiB, and in a debug build – uninlined, with every intermediate a stack slot – 128 levels of the evaluator’s own recursion overflows a 2 MiB stack before the depth check ever gets to refuse. fib(1_000_000) never needed the million: crossing roughly seventy nested calls was already enough, which the checker reached before the fuel budget did.

The property this breaks is the fuzzer’s first one, “nothing panics,” read too narrowly. A stack overflow is not a panic Rust’s catch_unwind can intercept – it aborts the process outright, which is why this finding could not be caught, reported and deduplicated the way the four in the printer were, and had to be bisected by hand instead (§5’s CANCHO_FUZZ_ITERATIONS, narrowed until one iteration count crashed and the one below it did not).

The fix lowers DEPTH to 32: measured empirically as under half of 68, the largest value that survives a debug build on a 2 MiB stack (72 is the smallest that does not), and still comfortably above every recursion depth a fixture here actually asks the evaluator to fold – fib(23) needs 23 (running_out_of_fuel_leaves_a_working_program). DEPTH’s own doc comment now carries this measurement, the same discipline FUEL already had.


5. Running it

In CI it is part of cargo test --workspace: 3,000 mutants, seed 0x5eed1e55, under a debug build, in about 15 s. That is a smoke test that fails the build on any finding. A long run is a local command:

CANCHO_FUZZ_ITERATIONS=150000 CANCHO_FUZZ_SEED=0x0badcafe \
    cargo test --release -p cancho --test fuzz -- --nocapture

Both variables take decimal or 0x hex. A value that does not parse fails the run instead of falling back to the default. The first long runs here found that out the hard way: 0x0badcafe was silently replaced by the default seed, and the “second seed” repeated the first exactly, down to the last count.

A finding is written to $TMPDIR/cancho-fuzz/finding-N.cho as one line of space-separated tokens. cancho print makes it readable. A tree mismatch is headed by // lines naming the first node that differs, before and after. CANCHO_FUZZ_REPLAY=<file> runs one saved finding through the same pipeline:

CANCHO_FUZZ_REPLAY=/tmp/cancho-fuzz/finding-0.cho \
    cargo test --release -p cancho --test fuzz a_saved_finding_replays

There is no automatic minimizer. The four bugs above were cut down by hand, and each became a unit test beside the code it fixed.


6. What this does not do

  • It does not run what it compiles. A mutant can loop forever, allocate without limit or write to the filesystem through its World. The backend’s behaviour is what differential.md checks, and it checks it on expressions chosen for their edges, not on random programs.
  • It is not coverage-guided (§1). Its reach is the corpus’s reach, plus what a few edits can do. A rule that no fixture comes near will rarely be exercised by a mutant.
  • It does not minimize. A tool for that would be worth having once findings arrive faster than one person can cut them down by hand.
  • The checker and the backend found nothing. Over about 41,000 checked mutants, 37,700 of which compiled, neither panicked and the backend never refused an accepted program. That result is only evidence up to the mutator’s reach, and that reach is §3’s 9%, not the whole language.

7. The suite

TestWhat it pins
mutants_never_crash_the_compiler (tests/fuzz.rs)§3: the three properties on 3,000 mutants, and the reach floors
a_saved_finding_replays (tests/fuzz.rs)§5: one finding, when CANCHO_FUZZ_REPLAY names it; nothing otherwise
a_one_part_tuple_keeps_its_comma (print.rs)§4.1
a_label_argument_keeps_its_escapes (print.rs)§4.2
prefix_and_postfix_bind_tighter_than_every_binary_operator (print.rs)§4.3, and that no operator binds as tightly as a prefix
a_numeric_literal_before_a_dot_keeps_its_parentheses (print.rs)§4.4
tests/reject/one_tuple.cho§4.1: (e,) is refused, by the rule () meets

The first page

Status: settled and built.

agent-errors.md §1 measured what this project’s machine audience had — 125 message shapes, 101 of them seen once, one error per invocation — and AGENTS.md was the answer. This is the third audience: a person deciding whether the project is worth their attention. It had never been measured either, and this time the instrument arrived on its own.


1. A reader wrote fifteen proposals, and eight of them were shipped

Someone outside the project read README.md and wrote a design proposal: fifteen numbered items, a phased roadmap, a comparison table, a success list. It is a careful document and its thesis is right — “a native language for programs that must prove what they are allowed to do” is a better one-line statement of this project than the README had.

Scored against what exists:

Proposals
Already built, with a design document behind each8
Partly built4
Not started2
Misframed — assumes a feature this language does not have1

The eight are capabilities as values, canonical effect rows, the unification of the two, lexical borrowing instead of a borrow checker, capability-mediated FFI, a native sandbox, program identity, and agent execution. Each has a document, a suite and a roadmap row.

The proposal’s phases 1 through 4 are done. It opens at phase 1.

1.1 Which is not the reader’s mistake

The document is evidence about the README, not about its author. A proposal that re-proposes eight shipped features is a measurement of what the first page failed to say, and it is a better one than any number this project could have invented, because nobody wrote it to be one.


2. Three words the README never uses

Counted over 572 lines:

WordOccurrences
sandbox0
audit, evidence0
agent, in the execution sense0 — the two hits are about AGENTS.md, the writing guide
lex-os1, inside a parenthesis about narrowing
program identity, ids1

Three of the proposal’s fifteen items — the sandbox, execution evidence and agent execution — live in lex-os, and the words for them do not appear on this project’s first page at all. A reader could not have known. All three were re-proposed.

That is the whole finding, and it is not about tone or length. The README describes a language and the project is a language and its runtime, and only one of those was on the page.

2.1 And the shape said something else again

SectionLinesShare
Performance expectation18031%
The language in one page13223%
What exists7012%
Try it6712%
Everything else12322%

The largest section by far was about the thing this project is weakest at, and “Not a usable language yet” was in the third paragraph. What exists was one 70-line block of prose naming some thirty features in sentences.

None of those lines is wrong. overflow-cost.md, aliasing.md and gpu.md are all measured and all honest, and the honesty is the point of them. But a first page is read in the order it is written, and this one spent a third of itself on the gap to C before it had said what the project is for.


3. What the rewrite is for

One job: a reader who stops after the first screen should come away with the thesis and the fact that it is built and enforced twice.

Four changes, and no new claims:

  1. A map of the ecosystem, which did not exist. §4.
  2. What exists as a table, one row per capability, each linking the document that settled it — so the answer to “is X built?” is a lookup rather than a reading.
  3. The performance section compressed to its numbers and its one structural cause, with the essays staying in the documents that measured them. aliasing.md §6, gpu.md §2.3 and benchmarks-game.md already carry every argument the 180 lines made; what the first page owes a reader is the number and the reason, not the derivation.
  4. The status line says what is usable, rather than only what is not. Both halves are true and only one was there.

What it does not do: soften anything. The gap to C stays on the page, whatever it currently measures — 1.17×–2.58× when this was written, benchmarks-game.md §8’s own narrower, less uniform range since --backend llvm became the default; “not a usable language” stays on the page; the three-word count above is why it moved, not why it shrank.


4. Where cancho sits, which nothing said before

Three repositories, one idea, and two of them are wired together:

                    lex-lang
              the high-level language
        16 crates: syntax, ast, types, store,
           vcs, jit, lsp, bytecode, trace
                        │
                        │  lex-types::trust::Grant
                        │  lex_syntax → lex_ast → lex_types
                        ▼
                     lex-os
            the autonomous-agent runtime
      manifest + grant → static check (lex-os-check)
              → perimeter → supervisor → audit


                     cancho
                     this repo
        a second, native language, same worldview
         — and no code edge to either of them —

lex-os takes its Grant from lex-lang’s lex-types and runs the agent’s programs through the real Lex front end, so one declaration is enforced twice: statically by lex-os-check before the program loads, and at run time by the supervisor.

cancho shares that worldview and shares no code. It mentions lex-os in exactly two comments, and lex-os does not depend on it at all. Saying so is the point: the proposal assumed an integration that does not exist, and a first page that implied one would have been the reason.

4.1 The two joins, both named and both gated

JoinState
cancho code in lex-vcs81% of that crate is already language-agnostic. Gated on a plateau in the effect vocabulary, not on a feature — hash-stability.md
cancho code under a lex-os grantMeasured in under-a-grant.md: not a compiler integration. authority --output json is already the right interface and the grant’s filesystem dimension works through it; network and exec do not, because both are libc

The second is worth writing down as a row rather than a paragraph, because it is the one place where this project’s thesis and its runtime would meet in code rather than in agreement.


5. The suite

TestShows
every_documentation_link_resolvesevery local link in README.md, AGENTS.md and docs/ points at a file that exists — run by hand at the end of every slice until now, which is the wrong place for it
the_readme_commands_still_workthe commands the first page shows a reader are commands the compiler still has

Related work

Status: written, and overdue.

Nothing in cancho is a new idea on its own. Linear types, regions, effect rows and capabilities each have decades of prior work, and at least one language — Austral — reached most of this project’s core before it did. Until this document, the repository credited four of the projects it drew on in passing and did not mention Austral at all. An outside audit pointed that out, and it was right.

Three parts: what was taken, traced to the document that took it; the closest relatives, and what differs; and the competitor for the use case this project is actually for. Where cancho differs, this says so in one line, because the differences are the only part a reader could not have found elsewhere.


1. What was taken

FromWhatWhere it landed
LexEffects in the type system, effect declarations as a function’s contract, examples as tests, content-addressed identityThe whole project — linearity-and-effects.md, canonical-ast.md
CycloneRegions as lexical scopes, with the checker confirming nothing escapes its blocklinearity-and-effects.md §5 says it plainly: “Cyclone’s idea with Rust’s inference deleted”
KokaEffects as a row — a set of labels on the arrow, not a monad in the return typelinearity-and-effects.md §2 and §7. cancho keeps the row and drops the rest: no handlers, and no row polymorphism (effect-polymorphism.md)
RustOwnership as move, & and &mut-shaped borrows, monomorphised genericsThroughout. What it declines is the borrow checker: lifetimes are lexical here, never inferred (aliasing.md)
ValeGenerational references — a handle that is a plain index plus a generation, checked on every use against the arena that issued itsharing.md §3’s Gen, which is this idea built as a library. That document now says so
Zigdefer, and the case against hidden control flowdefer.md, including why Zig’s errdefer is refused here
The object-capability model — E, and Mark Miller’s Robust CompositionNo ambient authority: a function can do only what it was handed, and authority is a value that can be attenuated but not forgedWorld, split, and one-way narrow — authority.md
CapsicumThat a Unix process can drop into a mode where it holds only the descriptors it was givenThe same instinct at the language level: release destroys authority a program will not use, and nothing re-creates it

2. The closest relatives

These are the projects a reader should compare cancho with, and in several cases they got there first.

Austral is the nearest. Linear types; capabilities as linear values, with a root capability handed to the entry point exactly as cancho hands main a World; borrowing through lexical regions; no borrow checker in Rust’s sense; and a specification deliberately small enough to hold in one’s head. Most of cancho’s core is in Austral, and Austral had it first. What cancho adds: the authority a function holds is also stated as an exact effect row, checked in both directions, which is what lets cancho authority answer what can this program reach without reading any body — and a canonical, content-addressed AST underneath.

Effekt treats effects as capabilities: an effect handler is passed to the code that uses it, and is second-class so it cannot escape its scope. That is the effects-literature form of this project’s rule that an effect is a borrowed capability, arrived at from the other direction. What differs: Effekt’s capabilities are handlers, with the control-flow power that implies; cancho has no handlers, and its capabilities stand for authority over the outside world rather than for control effects.

Pony has reference capabilities — iso, val, ref and others — that make data races a type error in an actor model. What differs: the problem, mostly. Pony’s capabilities govern aliasing between concurrent actors; cancho’s govern authority, and it has no concurrency yet. The two share the word val with different meanings — in Pony, deeply immutable and shareable; here, copyable and discardable — which is worth knowing before reading either.

Hylo (formerly Val) gets memory safety from mutable value semantics, with references second-class so they cannot be stored. What differs: cancho takes the other road — references are ordinary values, and a lexical region is what bounds them.

Linear Haskell, ATS, Mezzo and Vault are the linear- and typestate-types lineage these languages share, and anyone asking where res and val come from should start there.


3. The competitor

For this project’s actual use case — running code you did not write, with authority you chose — the incumbent is not Rust or C. It is WebAssembly with WASI: any source language, capability-style handles, and a sandbox enforced by the runtime.

cancho has to win on what that design cannot do, and it is one axis:

WASIcancho
When authority is knownAt run time, by tryingBefore execution, from the program’s text
GranularityPreopened directories and socketsA path prefix today; a host per net.md
Runtime costA Wasm runtime and its sandboxNone — a native binary, the checks erased at compile time
LanguageAnycancho only

The honest summary is the audit’s: know what a native binary can touch before you run it, at no runtime cost. That is a narrower claim than WASI makes, and a stronger one where it applies — and under lex-os it is defence in depth rather than a replacement: a static proof before load, and a supervisor while it runs.

It is also a claim with a hole in it today, stated where it matters: under-a-grant.md shows that a program reaching Ffi("libc") can do anything libc can, and the report now says so by failing closed.


4. What this does not claim

  • Not a survey. These are the projects a reader of this repository is likely to know or should know, not the literature.
  • Not that cancho is better than any of them. Austral is further along as a language, Koka and Effekt as effect systems, Pony at concurrency, WASI at deployment. The claim is only that the combination — linear ownership, capability effects, an exact row and a native binary — is the one this project is testing.

Self-hosting: the spike, and the decision it was for

bootstrap.md named the reason the v0 compiler is written in Rust (“Cranelift is a Rust library”) and, in the same breath, named the cheap experiment that would tell this project whether that reason is load-bearing forever: “port the lex-ast/lex-vcs canonical forms and check byte-identical OpId/SigId/StageId over the existing op-log corpus.” ROADMAP.md’s “Beyond” section repeats the same line. This document runs that experiment, on the corpus this sandbox actually has rather than the one the original note assumed, and answers the question bootstrap.md deferred: is self-hosting worth planning next?

No — not yet, and not because anything found here is a hard blocker. Every concrete thing checked came back feasible. What is missing is an asker (AGENTS.md §7): no cancho program needs to compute a lex-lang SigId, and no consumer is waiting on a cancho-language compiler. This is the same discipline docs/vcs.md §8 already applied to whole-function merge and typed issues — “has no asker in this repository yet… not started” — applied here to a much larger piece of unrequested work.


1. Two different questions this note does not conflate

“Self-host the toolchain” and “make cancho-vcs’s OpId match lex-vcs’s” sound like the same idea and are not.

docs/vcs.md §5 already answered the second one, on contact, while building cancho-vcs: OpId is BLAKE3, not SHA-256, deliberately — “matching lex-vcs’s algorithm would have meant a second hash dependency for no reason but appearance.” cancho-vcs hashes cancho programs; a cancho program and a Lex program are never the same bytes, so there was never a reason for their content hashes to agree, and none was sought.

What bootstrap.md actually asks is the first, larger question: could cancho itself — the language — eventually be the implementation language of its own compiler (today’s cancho-syntax/cancho-ir/ cancho-codegen*, ~29,400 lines of Rust, counted below), the way a self-hosted compiler is normally understood. That is what the rest of this document measures.


2. The corpus this sandbox actually has

ROADMAP.md’s line says “the existing ~136k-op corpus.” No such corpus is checked into any of the four repositories this session can reach — lex-lang, cancho, lex-os, lex-gpu — confirmed by searching each for an op-log store, not assumed from the absence of a memory of one. Whatever corpus that line originally meant lives outside this sandbox (a production op-log from real use, most likely), and this spike does not have access to it.

What does exist, real and checked into lex-lang itself: 31 real .lex files (examples/, plus the fuzz seed corpus under fuzz/corpus/), two of which are deliberately malformed fuzz seeds that do not parse (expected type expression, got Some(Fn) — a fuzzer seed testing the parser’s own error path, not a program). The other 29 parse into 168 real Stages (fn/type declarations) through the real, unmodified lex-syntax/lex-ast pipeline.

This changes what is actually testable. Diffing a cancho hash against a lex-lang hash for the same corpus was never going to say anything — different languages, different ASTs, no reason to expect agreement. What is testable, and what the “byte-identical” phrasing in bootstrap.md is actually insurance against, is narrower and more useful: is the canonicalization algorithm — RFC-8785-flavored JSON, SHA-256, the specific SigId/StageId composition — specified clearly enough, in lex-ast’s own source and doc comments, that an independent implementation reproduces it exactly? A self-hosted port would be exactly such an independent implementation, in a different language; if the algorithm cannot be reproduced byte-for-byte from its own documented spec in the same language, porting it to a different one has no chance.


3. The experiment

A from-scratch Rust reimplementation of canon_json’s writer (UTF-8, no whitespace, object keys sorted byte-wise, serde_json’s default number rendering, JSON string escaping including \u00XX for control bytes) and of sig_id/stage_id’s composition (read from lex-ast/src/lib.rs’s own doc comments: “SigId: SHA-256 over canonical_json({name, input_types, output_type, effects})”, “StageId = SHA-256(structural_sig_hash || implementation_hash)”) — written without calling lex_ast::canon_json or lex_ast::sig_id itself, only depending on the real lex_ast::Stage type so both sides walk the identical parsed data. Run over every real Stage the 29 parseable .lex files produce, diffed against the real crate’s own sig_id(stage) / stage_id(stage).

corpus: 31 real .lex files
parse failure: fuzz/corpus/parser/seed_02.lex (deliberately malformed)
parse failure: fuzz/corpus/type_checker/seed_02.lex (deliberately malformed)
parse failures (excluded): 2
stages examined: 168
SigId byte-identical: 168/168
StageId byte-identical: 168/168
RESULT: every SigId and StageId reproduced byte-for-byte.

168 for 168. The canonicalization algorithm is specified precisely enough, in the source it already has, to reproduce without the original code — the property a port needs and the property bootstrap.md’s “byte-identical” phrasing was actually checking for. This is the one part of the spike with a clean, positive, falsifiable result.


4. What else a self-hosted toolchain would need, checked rather than assumed

Three more questions, each answered against this repository’s own real code rather than guessed:

Does cancho support what a hand-written compiler needs structurally? Recursion — fn fib(n: int) -> [] int { if n < 2 { return n; } return fib(n - 1) + fib(n - 2); }, built and run in this sandbox, returns 55 for fib(10). Heap-allocated recursive data (an AST node needs a Boxed child) — already proven, not hypothetical: examples/tree.cho is a binary search tree, exactly this shape, already in the corpus docs/README.md calls “why a language needs a heap at all.” Neither is a new risk.

What is the actual size of what would move? The Rust crates that are the compiler today, real line counts:

CrateLines
cancho-syntax5,318
cancho-ir12,282
cancho-codegen3,397
cancho-codegen-llvm4,615
cancho-types704
cancho-id1,909
cancho (CLI)1,143
Total29,368

That is the scale of a full port, not a slice — an order of magnitude past anything this repository has ported in one piece before (§2 of docs/vcs.md measured lex-vcs’s diff-facing code at 1,477 lines as the largest prior comparison point).

Does bootstrap.md’s own Cranelift blocker still hold? Yes, and it is not a soft one: Cranelift and LLVM are libraries with Rust (and C++) APIs — builder patterns, trait objects, complex owned types crossing by value — none of which a foreign call can cross today (docs/reach.md §3: “a foreign result is int, bool or ()”). Reaching either from .cho directly is not a missing convenience, it is outside what extern fn can express at all. But docs/reach.md §3.4’s own note that fork is already reachable through Ffi("libc") points at the actual escape hatch a self-hosted backend would take: emit textual assembly (a [byte] slice, no different from any other output this language already writes) and shell out to the system as/ld the way an early self-hosting C compiler did, rather than reimplement a code generator. Untried here — a real slice of work in its own right — but not blocked by anything this language currently lacks.


5. The decision

Nothing this spike checked is a wall. The hashing algorithm ports byte-for-byte from its own spec (§3). Recursion and heap-allocated trees, the structural minimum, already work (§4). The Cranelift/LLVM dependency that keeps v0 in Rust has a named way around it — shelling out to as/ld — that does not require reimplementing a code generator (§4). What is missing is scale (29,368 real lines, §4) and, more to the point, an asker: nothing in this repository today needs a cancho compiler written in cancho, and AGENTS.md §7’s rule against building what nothing asks for applies at this size exactly as it does at docs/vcs.md’s smaller ones.

Decision (as first written): stay on Rust for the compiler. Revisit this document, rather than re-running the spike, the day a concrete asker exists. Nothing in this document’s findings would need to change before that day; only the “no asker yet” line would.

Update: there is an asker. The project’s owner asks for a cancho compiler written in cancho, for the reason that a language that can build itself is the strongest proof of the language (2026-10-07). That is the “concrete asker” this section waited for, and AGENTS.md §7’s rule against building what nothing asks for no longer applies to it. What stands: the Rust compiler is the oracle at every stage (the staged port of section 6 compares against it, and a stage is not done until it agrees), and nothing is switched over until the last stage, a fixed point where the compiler written in cancho compiles itself to the same output. The aim is the whole bootstrap: the checker complete, IR generation, a textual backend (LLVM IR or assembly, with the system tools shelled out to as section 4 describes), and the fixed point.


6. The staged port (epic #295)

§5 decided to stay on Rust and nothing below reopens it. What §3 and §4 could not give was a measured size, so the question “how big is a compiler in this language” has been run one stage at a time, the Rust compiler the oracle at every stage and any stage free to stop the effort by failing. This section records what the stages found, in place, the way this document corrects its own claims.

StageWritten in canchoOracleResult
1. Lexerexamples/selfhost/lexcore.cho (a module) and lexer.choexamples/dump_tokens.rs in cancho-syntaxSame token stream and the same refusals on every program in the repository (614 files)
2. Parserexamples/selfhost/parser.choexamples/dump_ast.rs in cancho-syntaxSame syntax tree, node for node and span for span, or the same refusal (rule and span), on the same 614 files, including its own source, and on a fuzz corpus (below) of 52,272 cases, 51,911 of them comparable and all identical, 29,097 of those refusals
3a. The treeexamples/selfhost/ast.cho (a module, the parser) and parser.cho (a walk that prints the tree)the same dump_ast.rsThe parser builds the tree in flat tables and the listing is produced by walking it; the same 621 programs and 52,314 fuzz cases (6 seeds), all comparable ones identical, 29,055 of them refusals
3b. Declarations (the first half of the checker, complete)examples/selfhost/pass1.cho, foreign.cho and checker.cho, over a generated tables.cho; programs of several filescheck_declarations (cancho-ir), the first half of the Rust checkerSame answer, OK or the first refusal’s rule and span, on every program of the repository alone (633) and with the whole standard library parsed with them (633, 464 of them OK), on 169 targeted cases, and on 62,892 fuzz cases (51,534 alone, 11,358 with the library): 62,434 identical, 37,967 of them refusals, none different; 458 are not UTF-8; nothing is skipped
3c. Bodies, function by function (scalar functions so far)examples/selfhost/body.cho (and bodies.cho)check_bodies (cancho-ir), the Rust checker’s body check, answered per functionFirst slice. The port answers OK, a refusal, or SKIP for each function; every function it answers is the Rust answer: 644 repository programs alone (278 OK bodies and 11 refusals among those it answers; 898 function answers skipped), the library’s 789 functions with one program (271 OK, 518 skipped), 304 targeted cases, and 57,579 fuzz cases (51,588 alone, 5,991 with the library): 57,166 identical, none different; 413 are not UTF-8
3d. References and slices of scalarsexamples/selfhost/types.cho and body.chocheck_bodiesSecond slice. Every function the port answers is the Rust answer: 644 repository programs, the library’s 789 functions with one program (437 verified OK, 55%, from 34%), 408 targeted cases (104 for references, regions and slices), and 39,826 fuzz cases (35,352 alone, 4,474 with the library): 39,547 identical, none different; 279 are not UTF-8
3e-1. Structs of scalarsbody.cho (types in types.cho)check_bodiesThird slice. Every function the port answers is the Rust answer: 81 targeted cases for struct literals, field reads and writes (539 in all), 8,000 fuzz cases alone and 4,200 with the library, 1,165 library cases: none different
3e-2. Enums of scalars and matchbody.chocheck_bodiesFourth slice. Every function the port answers is the Rust answer: 85 targeted cases for enum values, match and its errors (624 in all, 5 cross-module in the several-files test), ~11,000 fuzz cases and 1,269 library cases: none different; the library’s verified bodies are 461 of 814 (57%)
3e-3. Generic functionsbody.cho (type variables and parameters in types.cho)check_bodiesFifth slice. Every function the port answers is the Rust answer: 88 targeted cases for type parameters, inference at a call and ambiguous-type (712 in all, 4 cross-module in the several-files test), 15,774 fuzz cases (11,220 alone, 4,554 with the library) and 1,359 library cases: none different; the library’s verified bodies stay at 461 of 814
3e-4. borrow and region blocksbody.cho (block regions in types.cho)check_bodiesSixth slice. Every function the port answers is the Rust answer: 1,271 targeted body cases (456 of them for borrows, regions and their variants), 2 cross-module in the several-files test, 16,000 fuzz cases (11,463 alone, 4,726 with the library) and 1,475 library cases: none different; the library’s verified bodies stay at 461 of 814
3e-5. Builtins that perform nothing, and prelude typesprims.cho, over a generated builtins.chocheck_bodiesSeventh slice. Every function the port answers is the Rust answer: 1,376 targeted body cases (105 new, among them the ones that must be answered and not skipped), ~19,000 fuzz cases and 2,033 library cases: none different; the library’s verified bodies go from 461 to 590 of 814 (72%)
3e-6. Effect rowseffects.chocheck_bodiesEighth slice. Every function the port answers is the Rust answer: 1,443 targeted body cases (67 new, with the strict ones that must be answered), ~19,500 fuzz cases (13,434 alone, 6,044 with the library) and 2,156 library cases: none different; the library’s verified bodies are 625 of 855 (73%)
3e-7. The rest of the bodies: arena allocation (alloc, alloc_slice), the heap builtins (box_slice, contents), generic types, tuples, threads, then linearity, effects with arguments, regionsnot startedcheck_bodies
4a. Writing LLVM IR for functions of int and bool: literals, the arithmetic and bitwise operators with their traps, comparisons, calls, let, assignment, returnexamples/selfhost/emit.cho, written by the checker as it walks (compile.cho)the Rust compiler, by what the built programs doFirst slice of the backend. 74 programs built both ways and run: the same exit status, or a trap in both (18 trap); none different
4b. Control flow: if with else, while, && and `` (the right side only when the left has not decided it)emit.cho and body.cho
4c. World, release and a real main: main(world: World) calls the program, a World passed between functions, release(world)emit.cho, body.cho, types.cho, driver.chothe Rust compiler, by what the built programs doThird slice of the backend. 254 builds run and compared: every one of the 123 programs through the old run convention and through a real main, and 8 more with a main of their own (a World passed on, release used as a value): the same exit status or a trap in both (55 trap); none different
4d. Output: split(world) and its parts, a borrow of the Io, putchar, references to whole numbers and booleans, and what an owned capability dischargesemit.cho, body.cho, exprs.cho, effects.chothe Rust compiler, by what the built programs do and writeFourth slice of the backend. 268 builds run and compared, 14 of them print (characters, decimal numbers by recursion, loops, if chains, nested helpers, sequential borrows, a trap after output): the same exit status or trap and the same bytes on the standard output (56 trap); none different
4e. Strings and slices: a literal as a slice of bytes, len, s[i] with its bound, write_bytes, int_of/byte_of, a slice passed, kept and reassigned, a store through *r and s[i]emit.cho, body.cho, exprs.cho, scope.chothe Rust compiler, by what the built programs do and writeFifth slice of the backend. 292 builds run and compared, 24 of them new, 60 trap; none different
4f. Structs of whole numbers, bytes and booleans: a literal, a field read, a copy, passed and returned, a field written through a unique reference, *r of oneemit.cho, body.cho, exprs.cho, scope.chothe Rust compiler, by what the built programs doSixth slice of the backend. 312 builds run and compared, 20 of them new, 63 trap; none different
4g. Enums and match on them: a variant with payloads, match by value and behind a reference (payloads are references, written through), _ arms, nestedemit.cho, body.cho, exprs.cho, scope.chothe Rust compiler, by what the built programs doSeventh slice of the backend. 332 builds run and compared, 20 of them new, 64 trap; none different
4h. Arenas: region and alloc_slice[a](n, fill) of whole numbers, bytes and booleans, in the checker (the arena in scope, the count, the fill a value that copies, the slice cannot leave) and in the codeemit.cho, body.cho, exprs.cho, scope.cho, types.cho, driver.chothe Rust compiler: the checker by answers (1,945 cases), the code by what the built programs doEighth slice of the backend, and a checker rule. 352 builds run and compared, 20 of them new, 71 trap; none different. The checker’s 1,945 cases are identical, 27 of them new
4i. The heap: box_slice, contents, unbox_slice of whole numbers, bytes and booleans, with a Heap borrowed and passed (compile mode only: the checker still answers SKIP for a function that has one)emit.cho, exprs.cho, types.cho, scope.cho, effects.chothe Rust compiler, by what the built programs doNinth slice of the backend. 367 builds run and compared, 15 of them new, 76 trap; none different. The checker’s 1,945 cases are unchanged
4j. static tables: static name: [T] { .. alloc_slice[static](n, fill) .. return t; } of whole numbers, bytes and booleans, and the name used as a shared reference to the slice, in the checker (20 new cases) and in the codeemit.cho, body.cho, exprs.cho, scope.cho, driver.chothe Rust compiler: the checker by answers (1,965 cases), the code by what the built programs doTenth slice of the backend, and a checker rule. 382 builds run and compared, 15 of them new, 78 trap; none different. The library’s skipped functions go from 641 to 612
4k. Functions that return a slice, and sub-slices s[a..b] of whole numbers, bytes and booleansemit.cho, body.cho, exprs.cho, scope.chothe Rust compiler, by what the built programs doEleventh slice of the backend. 404 builds run and compared, 22 of them new, 84 trap; none different. The census: 529 of the compiler’s own 548 functions get code (488 before)
4l. Structs and enums whose fields and payloads are structs, enums or a Box[[T]] (and res ones, std.buffer’s Buffer), destructuring a struct of the file, a field of a res type read through a reference as a borrow, byte parameters and results, and the builtins wrapping_*, copy_into, index_of_byte, getchar, write_erremit.cho, body.cho, exprs.cho, scope.cho, types.chothe Rust compiler, by what the built programs doTwelfth slice of the backend. 424 builds run and compared, 20 of them new, 85 trap; none different. The census: 558 of the compiler’s own 558 functions get code, and all 39 of std.bytes/buffer/io
4m. Several files, and the bootstrap: a function is named by its node (@lexs_<n>), so two modules may have a function of one name; what the code is not written for is left out and a comment says how many; compiler.cho is the compiler for a program of several filesdriver.cho, emit.cho, body.cho, compiler.cho, bootstrap.rsthe compiler against itself: built by Rust, it compiles its own source to IR, clang builds that, and the result compiles the same source to the same IRThe fixed point. 100,409 lines of IR, 637 functions, byte for byte the same from the compiler Rust built and from the one it built; 55 functions left out, all of std.math, which nothing calls. The checker built by the second compiler agrees with the Rust oracle on 1,980 of 1,980 cases
4n. Capabilities and handles in the code: a target (TARGET darwin/linux in front of the input), runtime.ll (paths checked against the prefix a capability was narrowed to, open_*, file_write/file_read/file_close, exec_spawn, child_wait, arg_count/arg), prelude enums and handles as values, narrow; and builder.cho, the compiler that runs clang itself through its Execruntime.ll, runtime.cho, emit.cho, exprs.cho, types.cho, scope.cho, driver.cho, builder.chothe Rust compiler: the programs by what they do (433 builds, 89 trap), the builder by building itselfThe compiler builds a program with no help. The builder built by Rust builds itself, the result builds itself again, and the three write the same 3.2 MB of IR (equal sha256). The census: 614 of 614 functions (the compiler, its runtime and the builder) and 39 of 39 in std.bytes/buffer/io
4o. static tables computed while the program is compiled: consteval.cho, an interpreter for the bodies that build a table (loops, conditionals, the operators, table reads and writes, string literals, len/int_of/byte_of, earlier statics), whose answer is data of the module; a body it cannot run (a call to a function of the program, a trap, too much room) still runs when the program startsconsteval.cho, body.cho, ast.cho, driver.chothe Rust compiler, by what the built programs doA difference closed. 442 builds run and compared (9 new), 89 trap; none different. The census: 644 of 644
4p. One value on the heap and in an arena: Box[T] of any type the code holds, box, unbox, contents of a box, alloc[a](v), and so a type that holds itself through a box (enum List { Empty, Cons(int, Box[List]) })types.cho, emit.cho, exprs.cho, scope.chothe Rust compiler, by what the built programs doThirteenth slice of the backend. 454 builds run and compared (12 new), 89 trap; none different. Of the 115 programs of tests/accept, 55 build and give what their headers say (49 before)
4q. Tuples of two or three parts as values ((int, bool), (Box[int], int), a tuple in a struct or a payload), .0 and let (a, b) = e;; narrow(fs, "a", "b") as a tuple of capabilities; the one-shot fs_read and fs_writetypes.cho, emit.cho, exprs.cho, body.cho, scope.cho, runtime.llthe Rust compiler, by what the built programs doFourteenth slice of the backend. 467 builds run and compared (13 new), 90 trap; none different. Of the 115 programs of tests/accept, 66 build and give what their headers say (55 before)
4r. Generics, by monomorphization: a call to a generic function asks (instances.cho) for the function at the types its arguments solved, each type travelling as a code from one function’s table to another’s; the driver compiles the instances after the other functions, each with its parameters standing for those types, and a call from one can ask for more; literals of generic structs and enums (Pair { .. }, Option::Some(5), Option::None learning its type from a let annotation, a return or a call’s other arguments)instances.cho, types.cho, scope.cho, exprs.cho, body.cho, driver.cho, emit.chothe Rust compiler, by what the built programs doA difference closed; up to two type parameters and four fields or payloads, none that is a reference; a var x = Option::None that a later statement types has no code yet
4s. float and f32: literals read to the last bit with whole numbers only (floats.cho: the digits as a big number, a power of five and a shift, one rounding, ties to even, as f64::from_str and f32::from_str do), + - * / and the ordered comparisons, negation, the thirteen builtins (float_of, truncate with its three traps, bits_of, is_nan, sqrt, the f32 conversions and bit casts), floats in locals, parameters, results, structs, slices, arenas and static tables (run when the program starts)floats.cho, floatcode.cho, exprs.cho, emit.cho, types.cho, body.cho, scope.chothe Rust compiler, by what the built programs doA difference closed. 577 builds run and compared (135 new: 23 programs, 32 float and 17 f32 literals, and the negative of each float one), 94 trap; none different. tests/accept: 76 of 115
4t. Threads: spawn(payload, f) and join(t) on real OS threads (pthread_create with the function as the start routine and the payload as its one pointer; pthread_join and the cell the result comes back in), a function used as a value (let w = worker;) and called through the name (w(5)), payloads and results that are a whole number, a byte, a boolean, a handle, a reference or a capabilitythreadcode.cho, exprs.cho, types.cho, scope.cho, emit.cho, fold.cho (split out of exprs.cho)the Rust compiler, by what the built programs doA difference closed. 9 thread programs added to the compile differential, none different. tests/accept: 82 of 115 (the spawn programs that stop do so at the clock, the network, usleep and pthread_self, not at threads)
4u. Foreign functions: a call of an extern fn crosses as the Rust backend sends it (a c_ptr as a pointer, an int as 64 bits, a byte or bool as 8, a float as a double, a borrowed [byte] as its pointer and length, a borrowed capability as nothing) and comes back as a pointer, 64 bits, a c_int made 64 by sign, or a boolean; c_ptr values, null_ptr(), == and != on them; the call spells its own function type, so a program’s extern fn write or memchr need not agree with the runtime’s declarationexterncode.cho, codes.cho (the builtin numbers, out of exprs.cho), exprs.cho, driver.chothe Rust compiler, by what the built programs doA difference closed. 4 foreign programs added to the compile differential, none different. tests/accept: 90 of 115
4v. Sockets and the clock: bind, connect, listen, accept (edition 2), tcp_listen, tcp_connect, tcp_accept, conn_read, conn_write, conn_nonblocking, listener_nonblocking, conn_nodelay, conn_close, listener_close (edition 5) with their answers (Listening, Dialed, Accepted, Received, Sent), and clock_ms, clock_unix_ms; the host and the port checked against the bound a Net was narrowed to, trapping when they differ. The support is C (runtime/net.c) turned into LLVM IR for each target by runtime/make.sh and printed only into a program that uses itnetcode.cho, runtime/net.c, runtime/net_linux.ll, runtime/net_darwin.ll, runtime.cho, exprs.cho, codes.cho, driver.chothe Rust compiler, by what the built programs doA difference closed, on both systems. 4 programs added (the edition 2 integers, a connection made, accepted, written and read inside the process, one refused, the clocks), none different. tests/accept: 94 of 115 on macOS arm64 and on Linux x86-64 (gram), the same 94
4w. Pipes: pipe_open (a socket pair, close-on-exec, SO_NOSIGPIPE on Darwin) with its four-leaf answer Piped, pipe_read, pipe_write, pipe_close, child_end_close, pipe_nonblocking, which are the connection verbs on the same support; a child’s streams as Stdio::Pipenetcode.cho, runtime/net.c, exprs.cho, codes.chothe Rust compiler, by what the built programs doA difference closed. 2 programs added (a child’s output read, and a child written to and read from: cat), none different. tests/accept: 95 of 115
4x. Directory handles and listings: open_dir, dir_enter, dir_open_read/new/append, dir_rename, dir_rename_new, dir_remove, dir_sync, dir_close, dir_list, dir_next, dir_list_close, dir_stat, dir_mode, dir_own_mode, each name one path component checked first; the support is C (runtime/dirs.c) whose only per-target text is the array of constants the compiler writes (platform.cho: open flags, AT_FDCWD, dirent and stat offsets, errno values) for macOS, Linux x86-64 and Linux aarch64 (TARGET linux aarch64)platform.cho, runtime/dirs.c, netcode.cho, driver.chothe Rust compiler, by what the built programs doA difference closed. A program that makes, measures, renames, removes through a handle compared both ways; the two accept programs pass. tests/accept: 97 of 115
4y. defer: the expressions wait on a stack and are written out where their block ends and at every return (after its value is read), innermost and last-written first, as the Rust lowering expands thembody.chothe Rust compiler, by what the built programs doA difference closed. 4 programs in the compile differential (the accept program, a loop, two frames, a res closed on both paths). tests/accept: 98 of 115
5a. The builtins on words: load_le64, byte_mask64, trailing_zeros, leading_zeros, popcount, mul_wide, add_carry, sub_borrow, value_barrier, copy_within, trap, with the bounds checks that trapwordcode.cho, codes.cho, exprs.chothe Rust compiler, by what the built programs doA difference closed. 11 programs added, among them the traps; none different. tests/accept: 104 of 115
5b. Capabilities held in structs (a place in the aggregate, {}, and no value), fork_heap and fork_clock (a new capability, nothing in the code)emit.cho, scope.cho, exprs.chothe Rust compiler, by what the built programs doA difference closed. The four accept programs that hold a heap or capabilities in a struct, and the two that fork, are compared both ways. tests/accept: 109 of 115
5c. References held in structs, enums and tuples: a reference to a value is a pointer, a run of bytes a pointer and a length ({ ptr, i64 }), put in and taken out like any field; the region such a field has is any_region, which outlives and is outlived by alltypes.cho, emit.cho, exprs.cho, body.cho, scope.chothe Rust compiler, by what the built programs doA difference closed. 5 programs added (a struct, an enum with a match, a tuple, through a borrow, destructured). tests/accept: 110 of 115
5e. The hardware AES and GHASH builtins (hw_aes_gcm, aes_encrypt_block, aes_ctr32, ghash_update, ghash_powers, gcm_tag, gcm_tag_diff) and the SHA-256 ones (hw_sha256, sha256_blocks, sha256_block_words): the functions are the text cancho-codegen-llvm writes for each target (runtime/crypto_*.ll, dumped and checked by a test of that crate), carried by a module that calls them; the call sites check lengths and trap as the Rust backend does (cryptocode.cho)cryptocode.cho, runtime/crypto_*.ll, ex.cho (split out of exprs.cho)the Rust compiler, by what the built programs doA difference closed on macOS. 4 programs added; of the 105 programs of examples/ and tests/programs/ the Rust compiler builds, 69 now build and give the same output (52 before). Not run on Linux yet
5f. The poller (epoll, kqueue: poller_new, poller_add_*, poller_modify, poller_remove, poller_wait, poller_close), the hand-over of a connection (conn_detach, conn_attach, with the table of epochs), conn_peer, conn_connect_status, tcp_connect_start, flush_out, read_bytes; runs of structs, enums and tuples (the offset is the size of the element), a reference returned, generic literals of up to 12 fields, a variant given as an argument typed by what the callee wants or by the other arguments; exec_spawn_in, a child’s pidfd on Linux, and the signal set and closefrom a spawn hasruntime/net.c, runtime/os_*.ll, netcode.cho, emit.cho, exprs.cho, lookup.chothe Rust compiler, by what the built programs doA difference closed. A program with a poller, a hand-over, a peer and a connect status, and one with flush_out and read_bytes, compared both ways; runs of structs. Of the 106 programs of examples/ and tests/programs/ that can be compared, 86 build and give the same output (69 before)
5g. A reached function the code is not written for is a refusal (SKIP, at its span); cancho build --compiler <exe>; functions held by their type (f: fn(int) -> [] int, up to three parameters) in a parameter or a name, called through the pointer; atoms.cho split from exprs.chodriver.cho, types.cho, scope.cho, exprs.cho, atoms.cho, emit.cho, crates/cancho/src/selfhost_build.rsthe Rust compiler, by what the built programs doA difference closed. tests/accept 111 of 115 build and agree (110 before); the programs of examples/ and tests/programs/ that can be compared stay at 86 of 106
5h. Operations on an open file (file_pwrite, file_pread, file_sync, file_truncate, file_size, file_lock) and on a path (fs_remove, fs_rename); the signal claim (signals_watch, signals_pending, signals_close, with the thread count spawn/join keep); tuples of up to eight parts; a reference or a capability a function lends and gives back; cancho build --compiler in a project; filecode.choruntime.ll, runtime/signals.c, filecode.cho, netcode.cho, types.cho, exprs.cho, crates/cancho/src/project.rsthe Rust compiler, by what the built programs do, and the signals conformance with the compiler in cancho as a third backendA difference closed. tests/accept 112 of 115 build and agree (111 before); 20 of 20 signal tests pass with it as a backend; cancho-cache, cancho-oci (golden digests), cancho-robot, cancho-gpu and cancho-hooks build with it
5i. A variable whose type is what later statements make it (var x = list.List::Empty;): a function with one is gone over twice, once to learn such types (written down after the function in the instance table as rows with a negative declaration) and once for the codeinstances.cho, driver.cho, body.cho, scope.cho, exprs.chothe Rust compiler, by what the built programs doA difference closed. tests/accept 113 of 115 build and agree (112 before); of the 106 comparable programs of examples/ and tests/programs/, 89 give the same output (86 before)
5j. The serial-port verbs (tty_open, tty_configure, tty_read, tty_write, tty_flush_input, tty_close), from runtime/tty.c with the layout of struct termios, the open flags, the Linux rates and the macOS IOSSIOSPEED step of cancho_ir::ttyruntime/tty.c, netcode.cho, codes.cho, driver.chothe Rust compiler, by what the built programs do; a program on a pseudo-terminal opened, configured, written to and read agrees with the Rust build on what it does and on what fails (macOS)A difference closed. tests/accept 114 of 115 build and agree (113 before); of the 106 comparable programs of examples/ and tests/programs/, 91 give the same output (89 before), 1 is refused (process_capture)
5k. child_kill (the signal by its bit, to the child whose word is in the slot; cancho_native_signal per system); a literal of a declaration with parameters assigned to a variable (x = Option::None;) is the type the variable hasruntime.ll, runtime/os_*.ll, netcode.cho, body.chothe Rust compiler, by what the built programs doA difference closed. Of the 106 comparable programs of examples/ and tests/programs/, process_capture builds and agrees; tests/accept stays at 115 of 116 (aliasing_in_a_slice_of_references, a slice whose elements are slices, is the one refused)
5l. The conformance suite run through the compiler in cancho: arenas of one 64 KiB chunk that trap when a request goes past it and are given back at a return; the datagram sockets (udp_connect, udp_bind, udp_recv, udp_recv_from, udp_send_to, udp_peer, udp_local_port, with the ring of senders and its tickets); tcp_listen_on (the address of its literal); the signal a child was killed by as its bit; udp_attach answering UdpOpened; --compiler auto and CANCHO_COMPILER; a refusal explained by the Rust front end’s sentencedriver.cho, emit.cho, body.cho, netcode.cho, runtime/net.c, runtime.ll, crates/cancho/src/selfhost_build.rsthe Rust compiler, by the whole conformance suite with CANCHO_COMPILER set, so that every plain cancho build of a test goes through the compiler in canchoA difference closed. 695 of 702 tests pass that way (667 of 703 before this stage); the 7 left are differences, not gaps: three cranelift-only tests, the wasm target, the object bytes of a tuple against a struct, the stride of a struct of bytes (3 bytes packed here, 24 there, so an arena of them holds more before it traps), and the one fixture whose slice has slices for elements
5m. The corpus of refused programs, measured against the Rust front end (tests/reject, 333): the compiler in cancho refused 180 with the Rust rule and built 127 that the language refuses (27 linear values not consumed, 27 effects not declared, 18 capabilities not narrowable, 14 uses after a move, 9 linear values taken apart, and a handful of others; #512). The effect row is now checked while code is written, in the direction that cannot be wrong (the body performs what the row does not declare; the other direction waits until every builtin records its effect: 24 valid programs were refused by it), and a builtin records the labels it performs whichever way its code is written (the sockets, the clock, the directories, the files had a path of their own that did not), which moves 23 programs to the right rule: 203 of 333 agree. refusals_agree_with_the_rust_front_end holds those 203 to the Rust rule and the 130 in reject_gaps.txt to still differing, so the list only shrinks. Also: the first builds with --compiler auto no longer collide on the compiler’s cache (eight started together failed 5 to 7 times in 8)body.cho, effects.cho, selfhost_build.rscancho check --output jsonA difference measured, and the first part closed. tests/accept: 115 of 116 build as before (the one SKIP is as it was)
5n. Linearity while code is written (docs/linearity-and-effects.md §4, linear.rs): each binding is 0, holds a res value, or has had it consumed (a fifth int in the log of bindings); a read of a res name moves it, a block that closes or a return with a value still held is linear-value-unconsumed, a second read or a borrow of a consumed one is linear-use-after-move, an assignment over a held one, a let that shadows a held one, an expression statement and a _ (arm or payload) that drop one are linear-value-unconsumed; branches save the states and the arms that go on must agree, a loop must leave them as it found them. A res value is a capability, a handle, a box, a thread, a prelude enum, a tuple or a struct or enum with such a member. Also linear-value-taken-apart for a field or component read out of a res value, * of a reference to one, and a pattern on a handle or a box. Compile mode only: the Rust oracle of the checker alone does not run the passscope.cho, body.cho, exprs.cho, rules.chocancho check --output jsonA difference closed, in part. tests/reject: 256 of 333 get the Rust rule (203 before); the accept corpus is unchanged and the whole suite through the compiler is 697 passed with the 7 known differences. Left in this family: the generic ones that need the mode bounds of a type parameter (val_generic_*, mode-bound-violated)
5g. What is not done: UDP, ttys, signals, function values as parameters, a struct as a thread payload in the code (5 of the 115 programs of tests/accept still stop: a function-typed parameter, a slice of references, a generic literal typed only by a later statement, tty_open, signals_watch); a static that calls a function of the program is still run at start; a compiler that checks as fully as the Rust one (about a fifth of the functions the fuzz cases and the library hold are still SKIP in the checker)not started
5p. region-chunk-exceeded (#514, docs/region-limit.md §2): a constant or folded count of alloc_slice in a region whose bytes do not fit the 65,536-byte chunk is refused while code is written, with the stride the Rust lowering uses (byte 1, f32 4, other scalars 8, a tuple of scalars 8 each; any other type is left to the trap at run time). Found by selfhost::parity going red on main when the rule landed in Rust (#518) without the compiler in cancho knowing itexprs.cho, rules.chocancho check --output jsonA difference closed. The three tests/reject fixtures get the Rust rule and leave reject_gaps.txt; tests/accept/region_slice_at_chunk.cho (the exact limit) builds and runs as the Rust build does
5q. narrow (lower/mod.rs, lower/narrow.rs, docs/narrowing-into-several.md): a literal narrowing a path capability (Fs, Exec, Tty) must be inside what it holds, at a separator, and not equal; Net by plain prefix; a set of libraries (Ffi) must be among those held and not the same set; several literals only for Fs and Exec, unrelated paths, none equal or nested; a borrowed capability, a non-literal, a capability with nothing to narrow are refused (narrowing.cho, from the literal each capability type already carries). A literal with an escape is left alone. Also: every trap is the one instruction the Rust backend writes (ud2, udf #0xc11f), once in the module as @cancho_trap, so a program stops with SIGILL whichever compiler built it (llvm.trap is SIGTRAP on arm64); the arena’s trap says why on standard error, as the Rust one (#514); an explicit --backend cranelift is not sent to CANCHO_COMPILER; the arena keeps how much is used beside its chunk and not in it, so all 65,536 bytes are the region’s (an allocation of exactly the chunk wrote 16 bytes past it and made glibc abort in the next malloc on Linux, found by the selfhost CI job)narrowing.cho, exprs.cho, driver.cho, emit.cho, runtime.ll, runtime/net.ccancho check --output jsonA difference closed. tests/reject: 278 of 336 get the Rust rule (259 before); every capability-not-narrowable program of the corpus; three of the known differences close (identity, crypto_builtins, sha256_builtins build with Cranelift natively); tests/accept unchanged
5r. The rest of the refusals that had a rule apiece (#512): program-shape (a main that does not take the one World and return int, or no main, said after every function is checked); capability-misused (a struct literal or a pattern of a capability or a handle, release of what is not one, a path or program reached through a capability that is not the borrowed Fs or Exec, a heap operation or fork_heap without the uniquely borrowed Heap); listen-host (the address of tcp_listen_on a literal dotted quad that the Net bound covers, tcp_listen and udp_bind only under a bare port); signal-not-claimable, foreign-scope, unsized-type (unbox of a boxed slice), thread-payload-type, no-function-values, not-a-tuple, not-a-place, pattern-shape, tuple and pattern arities; mode-bound-violated for a res value in an arena, a boxed slice or a T: val argument; type-mismatch for unbox of a reference to a box; an argument label (a path, a program, a library) performed by a function whose row is empty and which owns no capability is effect-not-declared, and a row that says more than the body does is refused again, with conc, heap and clock recorded and a function that performs what the masks do not keep (a label with an argument, a function value, a foreign call) left alone. New modules allocs.cho (the ends of alloc_slice, box, unbox, unbox_slice, box_slice) and, from narrowing.cho, the checks for listeners; exprs.cho is kept under the 2,000-line budget by moving the part of each that follows the evaluation of its operandsbody.cho, exprs.cho, atoms.cho, allocs.cho, narrowing.cho, effects.cho, driver.cho, rules.chocancho check --output json, and the rule each fixture declaresA difference closed, almost. tests/reject: 322 of 336 get the rule (278 before; the three fixtures that are a property of a target are not compared). The 14 left need a port of the thread leases of linear.rs, the check of a generic definition with its parameters unknown, a row in the type of a function value, and what an owned capability discharges by path
5s. The leases of linear.rs (docs/aliasing.md §6.1, docs/threads.md §3): a &! given to a thread is lent to it until the handle is joined; a spawn of a reference that is lent already, a read of a copy of a lent reference, branches that disagree about whether a handle was joined, and a loop that leaves a lease behind are borrow-conflict. A reference is a copy of the one it was bound from (a sixth int in the log of bindings); five leases are kept in the header after the log, and branches and loops save and compare them as they do the states; only the two shapes linear.rs follows end a lease (a handle named by a binding, a spawn joined where it is made). Thread[T, R] is a type that can be written in a signature, carries its payload so that a handle cannot outlive the borrow block the payload points into (reference-escapes-region), and unifies by its result; join no longer records conc (only spawn does). A static the interpreter is sure traps (an index out of range, a division by zero) is constant-trapsscope.cho, types.cho, exprs.cho, body.cho, consteval.cho, tables.cho (the Thread index)the rule each fixture declaresA difference closed. tests/reject: 329 of 336 get the rule (322 before). Left, each a feature of its own: the check of a generic definition with its parameters unknown (arithmetic_on_a_type_parameter, generic_body_checked_when_unused, generic_array_over_a_resource), a row in the type of a function value (function_value_row_undeclared), what an owned capability discharges by path (fs_owned_prefix_covers_sibling, exec_owned_prefix_covers_sibling), and the region of a variable settled by inference (reference_escapes_via_inference)
5t. Refusals as sentences (#479): the compiler writes the sentence of the rule it refuses by (Rule::explanation, generated into tables.cho) on a line of its own after ERR <rule> <from> <to>, and cancho build renders where (file:line:column), why, the rule, the line and what of it, without asking the Rust front end; the CLI no longer calls the Rust front end to explain or to say whether the two agree. The sentence is the rule’s, the same for every program it refuses; the names and types of the Rust compiler’s messages (125 shapes) are not portedtables.cho (generated), rules.cho, driver.cho, selfhost_build.rsthe sentence of Rule::explanationA stage. What docs/agent-errors.md calls the shape in JSON waits for check in cancho (#485)
5u. The default (#478): the compiler in cancho writes the code of build, run and test on macOS and Linux unless the Rust compiler is asked for (CANCHO_COMPILER=rust), the build is Cranelift or WebAssembly, or the target is not a host it writes code for. Its sources are compiled into the cancho binary (build.rs lists each file) and built once into the cache, so nothing has to be next to the binary; CANCHO_SELFHOST_SOURCES builds from a directory. While reject_gaps.txt lists a program, the Rust front end checks first (RUST_CHECKS_FIRST, and a test that says it goes when the list is empty); a build the compiler has no code for, or that breaks, is done by the Rust compiler (an arena of aggregates with a byte or f32 leaf is such a build: the two compilers do not lay them out alike), and named alone (--compiler, CANCHO_COMPILER=auto) the compiler in cancho says what it cannot do. write_bytes is one fwrite, as the Rust backend’s, so a write that fails is reported by flush_out as it is there. A bug found on the way: the binding log grew to six ints and the header of the linearity state fell, in a program of 330,000 tokens, into the module textselfhost_build.rs, main.rs, project.rs, build.rs, driver.cho, allocs.cho, ast.chothe conformance suite under the default, and with CANCHO_COMPILER=auto in CIThe first version. 710 of 710 conformance tests pass under the default on macOS
5v. The process builtins of edition 9 (docs/processes.md §4.11, #524/#525): exec_spawn_detached is exec_spawn whose child leads a session of its own (a 19th argument of cancho_spawn, and POSIX_SPAWN_SETSID in the flags cancho_spawn_setup sets: 0x80 on Linux, 0x400 on Darwin) and performs exec_detached as well as exec(p); child_pid(&child) reads the pid out of the word; child_release(child) closes the pidfd and starts a detached thread of 64 KiB of stack whose one call is waitpid on that pid (runtime/process.ll, included only by a program that releases a child). All three are edition 9, as in Rust. A row that carries an argument label is no longer a reason not to compare the plain labels: Label::covers never lets a label with an argument cover a plain one, so a plain label performed and not written in the row is effect-not-declared whatever else the row hasfilecode.cho, exprs.cho, codes.cho, effects.cho, driver.cho, runtime.ll, runtime/os_linux.ll, runtime/os_darwin.ll, runtime/process.llthe rule each fixture declares; the seven process_release conformance tests and tests/accept/process_release.cho through CANCHO_COMPILER=autoA difference closed. tests/reject: 330 of 337 compared programs get the rule (327 before): child_release_undeclared, child_released_then_used and exec_spawn_detached_undeclared leave reject_gaps.txt; the three builtins leave unported_builtins.txt and six process_release:: tests leave .github/selfhost-known-differences.txt. Measured on Linux x86-64 only; not run on macOS or Linux aarch64 before CI
5w. A generic function is checked once with its type parameters unknown (rigid), as the Rust front end does (#512): the body runs through the checker with code writing off (body.cho check_function), so a name that only a type parameter can’t support (arithmetic_on_a_type_parameter), a body that is wrong whether or not anything calls it (generic_body_checked_when_unused) and an array of a resource type (generic_array_over_a_resource) are refused with the Rust rule. Linearity is tracked only when code is written, so a generic function with an unbounded parameter that passes this check is SKIP, left to its instancestypes.cho, scope.cho, body.cho, exprs.cho, allocs.chothe rule each fixture declares; the_body_checker_in_cancho_agrees_with_the_rust_one_per_functionA difference closed. tests/reject: 333 of 337 compared programs get the rule (330 before); three fixtures leave reject_gaps.txt, four remain. Measured on Linux x86-64 only (conformance selfhost filter: checker, body checker and refusals tests); not run on macOS or Linux aarch64 before CI
5x. child_kill_group (docs/processes.md §4.12, #533): child_kill’s signals to the process group the child leads. Builtin 138 in codes.cho (spelled exactly, so child_kill is untouched), arity 2 and cancho_child_kill_group in netcode.cho, and no support flag in exprs.cho since the function is in the runtime every module has; runtime.ll has it beside cancho_child_kill, the pid negated and a pid of 1 or less EINVAL with no call. Edition 9, row child_signal, in the regenerated tablescodes.cho, netcode.cho, exprs.cho, runtime.ll, runtime.cho, tables.cho, builtins.cho, signatures.chothe five process_group conformance tests, which build the driver with the compiler in cancho on the llvm backend and with the Rust one on Cranelift, and tests/accept/process_group_kill.cho through CANCHO_COMPILER=autoA port with no gap. Nothing is added to unported_builtins.txt, reject_gaps.txt or selfhost-known-differences.txt; the two fixtures that reach the Rust front end’s rules (child_kill_group_is_edition_nine, child_kill_group_undeclared) get the same rule from both. Mutating runtime.ll (the pid not negated) fails three of the five tests. CANCHO_COMPILER=auto cargo test -p cancho --test conformance: 724 passed, 7 failed, all seven already in selfhost-known-differences.txt. Measured on Linux x86-64 only

The method. A port that builds no tree has nothing to compare, and one that does needs a printer, which is another port. So stage 2’s parser wrote the tree the Rust parser would have built as a postfix listing, one node per line, children before their parent, at the moment the Rust parser pushes the node into its arena, and says how many children it takes. dump_ast.rs writes the same listing from the Rust AST. Each node carries its span, so the comparison covers every position the parser records; every refusal is one line, ERR rule start end. examples/selfhost/diff.sh compares the two over files, fuzz.py over the files plus hostile edge cases (the limits of int and of float and f32, escapes, the order of module, import and items) and mutants (a token dropped, duplicated, replaced or swapped, the file cut short, a byte range deleted), and tests/conformance/selfhost.rs runs both ports over the repository’s programs on every CI run.

Stage 3a: the tree. Stage 2 printed as it parsed; the checker needs something to walk, so ast.cho is the same parser building the Rust AST’s own design: flat tables and indices, not owned children. A node is a record of 16 integers in one table (kind, span, the next node of its list, and what its kind keeps), a reference to a node is its index, a list is a chain (the parent holds the first child and the count). The whole front end, state, tokens and nodes, is one allocation whose size follows from the length of the text (every node and token takes a byte, so tokens bound nodes), 160 bytes of state per byte of source. parser.cho is now only the walk that prints the tree in the listing’s order, so a match with the oracle is a statement about the tree: the walk reads nothing the tree does not hold, except where the parser had already checked the tokens and the node keeps only where they start (an effect row, a declaration’s [T, &r where ...], a destructuring pattern, a match pattern). Everything stage 2 was checked with passes unchanged, and the translation compiled and matched the Rust parser on all 621 programs the first time. The cost of the walk: ast.cho (1,921 lines, 1,678 not comment or blank) and parser.cho (847, 761) are 2,768 lines against stage 2’s single 1,970; parsing and walking its own 80 KB takes 0.24 s (stage 2 took 6 ms for its own 59 KB), with a 13 MB table to fill first; where the time goes was not measured.

Stage 3b: the declarations. The Rust checker is one long function, and its first half, collect_declarations, reads no body: imports, type declarations, foreign declarations, statics and function signatures, in that order, stopping at the first refusal. That half is now a function of its own (cancho_ir::check_declarations, a pure extraction; lower calls it), and it is the oracle: the port’s first refusal must be the Rust checker’s, rule and span, which makes the order of the checks part of what is compared. pass1.cho ports, in that order: imports (check_imports), type names (built-in and duplicate), members (duplicate fields and variants, enum with no variants), the finite-size check (reaches, with Box the finite way back), statics, and signatures (built-ins, duplicates, generic and region names, where clauses, duplicate parameters, pub signatures naming private types), and under all of it resolve_type: modules, qualifiers, arity, type parameters, scalars, regions, [T]’s size, tuples, pub. A type is resolved in place: each TName node of the tree is annotated with what it names, which is what the tree is for.

Two things make a partial port honest. SKIP at the point of divergence. While the port was partial, a construct whose checks were not ported (an extern fn, a val declaration, a val-bounded parameter at an argument that was not a plain scalar, Ffi with a library) ended the check with the answer SKIP where the Rust checker would have reached it, so a refusal found before it still counted and anything after it did not, and a skipped file was counted, by what the oracle said, not hidden. Each slice removed some; the last removed the answer, and the harnesses now fail on anything but the oracle’s. Generated tables. The prelude’s 49 types (46 before udp.md added Udp, UdpOpened and Datagram) (name, arity, edition, whether another module may name it, which parameters are val-bounded) and the 137 builtins’ names (118 when this was written) and editions are data the port cannot read from Rust, so tables.cho is generated, and a cancho-ir test fails when it is not what prelude_types, mode_of and Builtin::ALL say (UPDATE_SELFHOST_TABLES=1 cargo test -p cancho-ir selfhost_tables rewrites it).

What it found: the oracle caught an off-by-one in the import span in the first run, and nothing else differed in 51,150 cases; the same two keywords as before (module, region) cost a compile cycle each as the names of locals; and cancho fmt refuses import m.ast as ast, an alias equal to the last segment, which the file then spells without the as. pass1.cho is 920 lines (788 not comment or blank); the Rust it ports is spread over defs.rs, function.rs and lib.rs, 942 non-comment lines in the ranges that hold it, about a quarter of which (foreign declarations, modes) is not ported, so the ratio is nearer 1.2 than 0.8. That split is an estimate, not a count.

Several files, and modes. The compiler parses a program as a set of files, the user’s and then std’s, into one tree (parse_into, each file at its own base offset), so the port now does: driver.cho reads a stream of files on standard input (a line FILE <length> and that many bytes, each), lays them end to end with one byte between them as the Rust SourceMap does, and ast.cho tokenizes and parses them one after another into the same tables, with a module table (a path names one module however many files declare it) and an edition per file. Parsing std and a program together, 485 KB, gives the Rust parser’s listing byte for byte, in 0.27 s. Checking with the library then exposed what the first slice had skipped: std names types with a val bound (Option[T], Map[V: val]) at arguments that are not plain scalars, so modes are ported too. A type’s mode is written as a function of its parameters, a bit for “res whatever they are” and a bit for each parameter that makes it so, so nothing is substituted; the prelude’s bits are generated from mode_of itself (probing each parameter with a res type, which is exact because a mode is a disjunction over members), and a type the file declares has the bits of the members resolved so far, as the Rust checker, which fills members in as it goes, sees them. Ffi’s scope (parse_scope) is ported with it. Only an extern fn still ended a check in SKIP, which is the next paragraph.

Foreign declarations. The last part of the declarations, foreign.cho, ports the loop that handles every extern fn: the name is not a builtin’s nor a symbol another declaration binds, every parameter and the result resolve (c_ptr, and c_int for a result, are names only a foreign signature has), what crosses is what C can name (an int, a bool, an opaque pointer, a borrowed capability, a borrowed [byte]), an Ffi is narrowed to a library and not to nothing, exactly one Ffi is borrowed, and the row is the same set as what the borrowed capabilities discharge. What a capability discharges is data and is generated into tables.cho from discharged_by (each label plain, narrowed to the literal the type is written with, or to the empty string), the way the prelude’s modes are. The comparison is by equality of strings as written, with one subtlety it found a way to be wrong about and did not: the Rust checker canonicalises an Ffi’s library set (sorted, once each) in the type but compares the row’s ffi("...") as written, so ffi("libssl,libc") is not ffi("libc,libssl"), and the port reproduces that. The rules moved to rules.cho and the order of the checks to checker.cho, to keep ast.cho under the file budget.

What it does not show, and the next slice: the declarations half of the checker is complete; the bodies are not started, and they are most of it. The library is parsed in name order and not the compiler’s, which cannot change an answer about the program’s file because the library has no refusal of its own and the program’s items come first. A type that contains itself met first by a mode check makes the Rust checker recurse without end (the program is refused anyway, after the stack overflows); the port answers res past a depth of 64 instead, and no case reached it.

Stage 3c: bodies, one function at a time. The second half of the Rust checker reads function bodies, and a body is checked against other functions’ signatures and never their bodies, so each function is a case of its own. cancho_ir::check_bodies (the loop lower already ran, extracted as body_results) answers per function, and the port answers per function: OK, the first refusal of the body, or SKIP where the body uses something it does not check yet. That makes a partial port useful at once: it can be exactly right about the functions it handles while it does not handle the next. SKIP is raised where the Rust checker would have met the construct, so a refusal found earlier in the function still counts.

body.cho handles functions with no type or region parameters and only scalar parameters and result: let and var, assignment to a local, expression statements, if, while, return; integer, float, f32 (edition 6) and bool literals, locals, the unary and binary operators, and calls to such functions (arity, argument types, locals that are not functions, module qualifiers, visibility). What the Rust checker does for them is more than types: a literal operation that can only trap is refused at compile time (constant-traps) and folds through nested literals, so (1 + 2) * 9223372036854775807 is refused where the outer operation is met; unreachable-statement and missing-return are decided on the lowered statements; and an empty row is exact, so a declared label that nothing performs is refused. The order is the Rust checker’s: the body, then the row, then the return. Integer arithmetic in cancho traps on overflow, so every folded operation is decided before it is taken (the boundary cases for + - * are in fuzz.py).

Results: nearly all of what the port can say is checked against the Rust answer and none differs. How much it can say is the number to watch: with the library, 271 of its 789 functions (34%) are verified OK and the rest are skipped, because the port has no references, structs, match, generics or builtins yet. Every program’s main is skipped (a World is not a scalar), so no program is wholly verified yet.

How strong the comparison is was measured, not assumed: a mutation test changes one comparison or arithmetic operator at a time in body.cho (144 mutants) and asks whether the comparison with the Rust checker notices. The first run caught 82; the survivors were boundaries the edge cases never reached (an overflow decided at exactly int::MAX, a product at exactly -2^63), and adding them took it to 101; an operator-by-operand-type matrix (every operator on int, bool, float and byte) took it to 105 of 144. The 39 that survive were not each analysed. The ones looked at are of two kinds: equivalent (a loop over the node list that starts at the first item, b > 0 against b >= 0 where the zero case is decided earlier) and hidden by SKIP, which is a limit of the method: a mutant that makes the port skip a function it used to answer cannot be told from a function it does not handle yet. So the comparison cannot see a port that becomes more cautious, only one that becomes wrong. fuzz.py --bodies and bodies_diff.py are the harnesses, and selfhost.rs runs it in CI over the corpus, the edge cases, and a sample with the library.

Stage 3d: references and slices of scalars. types.cho is the type machinery a reference needs: a table of types in the state (a scalar is just a small integer, so it costs nothing), regions as integers (a parameter of the function, static, or a variable a call makes), Unifier::unify and unify_regions, outlives (reflexive and transitive over the declaration’s where clauses, with static outliving everything), and expect_type with its two coercions: a reference whose region outlives the expected one, and a unique reference where a shared one is wanted, never the reverse, the referent invariant. A call instantiates the callee’s region parameters as fresh variables that its arguments solve, then checks the callee’s where clauses against what they solved to (an unsolved variable outlives nothing but itself). Order matters and is the Rust one: a reference is unified by uniqueness first, then region, then referent, which decides whether a mistake is a type-mismatch or a region-mismatch.

On top of it body.cho gains *r, *r = v, s[i], s[i] = v, s[a..b], len(s), string literals (a shared slice of bytes in static), let with a reference type, and calls whose parameters and results are references. A reference is val whatever it points at, so nothing here is a resource and the linearity half of the checker cannot refuse any of it; that is what made this a slice the port could take without the borrow blocks, which are where borrow-conflict lives. What it still skips: references to anything but a scalar or a slice of one, borrow and region blocks, *s of a slice, and writing through a slice reference.

Result: with references the port verifies 437 of the library’s 789 function bodies (55%), up from 271 (34%), every one the Rust answer, and 375 OK bodies among the repository’s own programs, up from 278.

Stage 3e-1: structs of scalars. A struct the file declares, with no parameters, not res, and only scalar fields, is a type the table knows (NAMED, by the node of its declaration); it is val, so a value of it copies and linearity still has nothing to say. On top of it body.cho checks the struct literal (every field once, in declaration order, each of the declared type: unknown-name, duplicate-declaration, field-order, missing-field, not-a-struct, not-public in the order Rust meets them), p.x through a value or one reference, and p.x = v through a unique reference. Anything else that mentions a struct (a prelude type, an enum, a generic or res struct, a field that is not a scalar) is SKIP. The library gains little, since its structs are mostly generic or res (439 of 788 verified); the repository’s own programs gain the most.

The mutation test of body.cho kills 139 of 199 (105 of 144 before); the survivors in the new code are node numbers that are never 0 and the equal-index case a duplicate has already caught.

Stage 3e-2: enums of scalars and match. An enum the file declares, with no parameters, not res, and only scalar payloads is a NAMED type like a plain struct, and val. body.cho checks the variant expression (not-an-enum, unknown-name, not-public, arity-mismatch, each payload of its declared type) and match on a value or on a reference to one: the arms, in order, each a _ or a variant named once (match-arm-unreachable for a repeat, for an arm after _, and for a _ when every variant is already named; unknown-name, arity-mismatch, duplicate-declaration for a pattern; match-not-exhaustive at the end), with the bindings in a scope of their own. Through a reference each binding is a reference to the payload with the scrutinee’s uniqueness and region, so nothing moves. A pattern is not kept in the tree, so the checker reads the tokens after the arm’s first. . on an enum is match-on-a-non-enum (a read) or linear-value-taken-apart (a write), and a match terminates when every arm does. Anything else about an enum (a generic or res one, a payload that is not a scalar, a prelude type) is SKIP. The mutation test of body.cho kills 180 of 255; the survivors in the new code are node numbers that are never 0 and loop bounds the guards above them already settle.

Stage 3e-3: generic functions. The type table gains a rigid type parameter (Param, by its position) and a type variable (Var, with its solution in an array beside the table, like the region variables). A call to a generic function makes a fresh variable for each type parameter of the callee, lowers the callee’s declared types with the parameters as those variables, and unifies each argument with its parameter, binding variables as Unifier::unify does (an occurs check, and the result a type-mismatch, a region-mismatch or an infinite-type). Once the arguments have been unified every variable has to be solved: the first that is not is ambiguous-type at the call. The return type is then resolved all the way down, so no variable leaves the call, and nothing else in the checker needs to look through one. A function whose own type parameters are all val-bounded is checked with them as rigid types; one with an unbounded T is SKIP, because a value of T could be a resource and linearity is not checked yet. Generic structs and enums (a type applied to types) are the next slice. The library gains nothing (its generic functions mostly take a Heap or have an unbounded T), so the number of bodies verified stays at 461 of 814. The mutation test of body.cho kills 190 of 271, its survivors in the new code changing only whether a function is SKIP (which the comparison cannot see) or a loop bound the guards settle; that of types.cho kills 60 of 78 (40 of 54 before), the survivors in the new code being an occurs check no found type can reach, a variable compared with itself that a found type cannot be, and counters that stay distinct under the swap.

Stage 3e-4: borrow and region blocks. A block is a region of its own, numbered in the order it opens and remembering the block that was open around it; a reference into it is Ref with that region, and outlives answers as Region::Block does (a block outlives the ones it encloses, a region parameter outlives every block, nothing outlives static). borrow x as &r in {} looks x up (unknown-name), opens the block, freezes x (or locks it, for borrow mut), binds r to a reference of the block’s region, checks the body and thaws; region a {} is the same with nothing frozen. Whether a binding is borrowed is a flag in its record: owned, frozen by n shared borrows, or locked by a unique one. A name read while locked, an assignment while frozen or locked, a second unique borrow, and a unique borrow of a frozen name are borrow-conflict. Rust finds those in its linear pass, which runs after the body and only if the body had no error, so the port notes the first as it walks and reports it at the end, after the check that no binding holds a reference into a block it outlives (every binding is logged with the block open where it was declared) and before the row and the missing return. A let annotation that names a region inside a block is SKIP, because the name may be the block’s own. A borrow or region whose body returns terminates the block around it. The header of the state grows to 48 slots and a binding record to four. Of the first mutation run’s survivors, some showed that the cases used the first binding and the first block everywhere (so an offset swap changed nothing); the cases now come padded with bindings, with sibling blocks opened before, and without the final return that made the statement after a returning block unreachable-statement before a conflict could be reported. The mutation test of the new code in body.cho kills 33 of 42, the survivors being equivalent comparisons, a stored name nothing reads, and loops whose only effect is whether a function is SKIP.

Stage 3e-5: builtins and prelude types. What the checker needs to know of a builtin is data, so it is generated: builtins.cho, beside tables.cho, holds for each builtin its signature as a prefix-coded run of integers (a scalar, a reference with its region parameter, a slice, a prelude type, a tuple, a type parameter), its region parameters and its effect labels, and for each prelude struct its fields (the Split of each edition). prims.cho decodes a signature into the checker’s types, with each region parameter a fresh variable, and a call to a builtin goes the way a call to a function does: the arity (arity-mismatch), each argument unified with its parameter, the result. What it cannot hold (a tuple, a type parameter, a literal type, a prelude type that is a resource and not behind a reference) decodes to nothing and the call is SKIP; so is a builtin the lowering checks by hand (split, release, len, box), and a builtin that performs an effect, because rows are not checked yet and answering OK for a function whose row Rust would refuse would be wrong. The prelude types without arguments are now types of the checker too: Io, Heap, Args, File behind a reference (a borrowed capability is val), and Done, Read (not resources) by value; a field read or a match on one is SKIP. The case list now has a strictness: a skipped function cannot be seen by a comparison, so a change that only makes the port give up would pass; the cases written to be answered are marked, and one of them skipped is a failure. The mutation test of prims.cho kills 27 of 38 (9 of 38 before the cases were written for it), the survivors being branches no builtin the checker can call reaches. The state and body.cho were split for length: scope.cho (bindings, borrows, blocks and the lowering of written types), exprs.cho (expressions, folding, calls) and body.cho (statements and check_function).

Stage 3e-6: effect rows. A function’s row is exact (docs/linearity-and-effects.md section 7.3): what its body performs, which is what the builtins it calls perform and what the rows of the functions it calls say, must be the row, both ways. Rust finds the first label in the body that the row does not declare (effect-not-declared), and failing that the first in the row the body does not perform (effect-declared-not-performed); both are refused at the function. A set of labels is an integer here, bit i for the i-th label of the generated table (twenty, so far), a label a function’s row names that no builtin performs is a flag in a high bit, and so is one that carries an argument (fs_read("/tmp")). A call to a builtin adds its labels, a call to a function adds the labels of its row, in whatever branch or loop it is, and check compares the two sets once the body is clean and the borrow conflicts are reported, where Rust does, and before the missing return. What it leaves to SKIP: a callee whose row has an argument or an unknown label, and a body that performs anything in a function whose own row has an argument label, because a plain label and one with an argument are different labels and the port does not yet follow how an owned capability discharges them. A function that owns a capability by value is not checked either, so nothing is discharged here. The cases include the row in either order, with a trailing comma, with a duplicate, with a name nothing performs, rows that propagate through two and three callees and through recursion, and an effect in a dead branch, a short-circuit and an argument. The mutation test of effects.cho kills 24 of 31, the survivors being loop bounds that read one label past the table and a sentinel, and one case it found missing (a callee whose row is only an argument label), which is now one. With the pure builtins this takes the library from 590 of 814 to 625 of 855 verified, and the checker can now answer for a function that prints.

Stage 4a: the first code. The backend is written the way the Rust one is: the module is LLVM IR as text, and clang turns it into the executable (cancho-codegen-llvm does the same, so nothing here needs a library cancho cannot reach). The port writes it while the checker walks, because the checker already visits every expression in the order it runs and knows its type: the state gains three buffers (the finished output, the function being written, and its allocas, which belong in the entry block however late a let comes), and emit.cho the operations that write to them, an operand being a constant or a temporary %t<n>. Each function is define i64 @lexs_name(...) with a stack slot for each binding, a load for a name, llvm.sadd.with.overflow and its siblings and a branch to a trap for +, - and *, the explicit checks sdiv and srem need for a zero divisor and int::MIN by -1, the range check on a shift, comparisons as icmp, a call for a call and a ret for a return. The checker does what it did and writes only when compile mode asks; anything it cannot write yet (floats, bytes, references, control flow, a generic function) is a SKIP, which in this mode is a refusal with the function named. The IR is not compared with the Rust backend’s, which is another text; the programs are: each of 74 is built by both compilers and run, and must end the same way, the same exit status or a trap. A program the Rust build needs a main(world) for: the cancho compiler calls a function named run from a C main for now, until World and release are checked.

Stage 4b: control flow. An if is a branch on the condition to a then block, an else block (empty when there is none) and an end block; a while is a head block where the condition is computed, a branch to the body or the end, and a jump back. Each construct takes one number from the same counter the trap blocks use, so no two share a label. a && b and a || b keep their result in a stack slot of their own: after a the slot holds it and b is computed only if a did not decide the answer. The condition now hands its value back so the branch can name it. After a return the next block is a dead one, so every br that ends an arm has a block to end, and the end of the function is an unreachable the checker has shown nothing reaches. Programs that matter here: a short-circuit whose right side would trap if it ran (it must not), loops that trap on the iteration that overflows, an early return from inside a loop, and the recursive ones.

Stage 4c: World and main. World is the first prelude type with a type of its own in the table (by its index in tables.cho), and only in compile mode, where it is a type a parameter may have and a function may pass on. Like the Rust backend the port writes no code for it: a function’s World parameter has no argument and no stack slot, and a call’s World argument is not passed. release(w) is a call that writes nothing and answers 0. The driver calls the program’s main from the C one when it has it, and a function called run when it has not, as before. The checker’s answers do not change: outside compile mode a World still makes a function SKIP, because checking main means checking that its World is consumed exactly once, which is linearity and not written; in compile mode the program is taken to be well formed, which is the Rust compiler’s to say. The 123 programs are built through a real main as well as through run.

Stage 4d: output. split(world) answers a prelude struct (which one depends on the edition) and the destructuring let Split { io, ffi, fs, heap, args } = split(world); binds each part with its own type, taken from the generated fields; none of them has a code, as in the Rust backend. A reference is a ptr: borrow mut io as &!r in { .. } stores in the reference’s slot a pointer to a byte of its own (a Io has no representation to point at), and a borrow of a whole number or a boolean stores the address of its stack slot, so a function can be given &!i Io and a call passes the pointer. putchar is a trunc to i32, a call to the C function and a sext back, as the Rust backend writes it. What it made visible was the row of main: a function that owns a capability needs no label for what the capability allows (section 8.2 of docs/linearity-and-effects.md), so the effect check subtracts what the parameters discharge, taken from the same generated table that discharged_by fills, before comparing. The test now compares what a program writes as well as how it ends, and the first thing a cancho-written compiler printed was Hi.

Stage 4e: strings and slices. A reference to a slice is two values, a pointer and a length: a slot for each (%s7_0, %s7_1), two parameters in the header, two arguments in a call, and len is a load of the second. A string literal is a private constant @str.N written to a fourth buffer (the six escapes the lexer knows are decoded as the constant is written) and the driver prints the constants before the functions. s[i] is a comparison of the index against the length, unsigned so a negative index fails the same test, a trap, then a getelementptr; a store through it or through *r is the same address and a store. write_bytes is a loop over putchar in a helper the driver writes once, so the two stay in order in the C library’s buffer. int_of is a zext; byte_of traps outside 0 to 255, which the first run showed: the first version truncated, and the Rust build trapped. Reassigning a slice variable (s = "yyyy") stored one pointer into a slot that does not exist, which clang found.

Stage 4f: structs. A plain struct is an LLVM aggregate of its fields, { i64, i1 }, which is one value: the slot, the parameter, the argument and the return are each a single operand, as they are for a whole number, and the checker’s existing walk needed only the pieces that look inside. A literal is a chain of insertvalue from undef, in declaration order (the order Rust already enforces); a field read of a value is an extractvalue; behind a reference it is a getelementptr and a load, and r.x = v is the same address and a store. A reference to a struct binding is the address of its slot, as for a whole number. Only structs of whole numbers, bytes and booleans have code; a struct with a float or another struct in it is still skipped, so the answer is SKIP and not wrong code.

Stage 4g: enums and match. An enum is an aggregate of its tag and, one after the other, the payloads of every variant ({ i64, i64, i1 } for A(int) and B(bool)); only the tag and the payloads of the variant it names mean anything, and the tag is the variant’s position. A value is built with insertvalue as a struct is; a match extracts the tag once (an extractvalue, or an address and a load behind a reference) and is a flat chain of tests: arm n is ma<n> when the tag is its variant and mb<n>, the next test, when it is not; the last arm has no test, since the arms cover everything (Rust has already said so), and each ends in a branch to the end of the match. A payload is bound by extractvalue, or, behind a reference, as the address of its element, so *w = .. writes the enum in place. Enums with a float or a non-scalar payload are still skipped.

Stage 4h: arenas. alloc_slice was not in the checker at all (it answered SKIP), so this stage is a rule first: the arena named must be open here, and an alloc_slice[static] in a function is an escape (only a static item may add to the static region); then the count, an int; then the fill, which must copy (a res one is mode-bound-violated); the result is a unique reference to a slice in the arena’s block. A return of anything pointing into a block is now an escape checked before the types are compared, as Rust does it: a function returning s from its arena as an int said type-mismatch, and Rust says reference-escapes-region; the new cases found that. In the code, a region is a cell holding the head of a list of its allocations, each a malloc with a 16-byte header linking to the one before, and the end of the block frees them all; both are two helper functions the driver writes (@cancho_arena_alloc, @cancho_arena_free). A negative count traps, and so does a size that overflows, as in the Rust backend, and the fill is a loop. What differs from Rust is the allocator (it bumps within a 64 KiB chunk) and one thing that is a gap: a return from inside a region leaves without freeing, so the memory lasts until the program ends.

Stage 4i: the heap. Box[[T]] is a pointer and a length, which in the code is the aggregate { ptr, i64 }: one value, like a struct, so it is stored, moved and passed as one operand, and a reference to a box is the address of its slot, so borrow mut b as &!q in { contents(q) .. } is the borrow of a whole number again. box_slice is the arena’s size and fill with a malloc in place of the arena (a null answer traps, as in the Rust backend); contents(q) loads the pointer and the length back into a slice in the block of the borrow; unbox_slice is a free and the length. They are written for compile mode only: the checker has no Box type and no ownership of one, so in the other modes a function that holds a box is still SKIP, as it was, and the 1,945 cases are unchanged. That is the honest limit of this stage: the code for the heap is compared against Rust by what programs do, and the rules for it (unbox_slice consumes, contents on an owned box) are Rust’s alone until the checker learns to follow ownership.

Stage 4j: static tables. The compiler’s own generated tables are static items, so the compiler could not compile itself without them. In the checker the name of a static of this module declared [T] for a scalar T is a shared reference to a slice in the static region, as docs/compile-time-data.md says; the oracle checks only functions, so what had to agree was the uses (20 new cases, none different, and 29 more of the library’s functions are verified because they read a table). In the code a static is a global pair of a pointer and a length, @sd<n>, and a function @sinit<n> that runs its body and stores the pair there; alloc_slice[static], legal only inside such a body, is the heap’s malloc and fill; and the C main the driver writes calls every @sinit before main, in the order the items come. Rust evaluates a static while compiling and puts it in read-only data; this builds it when the program starts, so a static that reads another is not supported, and the table is writable memory. Neither is visible to a program, but the first is a difference: a static whose body traps is a compile error in Rust and a trap at startup here.

The census (mode 4, census.cho). To measure how far the compiler in cancho is from compiling a program, the bodies mode runs with the code on and says, for each function, OK (the checker accepts it and code is written) or SKIP <from> <to> with the first construct it has no code for. Fed the compiler’s own source and the std modules it imports (bytes, buffer, io, math; 644 functions), on 2026-10-12: 488 of the compiler’s own 544 functions (90%) get code, 20 of the 39 in std.bytes/buffer/io, and 6 of the 61 in std.math, which is floating point and which the compiler does not use. OK here means code is written, not that it has been run: the programs run so far are the 382 of the differential test. What the other 56 of the compiler’s functions stop at: a function that returns a slice or calls one (tables.*, builtins.effect_name, rules.rule_tag: about 20), a sub-slice s[a..b] (about 12), a struct with a field that is not a whole number, byte or boolean (block’s result, Buffer: about 14), a few builtins (wrapping_mul) and floats (1). Not measured: calls across modules, which the test programs do not make, and a binary that has been built from all of it.

Stage 4k: slices that come back. A function that returns a reference to a slice answers the pair, the LLVM aggregate { ptr, i64 }, and a call to one takes the pair apart again into a pointer and a length; return s; builds it. s[a..b] is Rust’s two tests, as in the backend: an end past the length traps, then a start past the end, both unsigned so that a negative number is a large one; then the address of the first element and the difference. The census, rerun after it: 529 of the compiler’s 548 functions (97%) and 24 of the 39 in std.bytes/buffer/io. What is left of the compiler’s own: block and lc.step, which return structs with a field that is a list or a boolean pair (8), wrapping_mul (2), Buffer and what returns it (std.buffer, 19 of its own): the struct that owns a Box.

Stage 4l: structs that hold structs. The census said what was left, and it was a few things. A field or a payload now has a type that is a scalar, a struct or an enum of the file, or a Box[[T]] of scalars (types.node_type), and the LLVM aggregate nests: { { i64, i1 }, i64 }. A struct declared res is one too, since what ends it is Rust’s to say; let S { a, b } = value; takes one apart. Reading a field through a reference follows docs/reading-references.md §2.0: a val one is copied out, a res one (the Box of Buffer) comes back as a reference to it, which is what contents(b.held) needs. Two smaller things were in the way of std.buffer: a byte could not be a parameter or a result, and five builtins had no code (wrapping_add/sub/mul are the plain instructions, copy_into a bounds test and memmove, index_of_byte a memchr, getchar the C function sign-extended, write_err a write to descriptor 2). The census, rerun: 558 of 558 of the compiler’s functions and 39 of 39 in std.bytes/buffer/io. That is the end of what the census can see and the start of what it cannot: a function that calls another module’s, two modules that each have a function of one name, and a whole program built into a binary.

Stage 4m: several files, and the fixed point. A function was named in the code by its identifier, which two modules may share (ast.get and lexcore.get would both be @lexs_get); it is now named by the number of its node in the tree, @lexs_<n>, which nothing else shares, and a call names the node it resolved to. A function the code is not written for is no longer a refusal of the program: it is left out, with its body rolled back from the output and a comment at the end of the module (; left out: 55) saying how many, and a call to it, or the program’s main, being left out, is a refusal (SKIP, at the construct the compiler stopped at); a function nothing names is left out in silence. A static that is left out gets a stub. compiler.cho is the compiler for a program of several files (drive(world, 3, true)), and the first try was to give it its own source and the std modules it imports, in the stream driver.cho reads:

  • the compiler built by Rust (the first) wrote 100,409 lines of IR, 637 functions, for it;
  • clang -O1 built that without a word, 343 KB: the second, which no Rust compiler took part in;
  • the second, given the same stream, wrote the same IR, byte for byte (shasum equal); clang builds that too, and the third writes it again. bootstrap.rs is that test (5.5 seconds);
  • the checker (bodies.cho) built by the second compiler agrees with the Rust oracle on 1,980 of 1,980 cases, as the one Rust built does.

What this is and is not. It is a compiler, written in the language, that is a fixed point of itself on its own source. It is not yet the end of epic #295: clang is still run by hand (or by the test), the first compiler is still built by Rust, the 55 functions left out are std.math’s (floating point), a program that needs generics, tuples, floats, sockets or threads in the code is not one this compiler can build, a static is evaluated when the program starts and not while compiling, and the checker part is a port of what the Rust checker does for about four functions in five (the rest is SKIP, which in compile mode means the program is trusted to have been checked). The compiler trusts a program that Rust has checked; it does not yet replace Rust as the thing that says a program is wrong.

Stage 4n: the compiler runs clang. The compiler wrote a module and a person ran clang on it. The builder (builder.cho, edition 7) does both: it reads a program on standard input, compiles it, writes the module to <output>.ll through an Fs, and starts clang on it through an Exec, waits for it, and answers its status. It is made of what the language has for the purpose, and the stage is mostly the front end that makes that possible in the code:

  • a prelude type that is a handle (File, Child…) is one value of the code, a descriptor or a pid; a prelude enum (Opened, Read, Spawned, Exited, Stdio) is the same aggregate as an enum of the file, built and matched alike; a capability narrowed to a literal (Fs("/tmp"), narrow(exec, "/usr/bin")) is its type with the token of the literal kept, which the code gives the runtime to check a path with. The generated tables gained the enums’ arms and how many leaves a handle is; the rows of functions are Rust’s to check in this mode;
  • runtime.ll is the support the code calls, written by hand and printed by the compiler into every module (runtime.cho is its text, checked against the file by a test): a path copied and checked (no .., the prefix, and /tmpevil is not under /tmp), fopen and dup for a file (the close-on-exec descriptor the Rust backend makes), read and write, posix_spawn with the three streams, waitpid. An error is its negative errno, and the code builds the enum from the number;
  • the module differs by target only in where errno lives (__error, __errno_location), so a line TARGET darwin or TARGET linux in front of the input says which. The Rust backend knows its triple; the compiler is told. The harness gives every program the line of the machine it runs on.

What this does not copy from the Rust backend: posix_spawn is given the three streams and nothing else (no closefrom, no signal mask: the child inherits what the parent has open above descriptor 2 that is not close-on-exec, and its signal dispositions), a child is its pid and not a pid with a pidfd, and clang’s own output is discarded (Stdio has no way to inherit), so a build that fails says its status and nothing more.

Along the way: an emit buffer was never bounds-checked, and the runtime alone is bigger than the buffers of a program of a few tokens, which silently wrote the end of a function into the middle of the runtime; a buffer is now given a floor, and a module that outgrew its buffers is a refusal (emit.overflowed). The signature table moved to its own generated module (signatures.cho) because builtins.cho passed the repository’s 2,000-line budget.

The bootstrap, again, with no hand. bootstrap.rs has a second test: the builder built by Rust builds the builder’s own source (and runs clang itself) into the second builder; the second does it again into the third; the three write the same IR. The tests/accept fixtures are a scoreboard for what is left: 49 of 115 build with the compiler built by Rust and give the output and the exit status their headers say, none gives a different one, and the other 66 stop at a function the code is not written for: generics (Option[T]), tuples (narrow(fs, "a", "b"), (int, bool)), threads, floats, pipes and the one-shot fs_read/fs_write.

Stage 4o: a static is computed by the compiler. Stage 4j built each static when the program started, which a program could not tell from docs/compile-time-data.md’s data in the binary except by a trap, a write or the start-up cost. consteval.cho is a small interpreter: it runs a static’s body over the tree, with one int to an element of its store whatever the type, and the answer is written into the module as @sd<n>.data, a constant array, and @sd<n>, a constant pair of its address and its length: read-only, in the binary, and nothing runs at the start (the initialiser stays, but empty). It gives up, and the static is run at start as before, on anything it does not do exactly: a call to a function of the program (doubled in static_data.cho calls twice), a match, an overflow or a division by zero (the Rust compiler refuses these at compile time; here the program is trusted to have been checked and the trap is at start), an index outside a table, a byte outside 0 to 255, more store than the 1,048,576 elements it has or fuel than 20 million steps, or a table that would not fit what the module has left. A static that reads another reads the answer of the one before it, if that was computed. The tables of the compiler itself, 1,100 lines of t[i] = v; each, are computed this way, and the fixed point holds.

Stage 4p: one value on the heap. Box[T] for a T that is not a slice is a pointer in the code, whatever T is, so a type that holds itself through a box has the size of a tag and a pointer and the declaration that did not compile in docs/heap.md §4 does. box(heap, v) is a malloc of the size of T (the address of the second element of an array of them, a constant of the module) with the value stored, unbox loads it and frees the block, contents of a borrowed box loads the pointer from the slot the reference points at and is a reference to what it holds, with the mode and the region of the borrow; alloc[a](v) is the same store into the arena’s list. A field of a struct or a payload of an enum that is a box is read through a reference as a borrow, as the other res ones are. The corpus of the stage: a list built, walked by a reference and freed, boxes of structs written through a unique borrow, a box in a box, structs allocated in a loop in an arena, a res struct holding a Box[int]. The first run of the checker modes found that the new expression also ran there and wrote code into buffers that were not: it is code only, and SKIP elsewhere, as it was.

Stage 4q: tuples. A tuple of two or three parts is an anonymous struct, so it is an aggregate ({ i64, i1 }), built with insertvalue, taken apart with extractvalue, passed and returned as one operand; .0 through a reference copies a val part and borrows a res one, as a field does, and let (a, b) = e; binds each part. A tuple of nothing in the code (the capabilities narrow(fs, "/tmp", "/var") returns) is nothing in the code. Tuples of more than three parts have no code yet. Along with them, the two builtins that open a file, read or write it once and close it (fs_read and fs_write) are one more call of the runtime.

Stage 4r: generics. A function with type parameters is not compiled where it is declared: a call to it knows the types its parameters are (the checker solved them from the arguments), so the call asks for an instance and names it @lexs_<node>i<n>. The types cross from the caller’s table to the instance’s as codes (instances.cho: scalars, a declaration with its arguments, boxes, tuples, prelude types), and the driver, after the functions, compiles each instance with its T standing for the type asked; a call from an instance asks again, so the worklist grows until nothing new is asked. A literal of a generic declaration has variables for its parameters until the values it holds say them; Option::None learns its parameter from the type a let or a return expects, or from the other arguments of the call it is in (it is written last), and the code of a literal is written only when all of them are solved.

Stage 4s: floats. A float is an LLVM double and an f32 a float; a constant is written as the hexadecimal of the bits of the double (an f32 is widened, which is exact). The one hard part is the literal: the Rust compiler reads it with from_str, correctly rounded, so the compiler in cancho has to as well, and has no floats to do it with. floats.cho keeps the digits as a big number in 30-bit limbs, multiplies by 5^e or shifts left and divides by 5^e (in chunks of 5^13, remembering whether anything was left over), takes the top 53 (24) bits, or fewer in the subnormals, and rounds to nearest even by the next bit and the rest. Arithmetic never traps; == is oeq and != is une, so NaN is unequal to itself; truncate and int_of_f32 trap on NaN and at 2^63 either way, as the Rust backend’s three checks do. The scalar types a slice, a field or a payload may hold are now int, byte, float, f32 and bool (types.is_plain).

Stage 4t: threads. spawn(payload, f) runs f on a new thread: f is pthread_create’s start routine as it is, so nothing is built for it, and the payload is the pointer it receives (a whole number, byte or boolean widened and made a pointer, a reference as it is, null for a capability, which is nothing in the code). join(t) waits and reads what it returned out of the cell pthread_join writes. A thread is a pointer in the code and a value that must be joined; a function used as a value is the address of its code, and a call through the name that holds it is a call to that function. A payload or a result of more than one leaf (a struct) has no code yet.

Stage 4u: foreign functions. A call of an extern fn is a call that spells its own function type (call i64 (i64, ptr, i64, i64) @write(...)), so the symbol is declared once whatever a program or the runtime says of the same C function, and a declare is written only where the runtime does not already have one. A bool is widened to the byte C is given, a [byte] is its pointer and then its length (so write(fd, buf, n) passes the length where C reads the count, as the Rust backend does), a c_int result is 32 bits and is sign-extended. A c_ptr is a pointer the code holds, compares with null_ptr() and gives back to C, and nothing else.

Stage 4v: sockets and the clock. The Rust backend writes each socket builtin out as a hundred lines of LLVM in the body of the function that calls it. Here they are C (runtime/net.c), compiled once for each system with -DDARWIN=0 and -DDARWIN=1 into IR that needs no header and no target (runtime/make.sh; every constant that differs, SOL_SOCKET, SO_NOSIGPIPE, EAGAIN, the offsets in addrinfo, is a macro, the same values SocketOs has), and the module of a program that uses a socket or the clock carries the IR of its own target. A call pushes its arguments and the bound the Net was narrowed to (pointer and length, as a path prefix is), and a function that answers a tag, a handle and a reason writes the three whole numbers to a cell, which become the leaves of the prelude enum. The Rust compiler’s behaviour is kept to the detail: an empty buffer to conn_read never reaches the kernel, accept clears O_NONBLOCK on Darwin, a host that is not the bound’s traps. The 115 programs of tests/accept were also built on Linux x86-64 (ssh gram, clang 21, LINUX_OUT in the scoreboard script) and the same 94 pass there.

Stage 4w: pipes. A pipe is a socket pair, so its verbs are the connection’s: pipe_read is conn_read and pipe_write is conn_write, and pipe_open answers Piped, whose four leaves are the tag, the parent’s end, the child’s end and the reason.

Stage 4x: directory handles. runtime/dirs.c is the same text for every architecture: what differs, the open flags (O_DIRECTORY and O_NOFOLLOW are not the same on Linux x86-64 and aarch64), AT_FDCWD, the offsets in struct dirent and struct stat, are an array of 21 whole numbers (cancho_plat) that the compiler writes into the module for its target, so the input line says TARGET linux aarch64 where Linux has two. A name is one component, checked before any call, as in the Rust backend: not empty, not longer than NAME_MAX, not . or .., no / or NUL.

Stage 4y: defer. defer E; is sugar, and stays sugar here: the statement puts the expression on a stack, nothing is written, and the block that holds it writes the expressions out in reverse where it ends (unless its last statement returns), and a return writes out all of them, after the value it returns has been read. Only in compile mode, like the other things the checker’s own modes do not handle yet.

Stages 5a to 5c. The builtins on words are plain LLVM written the way the Rust backend writes it (wordcode.cho). A capability held by a struct has no value, so its place in the aggregate is {} and nothing is put in or taken out. A reference held by a declaration is typed with a region that matches any: the code is written for programs the Rust compiler has accepted, so the regions a declaration names are not followed, which loses the escape check of a reference taken out of a struct and nothing else. A run of bytes is held as { ptr, i64 }.

The mutation test of types.cho (54 operator swaps) killed 40 on the first corpus of 458 targeted body cases (after region-variable, where-closure and coercion cases were added; 33 before); the 14 that survive are guards the other conditions already make redundant (&& on kinds that are checked again below, the bounds of a table index, the fuel of a closure that cannot cycle).

Size. parser.cho is 1,970 lines (1,760 that are not comment or blank) for the Rust parser’s 1,507 (1,225): 1.3 to 1.4 times. lexcore.cho is 963 lines (897) for the Rust lexer’s 633, with its generated tables. Counted with grep -v '^\s*//' and without blank lines; both ports are written one statement to a line, as cancho fmt leaves them. The port parses its own 59 KB in 6 ms.

What it found.

  • The lexer had drifted, silently. Between the spike and this stage the Rust lexer gained the f32 suffix on float literals (docs/f32.md), and the port, which had no test, would have refused every such literal. It has one now (selfhost.rs), which is the first thing the oracle approach is for: a port that is not run against its oracle in CI is a port of whatever the oracle was.
  • No integer constants. A token has to be stored as an integer, so the lexer grew a generated code function (one arm per token kind) and the parser compares with look(st, 0, lc.Tok::Comma), which runs that match on every test. A table of named integers is the one thing the port wanted from the language here.
  • No error propagation, so a refusal is sticky. The first refusal is recorded in the state and every later call does nothing; kind answers end of file once the parser has failed, so every list loop ends. That works, and it has one failure mode ? does not: every function has to be total on the state a refusal leaves behind. The fuzzer found the one place it was not (edition ): the number was parsed after the refusal, as if it were a number, and the arithmetic trapped). Fixed, with the fuzz seeds that found it kept.
  • Checked arithmetic shaped one function. An integer literal is read as a magnitude that must fit in 64 signed bits; summing it positively overflows at exactly the boundary the check is for, so it is accumulated as a negative number (which holds one more value) and each step is checked before it is taken. The boundary cases (9223372036854775807, ...808, -...808, the hexadecimal ones) are in the corpus.
  • Row exactness was a help. declaration_params first declared io_write without writing anything, and the checker refused it: the parse is pure and the printing is a separate walk over its tokens, which is the structure the listing needed anyway (the [...] of a declaration interleaves two lists the Rust AST keeps apart).
  • Smaller: region is a keyword, so no local can carry the name most parsers give it; the state is one slice of integers because that was the cautious choice, and a &!p reference to a struct with field assignment does work (checked while writing this), which would read better for the scalar slots.

What it does not show.

  • The tree is flat tables, not recursive Box values, because that is the design of the Rust AST being ported and what an arena-sized front end wants. Whether recursive boxed trees at this size are comfortable is still unmeasured (§4 showed they work, at a small one).
  • Float values are not computed: the listing has a float literal’s span and whether it is an f32, not its bits. Whether a literal rounds to infinity is checked, exactly, by comparing its digits with those of 2^1024 − 2^970 (2^128 − 2^103 for f32); the conversion itself is std.json’s to_float, which is there to be reused.
  • One file at a time. The Rust parser’s parse_into (many files, a base offset on every span) is not ported.
  • Inputs that are not UTF-8. The oracle takes a &str, so it decodes lossily and reports spans in the decoded text; the port reads bytes. The two cannot be compared and the harnesses skip those inputs (361 of the 52,272 fuzz cases).
  • Names are printed from their spans, not interned, so symbol ids are not compared.

Where it stands. Stages 1, 2, 3a and 3b are done as far as stated and nothing in them blocks the rest of stage 3, the checker (cancho-ir, 12 thousand lines), which is the real test: it is the first stage with enough shape (resolution, linearity, regions, effect rows) to tell whether the language is comfortable writing its own compiler. §5’s decision does not change.

Building with it from the command line

cancho build --compiler <exe> <file.cho>... [--std] builds the program with the compiler in cancho: <exe> is examples/selfhost/compiler.cho and its modules built once (cancho build ... --std). The command reads the files (and the standard-library modules they import), sends the target and the files to it, takes the LLVM IR, and has clang and the usual link finish the build. A refusal is exit status 1 with the Rust front end’s sentence for the same files and the rule the compiler in cancho refused it by (the compiler in cancho answers a rule and a place; if the Rust front end accepts what it refused, the message says the two disagree), and a program the compiler has no code for yet says so, at the construct. --compiler auto (or CANCHO_COMPILER=auto, or CANCHO_COMPILER=<exe>) has the compiler built from this repository’s examples/selfhost by the Rust compiler the first time, and kept in $CANCHO_CACHE or ~/.cache/cancho under a hash of its sources (a binary installed without the repository next to it says so). Targets: aarch64 macOS, x86-64 and aarch64 Linux. It is a way to try the compiler on real programs; cancho build without the flag is unchanged.

Roadmap

Where cancho is, what got it here, and what is next. The README says what the language is; this file is the only place that tracks its history, so the README does not have to grow a paragraph per change.

Tracked in the epic: #1 — milestones with acceptance criteria, sequencing, risks and open decisions.


Milestones

MilestoneWhatStatus
M0 — native hello world (#3)Lexer, parser, AST, IR, Cranelift backend, a real executabledone — green on linux-x86_64 and darwin-aarch64
M1 — typed coreType checker, bool, structs, ADTs with exhaustive match, monomorphised generics. No linearity, no effects — deliberatelydone
M2 — the actual thesis (#2)Linear ownership, effect rows and capability-passing as one systemdone — §3 through §8 of linearity-and-effects.md, every must-reject fixture enforced
M3 — minimal but realSlices and strings, arenas, libc FFI, settled overflow semantics, canonical printer, per-unit identity, file IO through Fsdone — examples/lines.cho is the acceptance criterion: a tool that reads and writes files, counts and filters, and whose authority to do any of it is one narrowed capability

Everything below M3 is post-milestone work, shipped one slice at a time.


What has landed

One row per slice, newest last. Each links to the document that holds the reasoning — this table is the index, not the argument.

#SliceThe claim worth remembering
#22A general heapBox[T] is res, so §4’s exactly-once rule turns out to be a memory safety rule for free: no leaks, no double frees, no use-after-free, none of them checked by anything new
#23Reading through a referenceA reference gives references. One rule closed two limits that looked unrelated, and there are no binding modes to infer because the scrutinee decides
#24Command-line argumentsAn effect row is about visibility, not containment. Arguments grant no power, and they are still an effect, because a function that branches on --force should say so in its type
#25A program in more than one fileThe smallest feature with the largest consequence: three design docs had each had to write “there is nowhere to put a library”. It also made canonical-ast.md §1 testable for the first time
#26Boxed slicesBox[[T]] is a pointer and a length — what every collection wants. Found a double free reachable from ordinary code
#27SharingRc is not expressible: it needs a value that copies and names an allocation, and every value here is one or the other. Three ways of writing it are three fixtures, refused by three unrelated rules. Gen is a library, and is examples/slab/
#28TuplesThe first feature a library asked for rather than a design — and it costs nothing: a tuple and the struct it replaces emit byte-identical objects
#29ShadowingThe restriction was a real rule stated too bluntly. A binding may be shadowed exactly when it is dead — which is the rule assignment already had, so it is one rule with two syntaxes
#30Standard inputNot a seventh capability: a second label on Io, following Fs’s fs_read/fs_write. Which renamed putchar’s effect to io_write across the repository
#31ModulesA module reaches no hash. A call has encoded the callee’s hash rather than its spelling since M0, so namespaces cost the identity system nothing — and a module is not a trust boundary: pub means reachable, never safe
#32A standard library--std, with the source compiled into the binary rather than looked up. Found the compiler emitting every non-generic function rather than what main reaches — 6720 bytes against 1048 for a program calling none of it
#33Mode polymorphismChecking a claim found a leak and a double free: a val on a generic declaration was trusted rather than checked. And a worse bug — a call took its types from one function and its effect row from another, so a call into C could declare [] and compile
#34CollectionsWhich collections hold a resource is decided by shape, not generics: a list works because taking it apart produces its elements; an array does not because freeing one is a single free that runs nothing
#35The READMENot a language change: the README had grown a paragraph per slice in three places at once, so the history moved here and the per-example prose moved to examples/
#36A borrowed fieldThe double free was never about reading, it was about owning. A res field through a reference is a borrow, and the refusal that used to guard it was doing work the type rule already does
#37Slicings[a..b], and the answer to “a writer abstraction”: there should not be one, because a function taking a Writer must declare the union of what every destination could do, and a union row is not an exact row. The abstraction is the buffer
#38deferSugar that stays sugar: expanded during lowering, so the checker replays the same events the hand-written version would, and there is one set of linearity rules rather than two. §12’s question answered — “visible” here means the type says what happened, not “written on the line where it runs”
#39The authority surface§12’s release question answered no: the four release calls are not ceremony around the authority declaration, they are it — main owns rather than borrows, so its row is [] and every entry point has the same signature. cancho authority reads them instead
#40[budget]Answered no, from the units: a budget is wall-clock seconds, commands and cents, none of which is a property of a program’s text. lex-os already charges it, at the boundary that can stop you. What was wanted was legibility, so the authority report grew --output json
#41What a program can reachAnswered by building it: a REST endpoint over a real TCP socket, with no socket type, no Net capability and no library — because sockets are libc and libc has a name. The no’s are one sentence: a foreign result is a scalar, so every opaque handle (TLS, libpq, FILE *) is out. And the row cannot say net, because a library is not an authority domain
#42What the overflow trap costsThe README’s “low single-digit percent” had never been measured. Measured: +2.8% call-bound, +3.6% memory-bound, −9.1% branch-bound, +40.5% arithmetic-bound — so not a percentage but a rule. And the stated reason was wrong: not the never-taken branch, but that a trap is observable, so the loop cannot vectorise — clang pays 46% and gcc 74% for the same guarantee. Both documents corrected in place
#43Bits, and a portThe first program here that already existed: coreutils base64, byte-for-byte on both directions. It needed the bit operators and hex literals and nothing else — no capability, no library, no change to linearity or rows. And running the suite under load for an unrelated reason found a real bug in examples/serve/: one read returns what arrived, not what was sent
#44A port with resourcesLC_ALL=C sort, five owned resources on the heap, checked against GNU. Answered §6’s four untested things: the move loop costs three tokens rather than difficulty, effects concentrate at the edges (four of eight rows are []), and borrow mut never got in the way — five nested blocks did. Found four missing library functions, every one absent because nothing had asked
#45Against C and RustThe measurement overflow-cost.md §4 said was owed: 1.6× at equal semantics, on both a compute-bound and a memory-bound kernel, with Rust within 4% of C — so the gap is the backend, not ownership. And the numerical row: f64 is 17% slower than the fixed point cancho is forced into, so the missing float costs precision (10 orders of magnitude) rather than speed
#46What a checked row is worthThe answer to is there anything it does better? — yes, exactly one thing: the row is a checked purity proof, which C can only promise unchecked and Rust cannot state. 35% of functions here qualify; worth 1.94× as CSE and 158× with hoisting. Collected by nothing: Cranelift has no call attribute, and an own optimiser is a non-goal — now an informed one
#48Floating pointfloat, IEEE-754 binary64 in full. The interesting decision is §2.1: NaN and infinity do not trap, because wrapping lies about a value where NaN announces the absence of one — and a per-operation trap would defeat exactly the loops floats are for. truncate traps on what C leaves undefined. Writing it found a real collision the design had waved away: t.0.1
#49Printing a floatThe shortest decimal that reads back to the same bits, and it is library code: std/fmt.cho is written in cancho, row [], no Heap, and the compiler’s whole contribution is bits_of — one bitcast. std.bignum underneath has no division, because the one quotient a printer needs is a single digit and nine subtractions settle it. Checked against Rust’s {:e} over 9000 values, which found two real defects an example never would; §3.4 records the three where Rust and Python disagree and why both are right. A comment claiming 2200 bits was measured at 1080 and corrected
#50Compile-time evaluationThe answer to can it be faster than C? began with finding we were slower at the easiest thing there is: 2 + 3 * 4 - 14 is zero and emitted fifteen instructions, because a checked add is sadd_overflow + trapnz and Cranelift’s folding rules are written for the plain form — overflow-cost.md §3.2’s mechanism sighted a second time. Folding it in the front end brings that to parity; a certain trap is now a compile error rather than a SIGILL. Past C in one place only: clang gives up on recursion, so fib(23) is a runtime call at -O2 and a constant here. And the estimate that motivated the work was wrong — 65 foldable calls predicted, 15 actual, because pure-and-constant is necessary and not sufficient
#51Compile-time dataA static item: a body with no parameters and no run time, evaluated during compilation into read-only data. examples/base64/ drops its 64-entry per-character scan and decodes 6.1× faster. But the measurement corrected the pitch: compile-time.md §8 called this “the one that pays” and 5.7× of the 6.1× was already writable in 36 lines of plumbing. What the feature actually buys is that plumbing, read-only pages, and the row that is a capability argument — 65 536 entries does not fit in a 64 KiB arena and traps, so a program that released heap could not have one at all
#52LayoutThe first slice here to build almost nothing, on purpose. This roadmap said packing reaches C and transposing goes past it; measurement says the second half is wrong. Transposing is worth 1.43× in cancho and 1.31× in C — the same transform, the same size, so it moves both along rather than closing a gap, and the vectorised half of the prize needs a vectoriser overflow-cost.md §3.2 already showed we do not have. Packing is real (2.6× on a byte-heavy struct) and 3 of 83 struct fields here are byte or bool — and slab’s Entry would not shrink at all. So: cancho layout, which makes the deferral falsifiable instead of remembered
#53Against a wider setThree programs from the Computer Language Benchmarks Game — fannkuch-redux, spectral-norm, binary-trees — each checked against the answer the Game publishes. The 1.6× headline was two kernels’ midpoint; across five it is 1.17× to 2.58×, and it tracks how much of the run is in code Cranelift generated. Porting found a compiler crash: two sibling region blocks put the backend’s arena stack at length 0 where index 1 was wanted, latent since arenas landed because nothing here had two regions side by side. And scripts/game.py reports the spread, which no published ratio here had carried
#54Bulk outputbenchmarks-game.md §2.1 parked this as “a real finding and a separate slice”, and the finding is not the 12.8×. examples/serve/ had been writing whole slices for four slices, through Ffi("libc") — which reach.md §5 says is every authority at once. So a program that wanted to print quickly had to ask for the filesystem, the network and exec, and a program that asked only for Io was held to one byte per call: the cheap thing to grant was the expensive thing to run, which is the wrong lesson for a capability language to teach every time someone profiles. Fixed in the primitive rather than the library, because a function holding &!i Io can only call what Io authorises. It lowers to fwrite and not POSIX write, since putchar is buffered and a raw descriptor write would interleave wrongly. And §4 declines the headline: base64 gains 1.6×, not 12.8×, because 6 ms of the saving goes straight back into byte-at-a-time buffer fills that cc -O2 vectorises away. §4.1 then corrects §4 itself (#55): it had said a program “whose time is output” gets most of the 12.8×, and examples/sort/ — which writes 9 MB and moved onto the bulk path with no change to the program at all — gains 1.22×. Volume written is not time spent writing, and the gain is only ever the share of the runtime that was call overhead
#55Canonical identity, made observableAsked what it would take to put cancho code in lex-lang’s lex-vcs, and the answer was that 81% of that crate is already language-agnostic — it keys on String ids and BTreeSet<String> effects — while cancho’s own hashes are the part that is not ready. So this slice builds the falsifier rather than the integration. cancho-id had 61 tests and all of them were relational: they compare two hashes to each other, and not one said what a hash is, which left §8’s “not frozen across releases” a claim nobody could observe. 35 golden fixtures now pin it, reporting by fixture name so an insertion does not cry wolf — and the first thing they caught was one of my own fixtures: perturbing the BINARY tag moved unary, because 0 - a is a subtraction and there is no unary minus, so that fixture had never covered UNARY at all. And §8’s field-order entry turned out wrong in both halves: a struct literal was never a gap, because the checker refuses P { y: 2, x: 1 } rather than reordering it (defined-behaviour.md §3 — the order you read is the order it runs); the destructuring pattern’s gap was real but the stated reason was not, since sorting by name settles it with no declaration lookup and also survives a struct reordering its own fields. The binders sort with the names, because they are positional from there on and a collision would be worse than the difference removed
#56UTF-8Asked how far cancho is from a string type, and the answer was already written: strings.md settled it as &r [byte] with no encoding claimed. So the question became the library, and the probe says the language needs nothing — a forty-line decoder matched GNU wc -m exactly on the first compile (119 bytes, 79 code points over ASCII, Latin, Japanese, emoji and a combining sequence), and vec.Vec[&t [byte]] compiles, so split can return substrings bound to the text they came from. What the probe did find is the decision strings.md §1 said would have to be paid somewhere: §1 declined a validated string because it would have to say what an invalid one is — an error, a replacement character, an unrepresentable state — and a decoder cannot decline. Seven malformed fixtures, four implementations, four different answers, and GNU contradicts itself by counting f5 80 80 80 as one character though it encodes a value above U+10FFFF. So GNU is not the oracle here, which also means wordcount.cho must never be checked against wc -m. §3 takes strict validity and the Unicode maximal-subpart skip rule, verified to reproduce Python’s replacement counts on every fixture — and std.utf8 now implements it, agreeing with Rust’s own from_utf8_lossy and str::from_utf8 over 2,000 generated cases. Nothing in the design moved on contact with the code, which is the first slice here where that is true
#57The rest of the string libraryutf8.md §1 said split, trim, join, ordering and case are “code, not design”. Rather than invent the surface, examples/cut/ was ported to ask for it — the route vec.set and vec.swap took — and it asked for exactly two: bytes.count_byte and bytes.field. trim came with the field parser and bytes.compare came from examples/sort/, which had the rule inline. Four functions, three call sites, checked against GNU cut on eight field specs. Porting it found the 64 KiB arena as a line-length limit: a 65 000-byte line buffer beside a 1 025-byte bitmap traps on exhaustion, so the number in the source is what fits rather than what was wanted, and the comment says so. And it found standard-library.md §5.3 claiming a fix it had not made — wordcount.cho moved its word boundary to std.bytes, tally.cho kept a byte-identical copy, so “one definition, in one place” was two definitions that happened to agree with nothing checking. Corrected in place, in the entry that named the failure mode
#58Standard errorTwo documents cited “reach.md §6’s standard-error gap” as a blocker and reach.md never named it — five rows, none of them this, and the second citation copied from the first. What the absence cost, measured: every failing program here produced zero bytes on every stream, against GNU’s 64, 68 and 22 for the same three failures, and eleven of nineteen examples have a non-zero exit. The workaround is worse than silence twice over — a diagnostic on the output stream fed a line of prose to the sort downstream of it (wc -l answers 1 where GNU answers 0), and standard output is fully buffered when it is not a terminal, so a message written just before a trap is lost: 0 bytes to a file and 0 through a pipe. Closed as a third label on one capability — standard-input.md §2’s rule a third time — with err_write named for the stream because 1> and 2> are two redirections, and §2.1 states plainly what that widens and why the answer is that the grant is the capability while the gate is the row. Five diagnostics across sort, cut and base64, four of them in the suite against the system’s own GNU tool. And §1.3 corrects itself: the first draft said the failing path was untested, and it was tested — against the status this program happened to have, while the same test compared eight valid specs to the real /usr/bin/cut, because a silent program gives a test nothing to compare. cut exited 2 where GNU exits 1, under a comment claiming it used GNU’s
#59Refusals a machine can readlex-lang’s AGENT_GUIDELINES.md is a contract with a reader that is a program; cancho had none. Measured: across 196 must-reject fixtures the checker produced 125 distinct message shapes, 101 of them exactly once, reported one error per invocation, and offered no machine-readable form — so classifying a refusal meant matching 125 English patterns by regex, one compile at a time. Closed with a 52-rule catalogue: a stable rule tag on every Diagnostic, set at the error site rather than derived from the sentence, plus check --output json and every independent refusal reported rather than the first (checking is already total and per-declaration, so those bodies are independent; a parse error still ends the file). §2 is the part worth reading: the rule_tag half of lex-lang’s answer transfers whole, and suggested_transform does not — its own guidelines page shows it beside rule_tag in the checker’s JSON, and in the source it lives in lex-store/lex-vcs/lex-lsp as an attestation against an op id, which is machinery cancho deliberately does not have. A fix field was designed here and cut, because the argument for it is about a consumer nobody has watched. And §3.2 is the finding: the conformance harness has claimed since M0 that “adding a rule means adding a fixture”, which was unfalsifiable while nobody had enumerated the rules — it was 43 of 52, and every_rule_has_a_fixture now keeps it
#60AGENTS.mdagent-errors.md §7’s own next row. lex-lang’s contract says a downstream repo copies AGENT_GUIDELINES.md as AGENTS.md; cancho is a different language with different rules, so it needs its own — and it had none. Measured: 90,283 words across 42 documents and no first page, so an agent asked to write cancho could read all of it or guess. Written from evidence rather than taste — the rules are ranked by how many fixtures they have (linear-value-unconsumed 19, effect-not-declared 12, reference-escapes-region 11, mode-bound-violated 11), and §6’s six facts are the ones that each cost this repository a compile or a SIGILL while porting a real program. cancho agent-guidelines prints it from inside the binary, the way --std carries the library. Every checked code block is a fixture: the valid ones compile, the refused ones are refused with the rule they name, and changing one rule name to a wrong one turns the suite red — checked by doing it
#61A line reader, answered noThe roadmap asked for std.lines because “two programs hand-roll the same loop”. Reading them says one does: tally.cho keeps nothing, wordcount.cho keeps nothing, base64.cho buffers its output, sort.cho keeps the whole input — only cut.cho reads a line at a time. A documented no, and the useful part is what the row was pointing at. examples/cut/ read a line into a 60,000-byte arena slice and the source called that “truncated”; it is worse, because a truncated line loses its delimiters, so cut switched to the no-delimiter rule and printed 60,001 bytes of the wrong field with exit 0 — a silently wrong answer, in a program checked against GNU on eight field specs, none of which varied the one dimension that mattered. Fixed by growing on the heap: 1.18× for correctness (152.9 → 181.1 ms on 14.7 MB), and the program’s authority report gains heap, which is bulk-io.md §3.2’s rule meeting its mirror and not contradicting it. One function was earned — buffer.clear, because reusing the allocation is 13.0× faster than drop plus empty per line, so it is a gap rather than a nicety
#61GPU, and whether lex-gpu should existAsked whether cancho could target a GPU, how a native one would work, and whether the GPU dialect should be its own language. The first two are design; the third looked like taste until the numbers came in. Same reduction, six ways, SIMD instructions counted in the emitted code rather than inferred from the clock: a bounds check is free (1.01×, SIMD unmoved at 10) and an overflow trap is not (1.46×, SIMD 10 → 0). So the memory half of the no-undefined-behaviour claim survives a GPU at no cost and the arithmetic half cannot — a much smaller concession than it first looked. And the one that settles the language question: deleting the trap does not reach C. cancho without traps is still scalar, 2.27× off vectorised clang and 1.55× off scalar clang, so the trap is 1.83× of its own cost and everything left is Cranelift — which means LLVM is what buys GPU speed, equally, whichever language the kernels are in. §2.1 corrects overflow-cost.md §3.2 in place: it measured one guard and generalised to “the check”
#62sqrt, and the capability questionfloating-point.md §7 had carried “std.math over floats — the capability question has to be settled first” since float landed. The capability question turns out to be the wrong question: sqrt is one instruction (sqrtsd, fsqrt), so it reaches no library, needs no Ffi, and its row is [] — arithmetic does not acquire a capability after all. What decided it was measuring the two programs that hand-rolled a square root, which is how the bar was met (newton.cho and benches/game/spectral.cho, counted by reading them). Both are wrong: spectral.cho’s twenty-step Newton loop is not correctly rounded on 58.4% of 40,008 values and is wrong by 143 orders of magnitude on a large one — 10^300 comes back as 4.77 × 10^293, because the first guess is x/2 and twenty halvings do not cross 143 decades. The benchmark never noticed: it only ever asks for the root of something near 1.27, which is cut’s long line again — the dimension the test never varied. So this is a builtin rather than library code, which is the opposite of the call float-printing.md made, for the same reason: put it where it can be correct. A correctly-rounded root is not expressible in cancho; the shortest decimal is. sin, exp and log stay open, and float abs/min/max are refused on §1’s rule — one asker with a working alternative. And AGENTS.md §6 was wrong: it said there is no unary minus, and there is; the table that said so was the one part of that document the suite did not check, which it now does
#63&! is not unique, and cannot cheaply become soAnswers no to the question README.md asks two sections after its own table had settled it. Across the 82 programs this repository builds, making &! mean Rust’s &mut refuses one fixture — written to document the behaviour being changed — so the stated cost, “it refuses programs that compile today”, was wrong, and the real price is elsewhere. There are three ways to alias a &!; two close syntactically and the third, a reference returned from a call, needs provenance in signatures, which is lifetimes and the borrow checker the non-goals exclude by name. And the fact would have no consumer even if proved: Cranelift has no noalias, only three fixed WebAssembly alias regions. §6 is the two conditions that would reopen it
#63Every check the backend emits, pricedShipped in the same PR, and it corrects two documents at once: overflow-cost.md §3.2 said “the check” having measured one, gpu.md §2.1 narrowed it to the overflow check having measured two, and six of eight loop-body checks take the SIMD count to zero. The axis is neither memory safety nor arithmetic — s[lo..hi] and s[i] are both bounds checks and cost 2.16× and 1.00×, and the same two comparisons on the induction variable cost 0.99×. Provable is free, and loaded is not. The worst check in the language is truncate(f) at 3.35×, which nothing had looked at
#64Poison, and where the thesis livesWhether a per-lane flag reduced at a boundary costs less than a trap, which gpu.md §5.1 had left as one number. It is three. On a wide vector ISA poison is a large win for every per-element check — a << b and -x become free and truncate(f) drops from 3.28× to 1.33×. On the overflow check carried by a reduction it does not help and is worse, because four lanes compute four different partial sums, so the flag records a different question. A control kernel falsified the first explanation offered for that: the barrier is the spelling, since the same test written as sign logic vectorises. And on baseline SSE2 the whole win disappears, so the answer is a property of the target rather than of the semantics
#65File handles, and three names that cost more than the designopen_read, file_read, file_close, a res File and a three-constructor Read, so the end of a file and a failure stop being two meanings of -1. The milestone was smaller than filesystem.md §3 advertised — two of its three questions were already answered by machinery that exists, and a forgotten file_close is a compile error with no new rule. Building it found three things the design had not: Result[File, int] cannot be a builtin’s signature (Result is std, and std is opt-in), the label carries no path because the path is spent at open_read, and three new prelude names cost 31 fixtures plus the extern fn read in examples/serve/
#66sort through a handle: the I/O went, the time did notThe port that tests the previous row. Reading through a handle takes 1.00× the I/O the old path took 2.75× of, and the wall clock moves 0.932× — three times less I/O bought 7%, which is bulk-io.md §4.1’s correction arriving from the reading side. What handles are actually worth is the code they delete: an 8 MiB ceiling and the exit status that existed only to report it
#67The backend, audited rather than rememberedFive documents gate the roadmap’s largest row on four claims about Cranelift, and only one had ever been read from the source. All four hold, checked against cranelift-codegen 0.121.2. The fifth fact is why it was worth writing down: is_call() and can_trap() are adjacent clauses of one four-line predicate, so this project’s checked purity proof (worth 158×) and its defined behaviour (costing 3.35×) are blocked by the same function. Also: sadd_overflow is pure — it is the trapnz beside it that pins the loop — and Cranelift’s SIMD is an input language for WebAssembly, with no pass that makes vectors out of scalar code
#68Effect polymorphism, answered noThe roadmap’s last language row, and the only one with no document behind it. 537 functions here write a row and not one could be polymorphic in it — not does not need to be, could not. A row is fixed at the declaration and read straight off it by every caller, and the two things a function is generic over, types and regions, cannot select a callee. The nearest asker, io.write_all against io.error_all, differs by the builtin as well as the row, so collapsing it needs a function value: row polymorphism is the shadow of higher-order code, which Rule::NoFunctionValues refuses with a fixture
#69The hash rate, measuredThe number canonical-ast.md §8 said nothing was measuring, and the one the lex-vcs row below was gated on. It is two numbers and they disagree. The encoder moved 20 times and the golden fixtures observed none of them, because they landed after the movement stopped — the instrument has never been exercised. The language moved enough that 71% of this repository’s own 117 .cho revisions no longer type-check under today’s build, and a single label rename (io → io_read/io_write) is 42% of that. A hash-keyed VCS inherits the second rate, so what to wait for is a plateau, not a number
#70A character, written as a character'a' is the integer 97: a third spelling, after decimal and hexadecimal, so no new node kind and no moved hash. 121 places in this repository spelled an ASCII character as a decimal number, and three of them already carried the translation in a comment — the clearest evidence a notation is missing, since the information exists, a human typed it, and nothing checks it against the number beside it. Counting decided the type rather than taste: 91 of the 121 sites want an int, because putchar and getchar mirror libc, and the commonest site is 48 + n % 10, seventy-five times. So it is a spelling, not a type — bitwise.md §1.1’s rule applied a second time — and §2.1 states the half it does not buy: the 30 byte sites keep their byte_of and gain only a readable argument. Two sites keep their numbers, the vertical tab and the form feed, because the escape set is strings.md §4’s and neither has one. This row also fixes the table above: six slices had landed without one, and two rows linked to the wrong pull request, because a PR carrying two slices was given two consecutive numbers
#71The checks, read out of the binarycheck-cost.md is subtitled “what every check this language emits costs” and it measured C: each kernel in benches/guards.c carries a comment naming the cancho check it stands for, and not one had been read against the machine code. Six of the eight proxies are exact — the author knew the unsigned trick and used it. The two that are not include the headline: the C guard tests the float range with two ucomisd on every element where Cranelift emits cvttsd2si and one cmp $0x1/jno, because the conversion answers a sentinel that rax - 1 overflows on — so 3.35× prices a guard this language does not emit, and the worst check in the language does not survive as a ranking. Division is backwards twice: §5 said both operators emit nothing, and each emits exactly one comparison, not the same one — 1 / 0 dies with SIGILL from its own ud2 and 1 % 0 with SIGFPE from the hardware. int::MIN % -1 answers 0, where defined-behaviour.md §2.3 said it traps, borrowing the quotient’s reason for an operator that produces no quotient — the compiler was right and the rule was wrong. And the constant folder disagreed with the backend about exactly that, so one expression was a compile error written down and a 0 computed. Writing the probe also found a codegen bug: a folded byte-returning call put an int-shaped literal where the backend expects one machine byte, and int_of(g(65)), g(65) == byte_of(66) and returning it each failed the Cranelift verifier with no span and no rule tag. Five documents also had the check’s name wrong — it is truncate(f); int_of widens a byte and emits no check at all
#72FlagsThis list carried “flag parsing” under ordinary work, blocked by nothing on the assumption that the two programs which parse flags would each be a little shorter with a library. They are not too long. They are wrong: of the twelve spellings GNU accepts across the two ports, six disagreed. cut refused four of them loudly — -d ,, -d, -f 2, --delimiter=, and a trailing --. base64 answered --decode and -di by encoding its input and exiting 0, because --decode is not two bytes so the test for -d was false and the flag was dropped. And every base64 test in the suite passed exactly -d: twelve input sizes, both directions and three malformed inputs, all through one spelling of one flag, which is line-reading.md §1’s shape again with the spelling of an argument in place of the length of a line. std.flags is a cursor, not a getopt_long table, because a table has to know which options take values and then resolve GNU’s abbreviations against itself; here the program says when it wants a value, so there is nothing to search and nothing to disambiguate. Three functions — start, step, value — and the nine shapes of an argument. Both ports now agree with the real coreutils binaries on every spelling but the three §4 keeps on purpose, and those are asserted to still diverge so one that quietly starts agreeing gets moved rather than forgotten
#72The first pageagent-errors.md §1 measured what the machine audience had and AGENTS.md answered it. This is the third audience — a person deciding whether the project is worth their attention — and the instrument arrived on its own: a reader outside the project wrote a fifteen-proposal design document, of which eight were already shipped with a document behind each. Its phases 1 through 4 are done and it opens at phase 1. A proposal that re-proposes eight shipped features is a measurement of the first page, and a better one than any this project could have invented, because nobody wrote it to be one. The cause is countable rather than aesthetic: in 572 lines the words sandbox, audit and evidence appeared zero times, agent in the execution sense zero (both hits were about AGENTS.md, the writing guide), and lex-os once, inside a parenthesis about narrowing — so the three proposals that live in the runtime could not have been known about, and all three were re-proposed. The shape said something else again: “Performance expectation” was 180 lines, 31% of the file, the largest section by far and about the weakest thing here, while “What exists” was one 70-line block of prose. The rewrite adds an ecosystem map that did not exist — three repositories, one idea, two of them wired together, and cancho sharing no code with either, which is the integration the proposal assumed and this page now denies in writing. Plus the two joins as rows: lex-vcs, gated on a plateau, and .cho under a lex-os grant, not built. Nothing about the language changed. Two tests keep it honest: every local documentation link resolves — run by hand at the end of every slice until now — and the README’s surface block matches --help, which caught layout and agent-guidelines missing from the page and check having lost its --output json
#73Under a grantREADME.md and authority.md both said authority --output json existed “for a supervisor checking it against a grant”, and no supervisor had ever been asked. Measured against lex-os’s real grant — {filesystem, network, exec} plus an egress allowlist — one dimension is enforceable and more precisely than the grant can express, because fs_read(p) carries a path where the grant has a tri-state. Two are not enforceable at all: examples/serve/ binds a TCP port, listens, accepts and answers HTTP, and its report is args and ffi. reach.md §5 argues correctly that the row is not wrong; what is new is that against network: None it is unable to refuse, and the only program here that touches the network is the one it cannot refuse. §3 removes the rescue and corrects reach.md §5.2, which said the foreign symbol list tells a supervisor what it is running “without trusting a word the program says about itself”: the list is a proof about names and a heuristic about domains, because the step from a name to a domain is the declaration and the declaration is the program’s to write — six lines of extern fn syscall type-check, open a socket and report ["syscall"]. §4 is the diagnosis in one sentence: Ffi(lib) is the one capability whose label does not bound what it authorises, and the filesystem dimension works precisely because filesystem.md §2 already took files out of libc. So reach.md §6’s Net(host) row is promoted: not a refinement of the report but the prerequisite for cancho code running under a grant at all. And the join itself is smaller than it looked — no compiler integration, no embedding the front end; --output json is already the interface
#74The networkThe design for the row the previous slice promoted, written before the code the way filesystem.md was — and the probe found that reach.md §5.1’s framing describes neither side. “A host is a thing worth narrowing to” is the outbound question. examples/serve/, the only network program in this repository, declares socket, setsockopt, bind, listen, accept, read, write, close — pure ingress, and no connect anywhere in the corpus. Read the other side: a lex-os grant’s network fields are network and egress, and both are outbound; there is no inbound notion in it at all. So the one program cancho has does the direction the grant does not describe, and the grant describes a direction cancho has no program for. §2 argues the asymmetry is correct: outbound is the program’s choice — it picked the host, so the authority question belongs in the type — and inbound is not, because who reaches a listening port is decided by routing and never by the program, which is exactly why lex-os’s second wall is iptables on the tap device rather than a grant field. Net therefore has two axes: net_out(host:port), narrowing by prefix the way Fs does, and net_in(port) bounding a port and nothing more. §3 keeps filesystem.md §2’s decision — the operations are builtins, because leaving them extern fn gates them by Ffi("libc") and Net contributes nothing. And §5 is the uncomfortable count this project’s own rule demands: inbound 1 asker, outbound 0. The half that unblocks the lex-os join is the half nothing here asks for, so this ends at settled, not built, and the next step is a program that connects rather than an implementation of a design no program has tested
#75Fail closed, and creditAn outside audit’s two cheapest correct items: the authority report now leads with "bounded": false for any program reaching foreign code, so a supervisor that reads nothing else refuses by default (under-a-grant.md §5.1); and a related-work document crediting what this project took and who got there first — Austral above all, which the repository had never mentioned
#76The folder against the backendThe audit’s C2(b), and the first item of its recommended order. fold.rs said “bin_traps_exactly_where_the_backend_does checks them against a running program” — a test that never existed, while the one disagreement it would have caught (int::MIN % -1, #71) shipped. Now every operator the folder evaluates, on every pair of 14 boundary operands, is computed three ways — folded during lowering, folded through a call, and at run time on operands neither optimiser can see: 5,156 cases, 0 disagreements, the same 436 traps on both sides. Five deliberately broken folders were each caught, #71’s bug in one case. Writing it found the fault it was not built for: bits_of(0.0 / 0.0) was 0xfff8… on x86-64 and 0x7ff8… on aarch64, so compile-time.md §6’s “the same value on every host and every target” was false for one builtin. bits_of now gives every NaN one pattern — two instructions, no branch — and since it is the only operation that can see a NaN’s bits, the sentence is true again, corrected in place
#77A program that connectsThe second item of the audit’s order, and the step net.md §5 named instead of building Net. examples/fetch/ is an HTTP/1.0 client, and the suite points it at examples/serve/, so a cancho client fetches from a cancho server. It found four things. No names: getaddrinfo answers a pointer, so a program only ever connects to an address while a lex-os grant only ever names hosts. The destination is data, so net.md §4’s literal is a bound checked at run time, as Fs(prefix) is, corrected in place. struct sockaddr_in is 2, 0 on Linux and 16, 2 on macOS, and both network programs work on macOS only because BSD reads family 0 as AF_INET. The first version said connect would refuse the Linux bytes, reasoned from memory, and the darwin runner refuted it. errno is a pointer, so why a connection failed is unknowable. It found no need for a socket type. Outbound askers: 1, so Net stays settled, not built. The test written to fail on this day is now a count
#78When the compiler is wrongThe audit’s C2(c). Measured by putting #71’s bug back for one build: check answered { "refused": [] } with exit 0, and build printed an unlocated “code generation failed” with exit 3, the code the CLI documents as the environment failed. So a compiler bug read as the machine’s fault, and the machine-readable gate passed a program that would not build. Codegen’s 22 invariant sites were bare Rust panics, exit 101. Now a backend failure is a refusal with rule internal, located at the function whose code failed, exit 1. Each function’s code generation runs under catch_unwind, and check runs the backend too, at about 2× its old time, a few milliseconds per real program. Tested without a bug to test on, by breaking one function’s IR by hand
#79Files, and a budget for themThe audit’s C1. cancho-ir/src/lib.rs had reached 10,060 lines, one slice at a time, with nothing to say no. It is now a table of contents over ir, builtin, defs, function and lower/, where impl FnLowering is split across child modules that see its private fields, and nine unit-test files declared with #[path] so no test changes name. A pure move: the same 228 test names before and after, and every line of the old file present in the new ones apart from formatting. The largest file is now 1,594 lines. tests/files.rs refuses any source file over 2,000 lines (rustc’s tidy uses 3,000). The two files already over it, codegen (2,886) and conformance.rs (6,684), are held to those numbers as ceilings that may only come down. CONTRIBUTING.md writes down the gate, how a slice is done and the conventions
#80Files, and a budget for them#79’s two ceilings, retired. cancho-codegen (2,886 lines) becomes abi, emit, layout and body/, with impl BodyEmitter split across child modules the way FnLowering was. Its largest file is now 688. The conformance suite (6,684) becomes tests/conformance/main.rs and seventeen topic modules, still one binary. Its item boundaries were found with a scanner that tracks strings and comments, because embedded cancho sources put fn main at column 0. Both are pure moves: the same test names, 8 and 104, and no line of either old file missing. Two codegen doc comments were already wrong and are corrected: one still said every error there is the environment’s, which #78 made false. CEILINGS is empty. The files nearest the line are parser.rs (1,997) and cancho-id (1,909)
#81Fuzzing the compilerThe audit’s C2(a). tests/fuzz.rs is a stable-Rust mutation fuzzer, part of cargo test: it seeds from all 331 .cho files, makes one to three token edits (half of them replacing a token with one of the same kind, so a mutant is usually a different program rather than a broken one), and requires three things of every mutant: nothing panics, what parses prints back to the same tree, and what the checker accepts the backend compiles. 44% of mutants parse, 9% check and 8% compile, and the test asserts floors so a shallow run cannot pass. Over 450,000 distinct mutants the checker and backend found nothing. The printer failed four ways: (e,) printed as (e), turning a refused program into an accepted one; a label argument such as ffi("a\n") printed unescaped and did not reparse; since #43, prefix and postfix operators bound no tighter than *, so -(a / 10) printed as -a / 10 (UNARY and POSTFIX are now derived from the operator table); and 1 . 2, component 2 of an integer, printed as the float 1.2, a fixed point only the tree comparison could see. tuples.md §2.1 said a one-tuple could not be written; it could, and one_tuple.cho now pins its refusal
#82Function values, not yetThe audit’s L1, written before anything forces it. No program here asks: of 594 function bodies, the only pair differing by nothing but its callee is io.write_all against io.error_all, the one sort has one comparator, and none of the 22 enums picks an operation. A whole program can always write a function value as an enum and a match, which is what it would do instead, and the fixtures measure the price: the row becomes the union of the arms’ rows. The decisions: no closures, since a captured capability is authority no parameter names; function values, when an asker arrives, are captureless, non-generic, val, with an exact row in their type. That changes no row rule and brings no row variables, which corrects linearity-and-effects.md §10’s prediction. Callbacks into C need row [] and scalar parameters, which leaves atexit and signal and not pthread_create. Corrected (#127): built. The asker was threads.md’s own spawn, not one of the three §6 named – Type::Fn, Expr::FnValue/Expr::CallIndirect, and codegen on both backends, exactly as designed here, with Rule::NoFunctionValues narrowed to refuse only a generic function’s address rather than every one
#83Who resolves namesconnect.md §1 found that a cancho program only ever connects to an address while a lex-os grant only names hosts, so whoever resolves owns the check. Decided: the builtin, because cancho has to be usable without lex-os: under the perimeter answer a standalone program could use no names and nothing would narrow where it connects. connect takes a name and a port, checks the name against its capability’s bound, then resolves it. Under lex-os the firewall stays as the outer wall, and the perimeter writing its pinned addresses into the guest’s hosts file makes both resolvers agree without DNS egress. The perimeter answer was considered first, and §4.1 says why each of its reasons does not decide it
#84Parse errors as JSONcheck --output json answered invalid JSON for every parse error, 20 of the 214 reject fixtures. The message was the whole rendered refusal, newlines included, and the writer escaped only quotes and backslashes. It also gave those refusals position: null though the span was known. Now the message is the sentence, the position is resolved, and control characters are escaped everywhere JSON is written, authority included. The existing test compared one line of each message, so it could not see any of this; a strict JSON reader now reads every fixture’s answer. Found by replaying the repository’s history through the compiler
#85Editions, measuredThe audit’s L2, designed before Net needs it: adding net to Split would break all 210 files that destructure it. scripts/history.py replays the repository’s history through today’s compiler: 58 of 141 revisions no longer check, 45 of them from the io split. Reading an old io as io_read, io_write recovers none, because rows are exact and the right half depends on each body. A checker-driven migration tool recovers 30, including 15 broken only because Split grew. The rest are a capability parameter and linearity tightenings, which should stay breaks. So an edition holds open only additions, which can simply be absent in older files. A refining change ends support for earlier editions, with cancho migrate as the fix, and a tightening is always a refusal. Edition 1 adds no bytes to identity, and edition 2 will be edition 1 plus Net. Nothing is built yet: the marker lands with Net, after parser.rs (1,998 lines) is split (corrected by #87: the marker shipped ahead of Net, not with it)
#86Files, and a budget for themparser.rs (1,998 lines) becomes a table of contents – parse, parse_into, Parser and the token plumbing every child calls – plus items, stmt and expr, with impl Parser<'a> split across them the way FnLowering was. A pure move, verified by token multiset rather than eyeballed: every token of the old file is present in the new ones, and the only tokens added are the pub(crate) 42 methods needed once they cross module boundaries, the three mod declarations, and the impl wrapper each child needs. The same 61 test names survive, moved to parser/tests.rs with #[path]. The largest file is now 531 lines. Prerequisite for Net (editions.md), which adds a field to Split
#87Editions, measuredThe marker itself, ahead of Net after all – editions.md §8 said otherwise and is corrected above. edition N; is a per-file marker, checked once ahead of module/import the same way static is a contextual keyword: it is an ordinary identifier everywhere else. Absent, a file is edition 1 forever, which is why Ast gained an item_editions side table parallel to item_modules rather than a field nothing but this needed to know about. Only edition 1 is known today, so edition 2; and later are refused under a new rule, unknown-edition – there is nothing to name yet, since Net and the per-edition Split are the next slice. Unobservable from any program a file can write until they land
#88A second outbound programconnect.md §6 and CONTRIBUTING.md rule 3 put the bar for a feature at two askers per half, and outbound had one – so building Net’s outbound half now, as the roadmap otherwise had it queued to do, would have been the discipline it names quietly skipped. examples/report/ is the second: an agent that posts a result, POST <path> with a body, rather than a generic client. It found nothing new about the design questions §1-§4 already settled – but sending a body large enough to matter, which fetch/ never needed to, found two of its own bugs: a region is one 64 KiB arena chunk, and copying a large message into one before sending it could trap; and the header buffer, once that was fixed, was five bytes short of what its own Content-Length digits needed. Both are bounds checks catching a real mistake, not a silent one. Outbound is now 2 and has cleared its bar; inbound is still 1
#89A second inbound programInbound’s half of the same bar, cleared the same way. examples/collect/ accepts a count of connections in a loop and reads each POST’s body by Content-Length, the two things serve/ never had to do, streamed to standard output rather than materialised for the reason docs/connect.md §8 already paid for. Found nothing new in the program – but found a real deadlock in its own test: collect’s stdout is a pipe smaller than a 100,000-byte body, and reading it only after the HTTP exchange finishes blocks forever the moment the pipe fills, since the exchange cannot finish until the write does. Draining it concurrently, on its own thread, is the fix. Both halves of Net have now cleared the bar. Writing a second corpus file also perturbed the fuzzer’s fixed-seed mutant sequence enough to land on fib(1_000_000), which overflowed the compiler’s own stack: the compile-time evaluator’s recursion-depth guard (fold.rs’s DEPTH, 128) was never measured against a debug build’s 2 MiB test-thread stack, only against fuel. Bisected to 68 safe / 72 not; DEPTH is now 32, with a regression test that reproduces the constrained stack directly rather than waiting on the fuzzer to land there again
#90Net, slice 1Building Net turned out to need more than the marker #87 shipped: nothing in the compiler consumed Ast::edition_of outside the parser’s own tests, so making edition 2 real meant threading it through every place a written name resolves, not only resolve_type_at. Agreed mid-implementation as three slices (connect.md §9): this one, name resolution, then inbound. Built: Net(bound), narrowed like Fs(prefix) (plain prefix, no /-boundary rule); edition 2’s Split, a second declaration of the source name Split rather than a sixth field on the existing one – Split is res by inference, so a field nothing edition-gated could unname would still be consumed by every one of the 210 files that destructure it, exactly the break an addition must not cause; net_out, discharged by an owned World the same unnarrowed way ffi/fs_read/fs_write already are; and connect(net, a, b, c, d, port), which builds the same sixteen-byte sockaddr_in, Linux layout, that examples/fetch/’s address builds by hand, once, in the backend. Three more raw defs.iter().find(name ==) lookups turned up beside the one already fixed – a struct literal, a variant constructor, a destructuring let – each needing the same “latest declaration this file’s edition can see” answer or an edition-2 let Split { .. } = split(world) resolved its pattern against the wrong Split. examples/fetch/ and examples/report/ are not ported: both still need read/write/close on the socket connect opens, which stay extern fn against libc until a later slice, so porting now would widen their report, not narrow it
#91Net, slice 2Slice 2 of 3: connect changes shape from slice 1’s connect(net, a, b, c, d, port) to connect(net, name, port), the shape §4.1 actually decided – octets were only ever a stand-in for “no resolver exists yet”, and getaddrinfo accepts a numeric address exactly as it accepts a symbolic one, so one builtin now covers both. The bound is "host:port"; split once, at compile time, into a host prefix (checked_host, checked_path’s shape minus the /-boundary and .. rules neither Net nor a host name has) and an exact port – a narrower capability names one port, not a prefix of them, so that half is equality and traps before the host is even copied. First build attempt found the bug this design avoids by construction: comparing the dialled host against the whole "host:port" bound trapped on every call, since a 9-byte host is shorter than an 11-byte bound that includes its port. struct addrinfo’s layout (48 bytes, ai_flags/ai_family/ai_socktype/ai_protocol/ai_addrlen/ai_addr/ai_canonname/ai_next) is asserted from POSIX’s <netdb.h> rather than measured the way sockaddr_in’s was, since it was standardised after both supported platforms already existed; getaddrinfo is asked for AF_INET/SOCK_STREAM only, and the backend patches only the resolved sockaddr’s port bytes – the two bytes connect.md §3 already found portable – rather than reconstructing the address itself, since the resolver already wrote the platform’s own correct family bytes. examples/fetch/ and examples/report/ are still not ported, for the same reason #90 gives: neither gap (read/write/close still on Ffi("libc")) closes until a later slice. Fixed on darwin CI, in place: struct addrinfo’s ai_addr/ai_canonname swap byte offsets between glibc and Darwin/BSD’s original RFC 2553 order, caught only by the darwin-aarch64 runner
#92Net, slice 3Slice 3 of 3, the last of the three agreed: bind(net, port), listen(fd, backlog), accept(fd) and the net_in label. bind folds socket, setsockopt(SO_REUSEADDR) and bind into one call, the inbound mirror of connect’s socket+connect, and builds the same struct sockaddr_in examples/serve/ builds by hand – INADDR_ANY’s eight zero bytes where connect’s has four octets, since a listener binds every address the host has. listen and accept take no capability – the fd is what bind already proved the authority for – so both are ordinary fixed signatures, checked the way Builtin::Close is, not the way connect and bind are. A Net narrowed for bind carries just the port as text, net_in’s bound checked by equality the same way connect’s port half already is; only bind performs net_in, listen/accept perform nothing, the same reasoning close’s empty row has. Writing bind’s port check the same shape as connect’s surfaced a bug already latent there: a bound whose port half does not parse as a number was read the same as an absent port, silently permitting any port instead of refusing all of them – both now read a malformed half as -1, a port neither builtin can ever be asked to dial, so a malformed narrowing refuses every call rather than none; this narrows what was promised, never what was reachable. Testing the new builtins against a real accepted connection found a genuine, pre-existing latent bug: every extern fn in this compiler crosses a cancho int as 64 bits regardless of the C function’s own width, while bind’s internal failure-path close (like open_file’s) is declared at libc’s true 32-bit width – one linker symbol, two declared signatures, which Cranelift correctly refuses when a program declares its own extern fn close alongside either. Not fixed here – close is one call a short-lived test process does not need, since the OS reclaims both descriptors on exit – recorded as a known exposure shared with open_read/fs_read/fs_write since before Net existed
#93fasta and reverse-complementbenchmarks-game.md §2.1’s last deferred row, unblocked once the Game’s own spec turned out to be reachable this session. Both are checked byte for byte against the Game’s own N=1000 reference output, and the measurement corrects a prediction rather than confirming it: bulk-io.md §4.1 guessed fasta would land nearer base64’s 1.59×; it measures 0.54× – faster than C – because its C reference writes with putchar per byte, the ordinary algorithm the Game’s own rules require, while fasta.cho has no primitive cheaper than io.write_all, so the ordinary cancho program is the bulk one. reverse-complement, whose read side is getchar either way there is no bulk read, lands at 1.19×, inside the range the other five programs already described
#97LLVM backend, slice 1The first slice llvm-backend.md §5 (#96) named: cancho-codegen-llvm, textual LLVM IR shelled out to clang -c, behind an additive `–backend cranelift
#98LLVM backend, slice 2Checked arithmetic: every trapping BinOp (Add/Sub/Mul via LLVM’s own with.overflow intrinsics, Div/Rem, Shl/Shr) plus the three bitwise operators, which cannot trap. A real correctness finding: LLVM’s sdiv/srem are undefined, not trapping, on a zero divisor or int::MIN / -1, unlike Cranelift’s, which trap on both – the obvious translation would have been silently wrong on exactly the two inputs this language exists to make defined, fixed with explicit checks ahead of the instruction. Every trap raises the signal #97 already measured, checked by seven tests that read .signal() rather than only status.code() == None – §3.2’s own critique of the existing suite, applied here instead of left as someone else’s gap. A methodology finding while writing those tests: docs/compile-time.md §3 folds a literal-pair operator at compile time into a refusal rather than a runtime trap, so a naive test like 9223372036854775807 + 1 never reaches this backend at all – every trap test instead routes one operand through an impure putchar-calling function the folder cannot evaluate. Comparisons lower too but stay untested end to end: this language has no way to observe a bool outside if/match, named explicitly now as the next gap. tests/accept/llvm_arith.cho exercises all ten non-short-circuit operators once each, the exact printed bytes proving the values rather than only that clang accepted the module
#99LLVM backend, slice 3Control flow: Stmt::If/Stmt::While plus the two short-circuit operators, And/Or – ir.rs’s own comment already said these belonged here rather than in binop’s instruction table. No phi needed, despite §5’s own text once assuming one would be: every local is already an alloca (the first slice’s own design, for clang’s mem2reg), so the block after an if just loads whatever the taken arm last stored, and a while’s back edge is re-entered the same way – fresh loads each time, nothing carried in a register across the edge. &&/`
#100LLVM backend, slice 4Slices and strings: Expr::Bytes/Expr::Len/Expr::Index, plus Builtin::IntOf – necessary plumbing hello.cho needed the same way putchar was necessary plumbing for #97. examples/hello.cho now builds and runs through --backend llvm, byte for byte the same as Cranelift – the loop the first three slices opened (each found hello.cho needed one more thing than assumed) is closed. Two things came out cheaper than the design doc assumed: a string literal needs no instruction, only a declaration, since an LLVM global symbol is already a usable ptr constant wherever Cranelift’s global_value needs one; and bounds checking is exactly one more trap_if, the same shape checked_shift’s range check (#98) already was. One thing did not come free: docs/compile-time.md’s constant folder does not reach Index at all, unlike its own finding about BinOp in #98 – a literal out-of-range index passes cancho check and only traps at run time, so this slice’s bounds-check tests use a literal index directly, with none of #98’s probe-routed workaround. Place::Element (s[i] = e) lowers too, by the same element_address the read side uses; Place::Field/Place::Deref stay refused, waiting on struct/box layout
#101LLVM backend, slice 5Structs and enums: Expr::Struct needed almost nothing new – a struct value is positional already, so its leaves are just every field’s leaves concatenated, the same shape Expr::Tuple would be, and this backend already read a struct this way through Expr::Field (#100). Expr::Enum/Stmt::Match matched cancho-codegen’s own layout exactly: an enum’s leaves are a tag followed by every variant’s payload, not only the constructed one – variant_layout computes one variant’s offset into that whole, shared by enum_lit (writes the tag and the constructed variant, zeroing the rest) and bind_payload (reads the same offsets when a match arm binds them). match_stmt lowers to a chain of icmp eq tag tests. One real subtlety: a match whose every tested arm returns still needs a merge block, unlike if_stmt (#99), which omits one – if/else are syntactically exhaustive, a match is only exhaustive because the checker proved it, so a chain with no wildcard arm still has a fall-through the IR does not know is impossible, and it needs a legal terminator (emit_default_return, factored out of emit’s own fall-through in this slice). Deliberately not in this slice: matching through a reference and Place::Field/Place::Deref, both needing pointer arithmetic into a referent – not needed to make tests/accept/enums.cho build, since its own match binds an owned Point read back through Expr::Field
#102LLVM backend, -O2 and a first measurementAsked to benchmark the two backends and found a bug first: run_clang never passed clang an optimisation level, so mem2reg – which the whole memory-backed design leans on – never ran, and the backend was slower than Cranelift on every kernel that builds on both. Fixed (-O2, always). Measured after: on the three benches/ kernels that build on both backends today, LLVM is 8%-54% faster, and on mandelbrot.cho it is indistinguishable from C (0.96x-1.00x) where Cranelift reproduces against-c-and-rust.md’s original 1.6x-1.8x almost exactly – answering that document’s own stated falsifier, corrected in place. Checked, not assumed, and it is not what it looks like: objdump on the -O2 object finds zero SIMD instructions – the gain is register allocation and instruction selection catching this backend up to a competent scalar compiler, not a vectoriser, and overflow-cost.md/check-cost.md’s “a trap is not reassociable” finding holds here too, confirmed on a real backend for the first time. Also inventoried, by trying to build every file under benches/ with --backend llvm: three gaps not previously named on their own – wrapping_add/sub/mul, heap boxing (box/box_slice), arg_count, Type::Float – beyond the already-documented region/alloc_slice
#103LLVM backend, wrapping_add/sub/mul#102’s own smallest-first ordering: LLVM’s plain add/sub/mul, no nsw/nuw requested, are already two’s-complement wraparound, so unlike binop’s checked forms this needs no overflow check – three match arms and a four-line helper. Every checked-vs-wrapping pair in benches/ that doesn’t also need region/box_slice now builds on --backend llvm. The measurement looked broken until it was checked: sum_wrapping.cho first reported -99.1% against Cranelift, and objdump explains why rather than excuses it – lexs_run compiles to xor %eax,%eax; ret, because the loop’s body is mathematically a no-op and, unlike the checked twin, nothing in it can trap, so LLVM proves it has no observable effect and deletes two hundred million iterations outright. Cranelift does not. fib_wrapping.cho, which has no such algebraic collapse (objdump: 21 real instructions), is the trustworthy number: -23.3%, in the same range as #102’s other kernels – overflow-cost.md/check-cost.md’s “an observable trap is not reassociable” finding, seen this time from removing the trap rather than adding one
#104LLVM backend, region/alloc_sliceThe bigger of the two gaps #102 named: one malloc in, one free out, a bump pointer in two ptr-typed alloca cells rather than an SSA value, matching cancho-codegen’s own arena exactly (same 64 KiB ARENA_CHUNK). Building sieve/scan against it found two smaller gaps sitting in front, not named until tried: byte_of and Expr::Not. Every program behind only those four gaps now builds on --backend llvm. Measured: sieve/scan are 29%-79% faster than Cranelift – the first memory-bound, bounds-checked kernels this document has compared. Checked with objdump, not assumed: the wrapping halves of both pairs actually vectorise (409 SIMD instructions in scan_wrapping.cho, 22 in sieve_wrapping.cho) where their checked twins – otherwise identical source, diffed to confirm it – compile to zero, confirming check-cost.md’s “an observable trap is not reassociable” on a bounds-checked memory scan, not only arithmetic, on a real backend for the first time
#105LLVM backend, heap boxingbox_slice/contents/unbox_slice, sharing alloc_slice’s own checked-multiply sizing and reaching for malloc instead of bump. Found a real bug along the way: Box’s own leaf count was never special-cased the way cancho-codegen’s abi::leaves_into already does, so Box[[T]] silently scalarised wrong. Building reduce_checked.cho against this found a second, unrelated gap underneath: a function could not return more than one leaf, closed in general (struct_ty/pack_struct, an insertvalue/extractvalue aggregate, the same shape checked_arith’s own {i64, i1} already is) rather than scoped to two leaves. Every program behind only these gaps now builds on --backend llvm. Measured: reduce_checked.cho – docs/gpu.md’s own kernel – is 61% faster than Cranelift, and objdump confirms the vectorisation split a fourth time (reduce_wrapping.cho: 54 SIMD instructions, reduce_checked.cho: zero)
#106LLVM backend, getchar/s[a..b]getchar, the mirror of putchar. Trying it against revcomp.cho found a second gap in front: s[a..b] (Expr::Subslice), the same two bounds checks element_address already makes, over a range. tests/accept/stdin_roundtrip.cho – the repository’s own first //~ STDIN fixture – closes the loop, piped and diffed byte-for-byte against Cranelift. revcomp.cho itself still refuses, and its boundary moved a second time in the same session: past both closed gaps it reaches std.buffer’s Buffer.clear, which needs Place::Field/Place::Deref – named since §5’s fourth slice, materially bigger than either gap closed here, and not closed. docs/llvm-backend.md §7.10 connects it to revcomp.cho explicitly for the first time
#108LLVM backend, field/deref accessThe gap #106 found and did not close: on contact, nine IR nodes rather than two – the named writes (Place::Deref, Place::Field), plus a read-side cluster nothing had built (Expr::Deref, Expr::FieldRef, Expr::FieldAddr, their tuple-shaped counterparts), plus Expr::Tuple/Expr::TupleField, never built either. All nine share two new helpers, field_offset/tuple_field_offset, computing a field’s byte offset once rather than five times over. Building revcomp.cho against this found a third, unplanned gap underneath: write_bytes/write_err (fwrite through stdout/stderr), needed once std.buffer’s own field writes stopped being the reason it refused. revcomp.cho itself now builds, runs, and matches the Benchmarks Game’s own published output on --backend llvm – the boundary that moved twice in #106 is finally past. Measured: 42%-46% faster than Cranelift; objdump finds 23 SIMD instructions, real but modest, attributed to the program’s getchar-based byte-at-a-time read side rather than this slice’s own work. docs/llvm-backend.md §7.12 leaves three gaps: bare alloc, arg_count, Type::Float – plus matching through a reference, named for the first time as its own distinct, still-unbuilt item rather than bundled with the field/deref access this slice closed
#109LLVM backend, arg_count/argargc/argv stashed once into module-local storage by @main, mirroring cancho-codegen’s own emit_c_main; arg’s bounds check the same icmp uge shape every other indexing operation here already uses, then argv[n]’s length computed with libc’s own strlen. Only one of the gap’s two named targets builds past it: fannkuch.cho does, checked against a real argument and not only the no-argument fallback; binarytrees.cho reaches bare Expr::Boxed instead, a different and still-open gap this slice does not touch – the same “a gap can block more than one program and close only some of them” shape #106 found with revcomp.cho. Measured: fannkuch.cho is 22%-28% faster than Cranelift; objdump finds 16 SIMD instructions, a smaller number than scan/sieve’s, consistent with fannkuch’s array rotation not being the loop shape those vectorise on. docs/llvm-backend.md §7.14 leaves two gaps with a benches/ program behind them (bare alloc/Boxed/Unboxed, now including binarytrees.cho; Type::Float) plus matching through a reference, still with none
#110LLVM backend, single-value alloc/box/unboxCloses the gap #109 found underneath binarytrees.cho: bare Expr::Alloc/Expr::Boxed/Expr::Unboxed, single-value allocation, arena or heap. Each one a smaller version of a primitive this backend had already built for a slice’s many elements – alloc_slice’s own bump, boxed_slice’s own malloc-and-null-check, both minus their fill loop – sharing a new value_bytes helper, the whole-value counterpart of stride_of’s per-element version. binarytrees.cho builds too now, taking both of #109’s named targets with it rather than only one – checked with no argument and with a real one, matching Cranelift exactly both times. The crate’s and the CLI’s “outside this backend” refusal tests moved from arena_roundtrip.cho (now inside the backend) to floating_point.cho, the next real boundary. docs/llvm-backend.md §7.16 leaves one gap with a benches/ program behind it: Type::Float, now the only gap left blocking more than one (spectral.cho and fasta.cho both) – plus matching through a reference, still with none
#111LLVM backend, Type::FloatThe last gap with a benches/ program behind it. The straightforward reading – add an F64 arm – was wrong: this backend’s LValue carries no type tag the way Cranelift’s intrinsically-typed Value does, so binop/Expr::Neg had no way to tell a float operand from an int one before evaluating it. scalar_kind, a new structural helper, answers that without evaluating anything, walking the same shape the type checker already agreed on. Once it existed: unchecked arithmetic, ordered fcmp comparisons (!= the one unordered exception), float_of/truncate (three checks ahead of fptosi, which is poison rather than trapping on the inputs that matter – no LLVM counterpart to Cranelift’s trapping fcvt_to_sint), bits_of’s NaN canonicalisation, sqrt, and both halves of a previously-unhandled Expr::Neg, found unrelated and closed alongside. spectral.cho and fasta.cho, both named targets, build and match Cranelift exactly – the second checked against the Benchmarks Game’s own published output. Measured: spectral(1500) 45%-56% faster; fasta(1000000) 10%-20% faster; both real vectorisation, float arithmetic never having been checked to begin with. This slice’s own diff pushed emit.rs past CONTRIBUTING.md’s 2,000-line budget – split by concern into body/{mod,arith,control,memory,expr}.rs, the same shape cancho-codegen’s own body/ already is, a pure reorganisation confirmed by rebuilding every fixture and benches/ program before and after. The refusal tests moved a second time in one slice, from floating_point.cho (now inside) to match_a_reference.cho. docs/llvm-backend.md §7.18: one gap left, and it has had no benches/ program behind it since §5 – matching through a reference
#112LLVM backend, matching through a referenceThe last gap this document tracked with any target behind it. Unlike every slice before it, close to what it looked like on the surface – §7.11’s getelementptr-address idiom and the fifth slice’s variant_layout/bind_payload had already built everything it needed. A reference is always one pointer leaf, so the scrutinee evaluates the same way in both modes; only the tag read differs (one more load by reference), and bind_payload gained the address-only mirror of its by-value half – variant_layout’s own offset scaled to bytes via getelementptr, stored as an address rather than a loaded value. Unlike Cranelift’s debug_assert_eq!, this backend returns an Err on the “one leaf per by-reference binding” invariant, matching its own no-panic convention. tests/accept/match_a_reference.cho and examples/tree.cho – the richer target docs/reading-references.md §4 names by name, a three-field variant matched three separate ways – both build and match Cranelift exactly. A &! unique-reference write, with no fixture anywhere in the repo, was checked by hand instead. Every gap this document names a target for is now closed – the refusal fixture moves a fourth time, to tests/accept/bytes_to_c.cho, refusing on a foreign call
#113LLVM backend, listen/acceptThe first slice with no benches/ program or tests/accept/ target behind it – every gap this document tracked closed with #112, so closing Net itself needed scoping directly, smallest sub-piece first, ahead of connect/bind rather than after them. Neither listen nor accept takes a capability – a fd’s authority was already proved once, at bind, a dedicated Expr::Bind IR node rather than a Callee::Builtin call – so both are ordinary fixed-signature libc calls, no different in shape from Sqrt: narrow the int argument(s) to i32, call, sext the result back to i64. connect/bind and Ffi/extern fn are still refused, confirmed empirically – a real edition 2; program’s bind(n, port) names Bind specifically on --backend llvm while running to completion on --backend cranelift – so there is no way to get a real bound fd out of a --backend llvm program yet, and tests/accept/listen_accept_bad_fd.cho checks both builtins against a deliberately invalid one instead: EBADF, matching on both backends, with no real socket or live connection needed
#114LLVM backend, bindThe second of Net’s four builtins, after #113’s listen/accept, and the first that reaches a real fd rather than a deliberately invalid one. Folds socket, setsockopt(SO_REUSEADDR) and bind into one call, the same struct sockaddr_in cancho-codegen’s own bind builds by hand – family bytes 2, 0 needing no platform branch, the same fact #77/connect.md §3 already measured. The one genuinely new shape: socket/bind can each fail, returning -1 rather than trapping (only a bound mismatch traps, checked first) – the first --backend llvm expression needing a value conditional on which of three runtime paths ran, where every earlier slice either trapped (nothing to merge) or was a Stmt-level if writing into an already-alloca’d var. Solved with a plain alloca i64 result cell, written in each branch and loaded once at the merge label, the same “no phi” rule if_stmt/while_stmt already follow rather than a new one. Checked against a real accepted connection: a new differential test builds a listener on each backend, connects a real TcpStream from the test process, and checks both exit 0 – the first Net-capable program --backend llvm has ever actually run. connect (getaddrinfo-based host resolution, genuinely larger) and Ffi/extern fn are still refused, so nothing built yet can read or write what it accepts
#115examples/seek, an agent-shaped toolNot a GNU port like base64/cut/sort – no upstream this is checked against, because the question is not compatibility. docs/agent-tools.md asks whether the properties this repo already built for other reasons – a legible authority report, no silent truncation (line-reading.md’s own finding about cut), no UB on adversarial input – add up to a tool genuinely better shaped for an agent’s own tool-use loop than a straight port would be. A literal-text search over named files, built on std.bytes.find and the same heap-growing buffer/open_read/file_read machinery sort.cho/cut.cho already use. cancho authority on it names exactly seven labels and bounded: true, pinned by agent_tools.rs. Two honest limits found building it: M2’s narrow takes a literal, so no flag can narrow this tool’s Fs to a directory at invocation time – a supervisor gets a scoped copy by substituting the literal at build time, not a --root flag, the same shape #114’s own bind test already uses to substitute a port; and -m caps matches across every file rather than per file the way GNU’s own does, because an agent bounding output is bounding its own context, not any one file’s share of it. Checked on a file containing a NUL byte and a lone 0xFF, found and printed byte for byte with no crash and nothing checked for UTF-8
#116LLVM backend, connectThe last of Net’s four builtins, after #113’s listen/accept and #114’s bind – genuinely the larger remaining piece, as #114 predicted. checked_host (mirroring cancho-codegen’s own function) is this backend’s first loop built for Net: a dialled name copied into a 256-byte stack buffer while its prefix is checked byte by byte against the capability’s bound, in the same “cursor in an alloca i64 cell, no phi” shape the arena-fill loop already established – not a new mechanism. connect then builds a struct addrinfo hints, resolves with getaddrinfo, patches the port the same big-endian way bind already does, and calls socket/connect – three failure points funnelled into bind’s own one-result-cell shape. Checked against a real accepted connection, both directions: a differential test connects each backend’s client to a plain std::net::TcpListener, and this slice’s own session built an all-LLVM listener-and-client pair talking over real loopback – the first time two programs this backend built have ever talked to each other. The two bound-mismatch traps (host outside the prefix, the exact-port check) are checked by signal, matching Cranelift. Net is now fully built on --backend llvm. Ffi/extern fn is the only gap left – what a program needs to read or write what it accepted or connected to, not merely open the socket
#117LLVM backend, Ffi/extern fn, and a correctionCallee::Extern mirrors cancho-codegen’s own arm – a capability parameter never crosses to C, everything else crosses at cancho’s own widths (an int is i64 regardless of the C function’s own parameter width) – reusing Callee::Fn’s own call-and-unpack shape, factored out as emit_call once both needed it; scalar_kind gained the Callee::Extern arm it was missing. Checked against tests/accept/bytes_to_c.cho unmodified (a &r [byte]-crossing write) and a fresh labs(-5) == 5 on both backends. Checking real programs against this slice found #116’s own “Ffi/extern fn is the only gap left” claim false – Fs (fs_read/fs_write/open_read/file_read) had been unbuilt the whole time, unnamed because no slice had tried a real Fs-using program against this backend until examples/seek/ did here. Corrected in place across lib.rs, llvm-backend.md, docs/README.md and README.md rather than left standing. The boundary fixture moves a sixth time, to tests/accept/file_handle.cho. A second finding: a user-declared extern fn naming a symbol this backend already declares for Net (socket et al.) at a different width makes clang correctly refuse to link – not new, the identical exposure #92’s own entry above already recorded on Cranelift for close, left unfixed here for the same reason: none of the existing network examples needs a different socket call than this backend already declares for Net itself
#118LLVM backend, Fs, and a byte-comparison bug it foundchecked_path is checked_host’s own loop (#116) plus a ..-traversal refusal and a /-boundary check, reused by fs_read/fs_write/open_read; file_read and a per-thread errno accessor round it out. tests/accept/file_handle.cho, unmodified, builds and matches its own directives exactly on --backend llvm. Checking real programs found two things, and both are fixed rather than documented away: read/write’s own unconditional declares broke bytes_to_c.cho – a program that had already been working (#117), unlike socket/bind’s already-accepted exposure – fixed by declaring them only when a program’s own extern fn doesn’t already claim the symbol. And compare had hardcoded icmp {cc} i64 regardless of operand type since this backend’s first slice – silently correct for int, silently ill-typed for byte/bool – caught only once examples/cut//examples/seek/ exercised std.bytes.find’s raw byte comparison, which nothing in eighteen prior slices had. Fixed by threading binop’s own lhs_kind through to compare, checked directly (byte_comparison_uses_the_right_width). The boundary fixture moves a seventh time, from bytes_to_c.cho (inside since #117) to tests/accept/static_data.cho, refusing on Expr::Static – docs/compile-time-data.md’s whole feature, found unbuilt the same way Fs was, by checking rather than assuming this backend’s Expr match was exhaustive
#119LLVM backend, Expr::Static, Expr::BitNot, and the last gapExpr::Static lays each static’s already-evaluated values out as one read-only global, packed at the same byte-or-eight-byte stride every other slice in this backend uses; a reference to it costs only the symbol name, declared once for the whole program rather than once per reader the way a string literal is. tests/accept/static_data.cho, unmodified, matches Cranelift byte for byte. Checking whether that was really the last gap – by walking every Expr variant against this backend’s own match, rather than trusting the running “named gaps” list a fourth time – found Expr::BitNot unbuilt too, invisible until now because tests/accept/bitwise.cho’s only use, ~0, is a literal the checker folds away before codegen ever runs. Fixed as Not’s own xor, at i64/-1 in place of i8/1; checked against a value putchar’s own echo keeps unfoldable. With both closed, expr’s match has no unhandled Expr variant, so its wildcard “not built yet” arm came out entirely – rustc itself now refuses to build this crate if a future Expr variant goes unmatched, a stronger guarantee than any test here gave before. The same enumeration over Callee::Builtin found nothing missing either. The boundary fixture does not move an eighth time: every file in tests/accept/ and every program in examples/ was built against --backend llvm directly in this slice’s own session, and all of them succeed. The one program that still refuses, examples/collect/collect.cho (and fetch//report//serve/ alongside it), does so for #92’s own already-accepted extern fn socket collision – not a missing node
#121std.crypto, SHA-256, and a static never gated by reachabilityThe first slice of moving lex-os off Rust: crates/lex-os-audit’s hash-chained log calls sha2::Sha256 on every entry, so a port needs a hash function to exist first. FIPS 180-4, in masked 64-bit int arithmetic – mask32(x) = x & 0xffffffff at the points the spec says to reduce mod 2^32, so the checked + underneath never traps and no wrapping_add is needed. The round constants are two static items – the first real consumer of Expr::Static outside its own design doc and tests/accept/static_data.cho. Checked against four vectors computed with the system sha256sum rather than from memory, including the 56-byte input that forces the multi-block padding path this backend’s earlier fixtures never reached; tests/accept/sha256.cho matches on both backends. Building it against AGENTS.md §7’s “an unused declaration costs nothing” test found a real gap static_data.cho could never have exposed on its own: unlike a function, a static was never gated by mono’s reachability worklist – every static a program declared was evaluated and pushed into Program::statics whether or not anything read it, so std.crypto’s tables reached the object file of any program that merely imported the module. Fixed in cancho-ir: an exhaustive, no-wildcard-arm walk finds which static indices a reachable function actually names, closes over statics reading earlier statics (safe, since a static may only read one declared before it), and renumbers what survives before evaluation runs – checked against static_data.cho’s own cross-static reference and a fresh case where the middle of three statics is dropped and the third has to renumber past it, on both backends
#122c_int, fixing a real bug in extern fn’s foreign returnScoping examples/vsock/ – connecting over AF_VSOCK, the channel lex-os-guest uses to reach its host supervisor – found a real, previously-undiscovered bug in both backends, not specific to vsock: a foreign call declared -> int read the C function’s return as a full 64-bit register. Correct when the real C function genuinely returns 64 bits (labs returns long, why its own test never caught this), but most libc calls (close, connect, socket, bind, listen, accept) return a 32-bit C int, and a failing call can leave the upper 32 bits of the return register zero rather than sign-extended – close(999) read back as 4294967295, not -1, confirmed byte-for-byte against equivalent C. No universal fix exists: extern fn’s syntax cannot distinguish a 32-bit int from a genuinely 64-bit long/ssize_t, both spelled -> int today, so assuming either width for every declaration would fix one case and silently break the other. c_int names the real width explicitly, in return position only – the call crosses at 32 bits and sign-extends, and the value is exactly int everywhere else in the program. Every existing socket declaration across examples/serve/, fetch/, report/ and collect/ is migrated; read/write stay plain int (ssize_t, genuinely 64-bit). Their own < 0 checks had this exact bug the whole time, untested only because the shipped suite exercises success paths, never a real bind/accept failure. examples/vsock/ is fixed and unblocked by the same change; docs/net.md §5’s outbound count moves to 3
#123std.crypto’s second slice, SHA-512, and the logical shift it neededEd25519 (lex-os-capsule’s signing need) hashes with SHA-512 (RFC 8032), not SHA-256 – crypto.md §7 named the dependency and deferred it. compress512 is compress() again at double the width and eighty rounds; wrapping_add does directly what mask32(checked +) had to simulate for SHA-256’s masked word. A full-width word surfaced two gaps SHA-256’s narrower one hid. Every rotate SHA-512 needs is logical, and this language’s >> is arithmetic by design (bitwise.md §2) – built the logical shift out of the arithmetic one by masking off exactly the bits sign-extension might have gotten wrong, and low_mask64(63) needed its own literal since (1 << 63) - 1 traps. And a 64-bit literal with the top bit set does not parse at all: every such constant in sha512_h0/sha512_k is spelled (hi << 32) | lo instead, each half reconstructed and checked against the original value before being written down – a mechanical transform, not a per-constant judgement call. H0 and the round constants are independently derived (integer square/cube roots) and checked against Python’s hashlib.sha512 and the system sha512sum on four vectors; both backends agree, and std_declarations_cost_nothing_unless_called needed no change since SHA-256’s reachability fix already generalized. §5 names what Ed25519 still needs beyond this – field arithmetic std.bignum cannot yet do, curve point operations, a constant-time posture – and deliberately starts none of it
#123Content-addressed VCS: what cancho would needShipped in the same PR, and unrelated to it: a design document, not a std.crypto change. hash-stability.md’s plateau measurement left one open question – whether 27 commits touching zero builtin/effect/prelude files is enough – and this document answers it yes, conditional on tagging every op’s edition from day one (editions.md’s own mechanism, applied before there is a second edition to need it rather than after). Read file by file rather than re-estimated: lex-vcs’s ~81% (operation.rs, attestation.rs, signing.rs, merge.rs, merge_session.rs, issue.rs, predicate.rs, op_log.rs, history_index.rs) is String/BTreeSet<String>-keyed and stated as decoupled in its own doc comments; compute_diff.rs+diff_to_ops.rs+body_merge.rs are exactly the ~1,500 CExpr-walking lines that need a native rewrite (449+617+411=1477, to the line). The apply→gate pipeline needs no change at all, because gate.rs already defers every type judgement to the caller’s checker – which turns out to already answer this repository’s own linearity worry about auto-merge (#55): a structurally-clean merge that double-consumes a linear value is exactly what the gate already exists to catch, so the open risk is a higher rate of merge-then-gate-rejects than lex-lang sees, not a soundness gap, and it is named as worth measuring rather than assumed. Recommends a native cancho-vcs crate – the same “shares the idea and no code” relationship lex-os/cancho already committed to, checked against zero shared lines in this repository’s own history – over a Cargo.toml dependency on lex-lang
#124cancho-vcs’s foundationvcs.md §8’s own next slice, built: crates/cancho-vcs, a native crate depending on nothing from lex-lang. OperationKind is scoped to what cancho can actually populate – AddFunction/RemoveFunction/ModifyBody, no budget_cost field at all, because budget.md already settled cancho has nothing for it to hold, so it is not declared rather than declared and permanently None. Operation carries Ast::edition_of’s own u32 from the day the crate is born, per §6. One thing changed on contact with the code: OpId is BLAKE3, not lex-vcs’s SHA-256 – canonical-ast.md already picked BLAKE3 for cancho-id’s own hashes for reasons that apply equally to an operation, so matching lex-vcs’s algorithm would have cost a second hash dependency for no reason but appearance. vcs.md §5 corrected in place. The golden suite is built from real cancho-id output, not invented strings: an AddFunction‘s sig_id/stage_id checked directly against cancho ids’ own CLI output on the same source, and two ModifyBodys over sources differing by one operator confirm the signature hash holds while the body hash does not. The gate, attestation, signing, merge and issue tracking vcs.md §3 said port largely unmodified are still unbuilt; §8 (updated) names the order – gate next, since it is now checkable against real code
#125cancho-vcs’s gate, op log and attestationgate.rs type-checks a candidate program with no code changed at the lex-vcs boundary – vcs.md §3’s own claim, checkable now rather than only argued: check_candidate parses and lowers assembled (name, source) pairs and reports each refusal’s rule tag, deliberately skipping the CLI’s main-shape rows since an operation can be about a library declaration with no entry point. op_log.rs is loose-file JSON, one file per OpId, atomic via same-directory rename, idempotent on a repeat write – and get recomputes each record’s OpId from its own payload and refuses a disagreement, the one thing put itself cannot produce but a hand-edited or bit-rotted file could. attestation.rs reimplements lex-os-audit’s own generic Chain<E> as an idea, no shared code – BLAKE3 rather than lex-os-audit’s SHA-256, for the same reason OpId already picked it. No seals yet at this point (signing needed Ed25519, which did not exist), so honestly the weakest of lex-os-audit’s own three documented layers: catches an edited payload or a reordered entry, not a truncated tail or a holder who recomputes – checked directly, not just asserted, including the truncation case verifying when it should
#125std.ed25519, and Ed25519 seals for the attestation logTwo consumers of the same primitive, shipped in the same PR, each closed with the tool that actually fits it. std.ed25519 answers sha512.md §5’s three named gaps – field arithmetic, curve point operations, a constant-time posture, the last one answered no rather than deferred again. Every “big number” is a bit-serial [byte]-slice toolkit (compare, add, subtract, shift-by-one, schoolbook multiply), not a radix-limbed one: sha512.md’s own carry-chain bug was one operation’s worth of risk, and this is that risk hundreds of operations over, so speed is traded for a toolkit simple enough that hand-computable cases caught its one real bug – bn_modpow/sign/verify reuse one scratch buffer across many calls, and the reduction step assumed its output arrived zeroed instead of zeroing it itself, found by 3^1 mod 7 returning 1. p/L/d/√-1/the base point are each derived and checked (on-curve, correct order), not transcribed. Checked against four fresh openssl-generated keypairs, in both directions: key derivation and signing match openssl byte for byte on all four, and verify accepts a real openssl signature over a 512-byte message this module never produced. This module is explicitly not what cancho-vcs uses for its own attestation log – that gains sealed_with/is_sealing/verify_seals via ed25519-dalek instead, the same crate lex-os-audit already uses correctly and safely, with the seal kept outside compute_hash’s own input so turning sealing on partway through a log cannot retroactively change anything already written
#126cancho docsync: generated facts instead of hand-edited onesPorted lex-lang’s lex doc-sync mechanism – same docsync.toml shape, same <!-- docsync:begin/end ID --> markers, same --check contract, sharing the idea and no code (vcs.md §5’s own precedent). The concrete problem: net.md’s own outbound/inbound tally had been hand-edited twice in this repository’s history because nothing failed when it went stale. cancho repo-stats now computes it fresh – mirroring tests/conformance/net.rs’s own scan exactly, so the two can never silently disagree – plus crate/example/doc counts and total Rust LOC, spliced into one new block in README.md. docsync --check is the gate’s fourth step (CLAUDE.md) and a new CI job. Removed (#136): the mechanism never gained a second target, and the one it had — the “Repo, by the numbers” line in README.md — was asked to go. A generic mechanism with exactly one consumer and no path to a second is not generic, so the whole thing (docsync.rs, repo_stats.rs, docsync.toml, the CI step, the gate’s fourth clause) went with it rather than being left running with nothing to check
#127--backend llvm becomes the defaultEvery gap llvm-backend.md ever named a target for had closed; this is the PR that acts on it, closing the 1.17×–2.58× gap to C for every build rather than only opt-in ones. Flipping the default is what turned two long-accepted, theoretical exposures into live regressions the same day – which is exactly what a default change should do to anything nobody had asked to pay for yet, so both were fixed rather than reverted around. docs/ROADMAP.md #92’s own “already-accepted” socket collision: every socket-declaring example in the corpus (serve/, fetch/, report/, collect/, vsock/, agent_guest/, agent_supervisor/) refused to build on --backend llvm the moment it became the default, because this backend declared socket/bind/listen/accept/connect/getaddrinfo/freeaddrinfo/close/creat/open unconditionally rather than only when a program’s own extern fn did not already claim the symbol – the guard read/write already had, extended to every other libc name this backend declares. Two backend bugs neither llvm-backend.md nor Cranelift parity testing had ever found, both invisible until something not opt-in ran them for the first time: Expr::Unboxed had no scalar_kind arm at all (docs/tuples.md’s identity fixture, crashing rather than refusing – fixed the same way Deref/Contents already were, leaves_of on the unboxed type); and int::MIN % -1 trapped by signal instead of answering 0, because checked_div applied the same is_min && is_neg1 check to Rem that correctly guards Div, when a remainder can never overflow – fixed with a real branch around srem (a select was tried first and rejected: both its arms evaluate, so srem would still see the undefined pair with its answer merely discarded). Two tests that pinned the socket collision as permanent (a_program_outside_this_backend_is_refused_not_panicked, a_program_outside_this_backend_is_refused_through_the_cli) now prove the fix instead; two tests asserting Cranelift-specific object-file byte-identity (a_tuple_emits_the_same_object_as_the_struct_it_replaces, std_declarations_cost_nothing_unless_called) are pinned to --backend cranelift explicitly rather than silently retargeting whichever backend is the default this month, and the second attempt at that pin found --backend llvm’s own object output is not reproducible across processes at all – run_clang’s temp-file name embeds std::process::id(), and that name leaks into the object. Same PR, second slice: c_ptr, edition 3. reach.md §3.1’s “every opaque handle is out” is corrected in place – Type::CPtr is a real, distinct type an extern fn can return or take, checked for equality and nullness only and never collapsing to int the way c_int does, which is what keeps arithmetic, dereference and coercion ordinary type mismatches rather than silently meaningless. null_ptr() is the one non-foreign producer, needed because OpenSSL’s own NULL-on-failure convention (the motivating case, grounded against the real /usr/include/openssl/ssl.h and nm -D libssl.so.3) has no other way to build the comparison value the no-coercion rule forbids constructing from an int. tests/accept/opaque_pointer.cho round-trips a real handle both directions through libc’s own fdopen/fclose on both backends (Cranelift: pointer-width int; LLVM: its own distinct ptr kind, LKind::Ptr, whose zero() was already "null" for exactly this). The real OpenSSL example was not built: every example this project has ever linked needs only libc, which the compiler always links, and nothing in cancho build can yet ask for -lssl -lcrypto – a real, separate “link an external library” feature this document tracks rather than assumes. The fixture that was built found a bug with nothing to do with c_ptr: the first attempt declared fopen’s own two string parameters directly and segfaulted on both backends, because a &r [byte] parameter already crosses as a pointer and a separate length (strings.md §6), and that phantom length register silently misaligns any parameter declared after it – safe only when a byte-slice parameter is the last one, which is why write/read’s own declarations (and every real OpenSSL signature opaque-pointers.md §1 grounds) already put it there. Rewritten around fdopen(fd: int, mode: &m [byte]) instead of chasing that pre-existing gap. Same PR, third slice: threads.md, a design doc, not yet built. function-values.md §5 and reach.md §3.3 both named the wall and stopped; this finishes the sentence. pthread_create’s one void *arg is reach.md §3.1’s un-crossable pointer restated, and c_ptr cannot smuggle a capability through it either – it is opaque by design, the same “smuggling does not help” argument reach.md §3.1.1 already made about malloc. Proposes spawn/join as compiler primitives instead, the same kind fork/box/split already are, never touching the C ABI: body is a captureless function value (function-values.md §4.2, its own first real asker), payload is moved into it, join is mandatory (res Thread[R]). The soundness argument – a shared or unique reference may cross provided the thread is joined before the region it belongs to closes – is linearity-and-effects.md §5 rule 4’s existing escape check (Type::mentions) asked about one more kind of value, not a new rule; Rc[h] T must be excluded, since its non-atomic count (§9) would race across a real thread boundary. Written up and reviewed before anything is built, the same gate c_ptr went through, materially larger given it needs function values first. Same PR, fourth slice: function values, built. threads.md’s own prerequisite, taken further than the design doc alone: Type::Fn(Vec<Type>, Vec<FnLabel>, Box<Type>) in cancho-types (a new FnLabel mirroring cancho-ir’s Label rather than depending on it, since the dependency runs the other way), fn(A, B) -> [row] R parsed as a type, and two new IR nodes – Expr::FnValue(FuncId) (the target’s address, region parameters freshly instantiated exactly like a call’s) and Expr::CallIndirect (checked against the value’s own type: arity, argument types, and the row unioned into the caller’s exactly as a named call’s is). Both backends: Cranelift takes the address with func_addr and calls through call_indirect against a Signature built from the value’s type; LLVM reads a global symbol directly as a ptr – no instruction needed – and spells the callee’s signature explicitly at the call, since an opaque pointer carries none of its own. Rule::NoFunctionValues narrows to refuse only a generic function (no address until its type arguments are known) or anything that is not a named top-level function; a plain int local stays refused under not-a-function, unchanged. Verified past the type checker: tests/accept/function_value.cho calls a region-polymorphic, effectful function through a value taken twice (proving val copy semantics, not a move) on both backends, real output checked. Rule::NoFunctionValues’s own golden fixture (tests/reject/function_as_value.cho) pinned a captureless function’s refusal as permanent – rewritten to a generic function, the case that is genuinely still refused, the same “obsolete pin” pattern --backend llvm’s own socket-collision tests hit above. spawn/join themselves are not built; threads.md still needs its own review first
#128spawn/join, the single-leaf slicethreads.md §2 built, deliberately narrower than its own illustrative signature: this compiler has no way to synthesise a trampoline function for a multi-field payload, so spawn’s payload and body’s return type are each restricted to one pointer-width leaf (int, bool, c_ptr, a captureless function value, or a single non-slice reference), checked by a new Rule::ThreadPayloadType. That restriction is what makes the whole slice possible with no new compiler machinery: body’s own compiled entry point – the same symbol an ordinary call already targets, reachable through the function-value work #127 just built – becomes pthread_create’s start routine directly, since every leaf this slice allows already lowers to one pointer-width value. res Thread[T, R], two type parameters rather than the one sketched in the design doc: T, the payload’s own type, rides along purely so its region is tracked, which makes §3’s entire soundness argument – a handle cannot escape past the borrow block that gave its payload a reference – fall out of Type::Named’s already-generic mentions/regions_into walk into its own type arguments, with zero new checker code; join is Thread’s one consumer, mirroring Box/unbox. Join needed its own IR node, Expr::Joined, because its return type varies per call site and the backend needs to know at codegen time how many leaves to read back – discovered before any backend code was written, not after. Both backends declare pthread_create/pthread_join on demand (Cranelift’s existing libc_fn helper, already used by malloc/free; LLVM’s own declare_libc_unless_own) rather than through new module-level plumbing that was tried first and reverted once libc_fn turned out to already do the job. Two real ABI bugs, found by running compiled programs, not by review: LLVM’s pthread_create call used a bare integer literal where its ptr-typed parameter required null or an inttoptr conversion – invalid textual IR, caught by clang refusing to assemble it; Cranelift’s Callee::Builtin(Spawn) arm indexed args[0]/args[1] positionally without accounting for a () payload contributing zero leaves (shifting body’s address into the payload slot) or for a byte/bool payload’s narrower Cranelift type needing uextend to the signature’s pointer-width parameter – both fixed, and both would have shipped silently wrong without tests/accept/spawn_join.cho actually running on both backends. Verified past the type checker, threads.md §5 step 2 in full: tests/accept/spawn_thread_ids.cho checks real, distinct pthread_self() values from main and two workers (not simulated), doubling as the “shared reference crosses into more than one spawn, still readable by the spawning side after every join” case from §3 – one borrowed Ffi("libc") capability is payload for two threads and read again afterward; tests/accept/spawn_parallel_sleep.cho proves genuine OS-level parallelism, not a disguised sequential call, by timing four real 200ms usleeps joined together and asserting comfortably under twice one sleep’s wall-clock time on both backends (spawn_and_join_run_concurrently_not_sequentially). tests/reject/spawn_handle_escapes_borrow.cho checks §3’s soundness claim directly – a Thread[&r int, int] returned out of the borrow r block that opened r, unjoined, is refused by the pre-existing reference-escapes-region rule, no new rule needed, exactly as predicted. tests/reject/spawn_payload_type_not_supported.cho checks this slice’s own wall, a struct payload refused before codegen. Left for later, honestly: an owned (not borrowed) capability payload and the Rc[h] T exclusion threads.md §5 named – the latter is moot today since Rc is not itself an implemented type in this compiler, so nothing of that shape can be written to exclude
#129spawn/join: an owned capability payloadthreads.md §5 step 3, built – and its own original text turned out to be wrong. It assumed an owned File/Io is a multi-field struct under the hood needing the same compiler-synthesised trampoline the general struct-payload case does; checked against abi::leaves_into on both backends before writing any code, File is exactly one leaf (the fd, its own dedicated ABI arm) and every zero-field capability – Io, Ffi, Fs, Args, Heap, all declared with no fields at all in defs.rs’s prelude_types – is exactly zero, the identical shapes #128’s own codegen already handles for int and (). So the fix was a wider crosses_to_a_thread allowlist in the checker, not new machinery: zero lines changed in either backend. A real, pre-existing gap surfaced writing the first fixture, unrelated to spawn: a function taking Io by value (not &!i Io) had never been written anywhere in this repository’s corpus, and the first attempt declared [io_write] on it and was refused with “declares io_write but never performs it” – not a bug, but defs.rs’s own discharged_by mechanism (owning Io outright discharges io_write/io_read/err_write, the same reason main itself is allowed to declare [] while performing effects through the World it holds) applied one level deeper than anything had exercised it before. tests/accept/spawn_owned_io.cho moves an owned Io into a thread that writes to the real console; tests/accept/spawn_owned_file.cho opens a file in main, moves the resulting File into a thread that reads and closes it there – real I/O performed by a second OS thread holding the only reference to the capability that authorises it, checked on both backends (the_two_backends_agree_on_spawn_owned_io/_file). tests/reject/spawn_owned_capability_reused.cho closes §5 step 4’s other named companion: main moves io into spawn and then calls release(io) again, refused by the pre-existing linear-use-after-move rule – confirming §3’s “the existing rule, not a new one” claim by running it rather than only asserting it, same as use-after-move for any other res value. threads.md’s status header and §5 corrected in place, including the wrong original claim about step 3’s own difficulty. Left open: a capability shaped like a genuine multi-field struct (none exist among today’s capabilities, so this step’s trampoline-free answer is untested past File/Io’s own accidentally flat ABI shape) and the Rc[h] T exclusion, still moot until Rc exists
#131threads.md §5 steps 3 and 5, measured and decided not yetThe two items #128/#129 left open – a struct-shaped owned capability payload and the Rc[h] T exclusion – both trace to the same missing piece, a compiler-synthesised trampoline function (Rc doesn’t exist as a type either way, so its exclusion needs one before it needs anything else). Rather than build that trampoline speculatively, AGENTS.md §7’s own rule was applied and actually checked rather than assumed: a repo-wide search across all four reachable repositories – cancho, lex-os, lex-lang, lex-gpu – for any program wanting a multi-field spawn payload today. None. lex-os-guest’s own reasoning loop, the concrete production target threads.md §2 itself names, is single-threaded by design with no comment or TODO anywhere wanting concurrency; its only std::thread::spawn calls are host-side pipe-drain threads, unrelated to cancho’s primitive. lex-lang’s own conc.spawn_thread (PR #660 there, an independent design for the identical problem in a different language) answers “how does a background task get its state” with a captured, zero-argument closure rather than an explicit payload – not proof, but a real signal that this problem shape tends not to want a fat payload even where a language is free to offer one. Decided not yet, the same verdict self-hosting.md reached the same way: nothing found is a hard blocker, nothing asks for it today, revisit when an asker exists rather than on a schedule. threads.md’s status header, §4’s Rc bullet, and §5 corrected in place – including a stale line in the status header itself, still claiming step 4 (the unique-reference-across-join companion) as a future slice a full PR after #129 had already built it
#130examples/results_stub/: the first lex-os component ported to canchoThe cancho epic issue’s own long-unchecked sequencing box, closed: not an analogue or a simulation like vsock//agent_guest//agent_supervisor/, but a real port of lex-os/crates/results-stub – the single allowed-egress target the lex-os demo’s manifest narrows to (lex-os issue #10) – checked line by line against the Rust original rather than reimagined from its description. Same discipline serve//collect/ already established: hand-rolled HTTP/1.1 over raw libc sockets through Ffi("libc"), no Net capability. Two honest narrowings, both because of what this language’s foreign-call boundary can and cannot cross (docs/reach.md §3), not oversight: no peer address (getpeername’s real signature wants a socklen_t * the kernel both reads and writes – a pointer to a plain scalar, neither the &[byte] “pointer and length” shape nor the “value across, nothing back” shape any foreign call here can express; accept’s own two NULLs in serve.cho already made the identical choice for the identical reason), and a request body past its 200-byte preview buffer is drained from the socket but not kept past that, since an unbounded body has nowhere to live in a 64 KiB arena (examples/cut/’s own line-length cap, same reasoning). A real behavioural gap found by running it, not by reading the Rust source: Rust’s println! is internally line-buffered regardless of whether stdout is a terminal, so the real results-stub’s log is visible per request; this language’s write_bytes is fwrite, fully buffered like any C program’s once redirected to a file, so the first working build’s log only appeared in bursts. Fixed with one extern fn fflush call, NULL flushing every open stream, the same “pass 0 for a NULL pointer” idiom accept’s own unused out-params already established. crates/cancho/tests/conformance/backends.rs’s the_two_backends_answer_the_results_stub_port builds it on both backends, sends a real HTTP request over loopback, and checks the response and the request-log line both – not simulated. docs/net.md §5 recounts inbound to 4 (net.rs’s own counting test, which found this file the moment it existed, exactly as it is built to)
#137Linking beyond libcopaque-pointers.md built c_ptr so a handle like SSL_CTX * could cross, but its own §5 could not compile the TLS example it was designed for: cancho build only ever links libc. -l/-L on build/run, passed to cc unexamined between the object and -o – measured against this project’s own ld that the order matters, not assumed. examples/tls_client/ is the proof: a real handshake and encrypted round trip against openssl s_server -rev, on both backends. Building it found two things unrelated to linking: c_ptr cannot name an ordinary function’s own parameter or return type, only an extern fn’s (opaque-pointers.md §4, easy to miss), so the handshake/write/read became one function instead of three; and the program’s own extern fn connect collided with edition 2’s connect builtin, fixed by giving the raw socket its own file on edition 1 while the rest stays on edition 3 – docs/editions.md §7’s per-file marker doing exactly the job it was designed for. net.md/net.rs recount outbound to 5. Not CI-gated: this project’s own darwin-aarch64 runner cannot be assumed to have OpenSSL in cc’s default search path, so the transcript is hand-verified in foreign-linking.md §4 instead, the same fallback opaque-pointers.md §5 pre-approved, for a different reason than the one it named. reach.md’s TLS row moves from Partial to Yes
#138A package systemDesign only, answering modules.md §7 and standard-library.md §2.1’s deferred question now that the two things it waited on exist: cancho-vcs and editions. The load-bearing finding: lex-lang’s own working package manager, lex pkg, is built on crates/lex-store’s Store – a content-addressed repository keyed by declaration hash, resolved by pinning a (store, head) – which is cancho-vcs’s own architecture, not a coincidence. Proposed: a package is a cancho-vcs store at a pinned head, resolved the way lex-store/src/deps.rs already resolves one, re-checked under the consumer’s own compiler rather than trusted from a lock file – hash-stability.md’s 71% is why a pin cannot assert it still compiles. lex-os-capsule’s “the consumer’s grant, not the publisher’s declaration, is the ceiling” becomes a build-time check via authority --output json; lex-os-authority’s derive/diff/narrow is reused whole for “did this bump widen what I’m exposed to.” modules.md §2’s “a call encodes the callee’s hash, not its spelling” extends into the dependency graph for free: a name is chosen once and resolved by hash forever after, which is the direct answer to typosquatting. Stated as a design commitment: no code ever runs to resolve a dependency, the same reason this project has no macros. Not scoped to build yet – §6 names the smallest next design doc
#139vcs resolvepackage-system.md §6’s own named next slice, built: a real second store, never trusted from its own lock. Found a prerequisite the design doc had not named – neither OpLog nor Manifest store a declaration’s actual source, only its hash, so there was nothing to re-typecheck. Blobs (crates/cancho-vcs/src/blobs.rs) is the missing piece, BLAKE3-hashed the same way Operation::op_id already is. cancho vcs resolve <store-dir> groups a store’s pins by source file, re-parses and re-typechecks each one under today’s compiler, then re-derives every declaration’s identity and diffs it against what the manifest pinned – refusing, every mismatch at once, if a pin no longer matches its own source, the source no longer type-checks, or a blob is missing. Verified by hand against all four cases before writing them as tests. Not built: resolve checks one store against itself, not a consumer’s dependency graph – no lock file, no recursion across a closure of stores, no diamond-conflict detection. docs/package-system.md’s own §4.2/§6 corrected in place
#140vcs lockpackage-system.md §4.5/§6’s naming half, built: Lock (crates/cancho-vcs/src/lock.rs), name-keyed rather than Manifest’s SigId-keyed, because a consumer’s own import names a dependency, not a hash it does not have yet. cancho vcs lock --store <dep-store> -o <file> <name>... looks a name up in a dependency’s manifest and pins it, refusing an unpublished or ambiguous one. vcs resolve --lock <file> <store-dir> scopes resolution to just those pins instead of a whole store. One prediction corrected on contact: §4.5 said re-locking a name to a different hash should be a refusal; building it found the guarantee was about automatic substitution, and an explicit second vcs lock run is a deliberate re-pin, the same act as cargo update – so it overwrites, and the section is corrected in place to say what actually holds. Verified by hand against every case before writing tests. Still not built: resolve --lock checks one store against one lock, not a program’s dependency graph – no recursion across a closure of stores, no cycle or diamond-conflict detection. The next slice is an import reading a lock at compile time
#141vcs fetchpackage-system.md §6’s own named next slice, built – but the premise it was scoped under turned out wrong. §6 said the next case was import learning to read a lock at compile time; building it found that modules.md §4.2 already means a program is the set of files named on the command line and import already resolves a name against whichever of them declares it, with no search path or manifest lookup in the compiler to extend. So the real next slice needed zero compiler changes – only a place to fetch a verified dependency source to. cancho vcs fetch --lock <file> --store <dep-store> -o <dir> re-verifies every pin the same way vcs resolve --lock does, then writes each distinct verified source to <source_hash>.cho, nothing written unless everything verifies first. cmd_resolve’s inline verification loop is factored into select_locked/verify_selected so resolve --lock and fetch share it rather than duplicate it. Proven end to end, not asserted: fetch_materializes_a_verified_file_a_real_program_can_import publishes a dependency, locks it, fetches it, then builds and runs a second, separate file that imports the fetched file unmodified. docs/package-system.md’s §4.6/§6 corrected in place to record the finding. Still not built: recursive resolution across a closure of stores – resolve --lock/fetch still check one store against one lock, not a program’s full dependency graph; no cycle or diamond-conflict detection
#142net.sockets, the first real packageEvery prior package-resolver slice was tested against fixtures; this is the first real dependency for a real program. examples/serve/serve.cho and examples/results_stub/results_stub.cho had independently declared the same eight extern fns against libc and the same put/put_nat byte helpers since #129 – organic duplication this project’s own “a feature earns its way in when a program asks” rule already justified extracting. Moved to packages/net-sockets/sockets.cho, published to a real checked-in store; both consumers lock it in their own net.lock and import net.sockets instead of duplicating it. Found vcs publish’s only real gap so far: every store published before now held ordinary functions with bodies, and cmd_publish read a declaration’s effects off its lowered IR function unconditionally – an extern fn has no body to lower, so publishing any file with a foreign declaration hit internal: has an identity but no lowered function. Fixed by reading Program::externs when no lowered function matches. Verified end to end: both binaries still build, run, and answer a real HTTP request identically to before the extraction, on both backends. Test fallout from two examples no longer being single-file programs: authority.rs/backends.rs/net.rs now fetch the package fresh (never a checked-in copy) via new helpers in main.rs; net.rs’s network-programs walker gains packages and an updated expected list – 4 programs still ask for inbound socket authority, now across 3 declaring files rather than 4, docs/net.md §5’s matching “Recounted” entry says so. docs/package-system.md’s status header and §6 record the extraction and the fix. Still not built: recursive resolution across a closure of stores
#143Both remaining “not usable” reasons, re-measuredAsked directly: what would close README.md’s own “not a usable language yet” paragraph, now that #142 landed a real package. Reason 1 (package system) corrected in place – “there is no package system” is stale after four merged PRs; the honest gap is narrower, no manifest and no multi-dependency resolution. Reason 2 (the 71% effect-vocabulary figure) re-measured, not assumed: the working copy was shallow-cloned, so git fetch --unshallow first, then scripts/history.py against the real full history – 165 revisions, 104 read, 61 do not (37%, down from 71% originally and 41% at #85). The load-bearing check was not the percentage but whether the vocabulary still breaks old files: git diff since #120 shows null_ptr/spawn/join/Thread all landed, touching builtin.rs/defs.rs/cancho-types exactly as hash-stability.md §3’s stale “plateau” claim said growth would look like – but every one is edition-gated and additive by its own stated design, and the corpus gained 24 revisions since #85 while only 3 joined the unreadable pile. The vocabulary keeps moving; editions.md’s edition-gating means it no longer reaches backward. docs/hash-stability.md gets a “Re-measured (#142)” entry (§2) and a “Corrected again” entry (§3) with the actual numbers and the --alias/--migrate cross-checks (unchanged: 0 and 23, matching #85); README.md’s paragraph rewritten to match both findings
#144net.connect, two dependencies at onceAsked directly: can this actually resolve multiple dependencies? Six more examples turned out to duplicate net.sockets’s declarations independently – fetch/, report/, collect/, vsock/, agent_guest/, agent_supervisor/ – and split cleanly on net.md §1’s own line, “the two directions do not meet”: inbound wants net.sockets alone, outbound wants net.sockets plus connect, which stays its own package (net.connect, packages/net-connect/connect.cho) rather than joining it. examples/fetch/fetch.cho migrated onto both – two independent vcs lock/vcs fetch pairs, composed at the same build command line, needing zero new tooling. The question package-system.md §6 had flagged as unbuilt (“recursive resolution across a closure of stores”) turns out to be a different question from “does N direct dependencies work at all” – that one already worked, it just had never been tried against a real program. What’s still actually unbuilt is narrower: a dependency that itself has dependencies, and conflict detection across them. Found a real, separate compiler gap: cancho authority’s foreign_symbols is not reachability-pruned the way Program::funcs is – cancho_ir::lower_all collects every extern fn unconditionally, before pass 2’s reachability walk runs, so importing a package that declares more than a program calls over-names what it reaches. Not a safety bug (over-naming fails closed), but a real precision gap against authority.md’s own claim; documented at docs/authority.md §3 and docs/under-a-grant.md §6, not fixed here – a separately-scoped cancho-ir change. README.md’s own “nothing resolves more than one dependency at a time” claim, written one slice ago, corrected in place
#145foreign_symbols, reachability-prunedCloses the gap #144 found and documented but did not fix. Fixed the same way docs/crypto.md §4 fixed the identical problem for statics: a read-only, exhaustive-match walker over every reachable function body (fold::collect_extern_refs/collect_extern_refs_body, mirroring collect_static_refs’s exact shape) collects the Callee::Extern indices actually called, wrapped as cancho_ir::reachable_externs; print_authority filters program.externs by it before building foreign_symbols. Program::externs itself is untouched – nothing renumbers it, so Callee::Extern’s index and both codegen backends, which index into it positionally, needed no change. authority.rs gets a dedicated, package-independent regression test; net.rs’s assertion relaxed in #144 is restored to its original, stricter form. docs/authority.md §3, docs/under-a-grant.md §6, and docs/package-system.md’s status header all move from “found, not fixed” to “fixed”
#146The other five duplicating examples, migrated tooFinishes what #144 flagged as “candidates, not done here”: report/, vsock/, agent_guest/ now import net.sockets and net.connect; collect/, agent_supervisor/ now import net.sockets alone. Nine files’ worth of duplicated extern fn declarations collapse into the two real packages – the same lock/fetch/build pattern proven on serve/, results_stub/, and fetch/, run five more times with no new tooling. net.rs’s the_network_programs_are_counted recounts to one inbound declaring file (packages/net-sockets/sockets.cho) and two outbound (packages/net-connect/connect.cho, examples/tls_client/socket.cho – still on edition 1, unmigrated for the reason its own note gives); backends.rs’s LLVM-collision guard now fetches each program’s dependencies before building. docs/package-system.md §6’s actual remaining gap – a dependency that is itself a dependency, a true closure of stores – is unchanged: this was convergence onto already-real packages, not new composition
#147agent.wire, the third real packageexamples/vsock/vsock.cho and examples/agent_guest/agent_guest.cho each declared the same five pure functions – an AgentViewMsg decoder, byte-for-byte – the same “two real askers” bar net.sockets cleared first, but the first package with no extern fn in it at all. Confirms vcs publish/vcs lock/vcs fetch are indifferent to whether a declaration is a libc binding or ordinary Lex with a body – a package is just a store. Found the one real constraint that shape has: vcs publish never makes --std available (cmd_publish parses with with_std: false unconditionally), so the package inlines its own digit check rather than importing std.bytes, the same workaround net.sockets’s own put/put_nat already used. The JSON escaper on the encoding side of the same wire protocol (vsock.cho’s append_json_escaped, agent_supervisor.cho’s put_escaped) stays unextracted: same rule, two different signatures (one needs std.buffer/Heap, the other a fixed slice), so bundling them would guess at a shape neither file asked for
#148Closure resolution, designed against a real caseexamples/collect/collect.cho and examples/agent_supervisor/agent_supervisor.cho duplicate a fourth pair, content_length_of/read_request, but the package that would hold them needs net.sockets itself – confirmed directly, not assumed: publishing a file that imports net.sockets today refuses with “no module net.sockets in this program.” The real transitive-dependency case §6 had pointed at speculatively (via --std, which never had a reason to force it) for three PRs, now concrete. New §4.6 designs the fix: a store-carried requires/*.json, vcs publish --requires <lock>:<dep-store> to write it, a recursive walk in vcs resolve/vcs fetch with cycle and diamond-conflict refusal. A follow-up commit to this same PR corrected one claim in the design itself, found while implementing it: cancho-id::identify() is not resolution-independent – a call’s contribution to its caller’s body hash is the resolved callee’s own signature when the callee resolves, so computing identity without the dependency present would record the wrong hash and refuse a body that never moved. Design only; implementation in #149
#149Closure resolution, builtBuilds #148’s design. Requirement { store, lock } (crates/cancho-vcs/src/requirement.rs), one requires/*.json per distinct dependency store; vcs publish --requires <lock>:<dep-store> (repeatable) resolves and verifies each dependency, merges its source into the file’s own for the soundness gate and for check_candidate, and computes identify() from that same merged AST (per #148’s correction), filtered by this file’s own names so a dependency’s declarations are never republished. resolve_closure/resolve_own_requirements walk a store’s own requires/ recursively, threading a visiting stack (cycle refusal) and a pinned map (diamond refusal: the same (store, name) pinned to two different heads by two different paths) through the whole walk rather than resetting per level. verify_selected gains an extra_context parameter, a no-op for every store with no dependency of its own – net.sockets/net.connect/agent.wire unaffected. vcs fetch writes the whole closure to -o, not just the directly-locked store’s own. Verified against a real dependency (net.sockets, a prototype http.request-shaped file) before writing the synthetic-package conformance tests that ship with this PR; packages/http-request/ itself, the package this gap was found from, is the next slice
#150http.request, the real motivating casepackages/http-request/ (request.cho), the first package that itself depends on a package: content_length_of/read_request move out of collect.cho/agent_supervisor.cho into one file, published with --requires <net.lock>:packages/net-sockets/.cancho-vcs. Both examples now import http.request instead of duplicating the pair, and drop their separate net.sockets lock entirely – a store is always exactly one published file, so fetching any of http.request’s own declarations transitively materializes the whole net-sockets.cho file too. Building it against the real, non-synthetic pair found a second real bug in #148/#149’s own design: Requirement.store was read back as a bare path, silently interpreted against whatever working directory the reading process happened to have rather than the one it was recorded relative to at publish time – invisible against the mechanism’s own tests (their stores are always absolute paths under one scratch directory), but real once cargo test’s own integration binaries (which run with their crate directory as cwd, not the repo root) hit a genuine multi-store closure. Fixed by recording Requirement.store relative to the depending store’s own directory (relative_from, computed lexically at publish time, never touching the filesystem) and resolving it by joining against that store’s own already-resolved path rather than reading it bare. §4.6 and its status header both carry the correction
#151ROADMAP.md: the row for #150Docs only – adds the row above, following the convention of adding it once the PR number is known
#152Correcting the stale “no closure of stores” claimREADME.md’s “not a usable language yet” paragraph and docs/README.md’s package-system.md index row both still said a dependency’s own dependencies do not resolve – true when written, false since #149/#150. Corrected in place rather than left to rot, the same convention this file documents for this repository’s own README
#153net.connect extended, http.response, the fifth packageFound by hashing every function body across examples/, packages/, std/ and benches/ and grouping the ones that collide, rather than rereading files by eye: fetch.cho, report.cho and agent_guest.cho each declare the same six functions byte-for-byte. octets_of/port_of/address/connect_to are generic to any outbound program and join net.connect itself – growing an already-published store with more declarations is the same act as its first publish, just a second vcs publish against the same --store, unchanged entries skipped. send_all/status_of are HTTP-specific and become packages/http-response/ (response.cho), the client-side mirror of http.request. Both now import net.sockets too, its third and fourth real consumer. Found one thing the design had not foreseen: two sibling packages transitively requiring the same dependency means fetching each into its own output directory – every earlier multi-package example’s own shape – now fetches it twice, under two different paths, which build/check refuse as a duplicate declaration; fixed by fetching into one shared output directory instead, which also fixed the same latent collision in vsock.cho’s own manual fetch instructions. fetch.cho/report.cho/agent_guest.cho all verified end to end against real peers over real sockets, not just build success
#154ROADMAP.md rows for #151-#153, and re-measuring the gap to C on --backend llvmStarted as the routine ROADMAP-row follow-up; grew when a direct question – is cancho still 1.17x-2.58x slower than C under the now-default LLVM backend – turned out to have no real answer on file. scripts/game.py/scripts/three.py pass no --backend flag, by design, so they measure whichever backend is the default; that stopped being Cranelift the day #127 landed, and nobody had re-read what they now report. Three runs of scripts/game.py --rounds 9 plus two of scripts/three.py --rounds 9: fannkuch ~1.0x (was 1.32x), spectral 1.27x-1.46x (was 2.58x), binarytrees 1.20x-1.34x (was 1.17x), fasta 0.89x-0.90x, still faster than C (was 0.54x), revcomp too noisy on this container to pin down past ~1.0x-1.6x, mandelbrot 0.95x (was 1.69x), sieve 0.93x-0.95x (was 1.56x) – both of against-c-and-rust.md’s own kernels now faster than C, not merely closer to it. The high end moved exactly as benchmarks-game.md §4’s own falsifier predicted; fasta moved the wrong direction for a naive “faster backend” story even though fasta.c is unchanged, and is left open rather than explained. docs/benchmarks-game.md §8, docs/against-c-and-rust.md §2.1, docs/ROADMAP.md’s own pre-#127 “What is next” essay (superseded in place, not rewritten), docs/first-page.md and benches/game/README.md all corrected. Also fixed cancho --help’s --backend text, found while tracing this down: it still said “default: cranelift” and “opt-in” for llvm, both false since #127 – the actual default was already correct, only the string had drifted
#156Correcting the stale sqrt passage in benchmarks-game.mdScoped “std.math over floats” – benchmarks-game.md §5’s own Open row – as a candidate next ticket and found it already done: #62 made sqrt a builtin four months earlier, and spectral.cho’s own header has said so ever since. #62 never touched benchmarks-game.md itself, so §3’s “sqrt” paragraph and §5’s Open row kept describing the pre-#62 state – the n-body row two lines below the stale one already assumed sqrt existed, so the table had drifted from itself, not just from the code. Corrected in place; confirmed cosmetic, not a measurement bug, since the call sits once outside the O(n^2) loop the benchmark actually measures
#157cancho introspect/skill, imported from lex-lang’s ACLI integrationlex-lang’s crates/lex-cli/src/acli.rs registers every subcommand with the acli SDK so its CLI is self-describing; cancho had only a hand-maintained USAGE string. One Vec<CommandInfo> registration (src/acli.rs) now backs both cancho introspect [--output json] (the full command tree) and cancho skill (a generated agentskills.io SKILL.md), checked against the dispatch table itself by a new conformance test so the two cannot drift apart the way benchmarks-game.md just had. Found a bug of the same shape while building it: the SDK always renders an option with a -- prefix, but -o/-l/-L are single-dash real flags – registering them as options would have taught an agent to write --o/--l/--L and get refused. Fixed by registering them as arguments instead, which render bare. A second inaccuracy – the generated skill’s fixed “Exit codes”/“Output format” section, acli 0.5’s own generic template, which doesn’t match cancho’s real 0/1/2/3 contract – has no override hook in this SDK version and is documented as a caveat rather than silently shipped
#192Packages across repositoriespackage-system.md §7, steps 1 and 2. A Lock may carry an origin (git URL, full commit hash, path); vcs lock --git, vcs fetch and vcs resolve --lock fetch the commit into a cache keyed by it and hold it to the same recheck as a local store, so the transport carries no trust and a lock never holds a branch name. A requirement whose lock has an origin is read through it (a package can require one in another repository). vcs publish --dir publishes a library of several files in dependency order, byte-deterministically, and static declarations now publish (cancho-log’s crc could not). Found by trying it on a real library: a clean checkout of cancho-hooks plus the compiler builds from two locks and passes its idempotency and chaos suites. 21 mutants of the new code, 20 killed, one (a rev-parse behind git’s own hashing) unverified. Not built: a project file, a checked compiler pin, prebuilt compiler releases, the authority diff on update
#193The project filepackage-system.md §8, §7.5 steps 3 and 4. cancho.toml names the compiler a project was written for, its dependencies (each a git URL, a full commit hash and the store inside) and its programs; cancho install fetches and checks the dependencies, cancho add <name> <url> pins one (a --ref is resolved once and the hash written; the file is put back if the install fails), and cancho build with no files installs and then builds. cancho --version now reports the commit it was built from, and a project that names another compiler is refused, naming both: the toolchain floor lex-lang wrote for a long time before anything read it (#803). cancho-hooks runs on it, with one compiler pin instead of two. 23 mutants, 22 killed, one unverified (the branch for a compiler that does not know its revision). Also measured, not built: a cancho binary is a native executable linked only to libc (169 KB for the service; 1.2 MB static with a linker that adds -static); no Docker daemon was available, so no image was built (§8.6). Not built: version ranges, test in a project, a registry, prebuilt compiler releases
#194cancho test in a projectpackage-system.md §8.8. [[test]] sets in cancho.toml (sources, std) and cancho test with no files: install, then run every set against the project’s dependencies, --test <name> one; a failing set does not stop the rest and the exit code is the first failure’s. With files it is the runner it was. 10 mutants, 10 killed. Also found: the compiler runs clang and cc at build time, so a released binary needs a C toolchain where it runs
#195Prebuilt compilerspackage-system.md §9. scripts/package-release.sh (a tarball and checksum named for the full commit, read from the binary; a dirty or unstamped binary is refused), scripts/install.sh (checksum and reported commit both checked before anything is installed), .github/workflows/release.yml (linux-x86_64 on ubuntu-22.04 for glibc, darwin-aarch64; install and build hello from the packaged asset; a release only on an explicit publish, tagged with the commit). Six conformance tests on a stand-in compiler. Its first run on GitHub found three faults, fixed here (macOS asset naming in the tests, an exit code, and clang 14 on 22.04). Nothing is published.
#212std.chacha20: ChaCha20-Poly1305 for the pure TLS client#199, the first slice of #197 (a TLS 1.3 client with no C library). Its own file, so #198 can still move it to a package. Every refusal tagged; open checks the whole tag in constant time before it writes anything; arithmetic on secrets is wrapping_*, and the object code shows no conditional jump in the secret path except traps on public data (the Poly1305 select is cmov). 28 RFC 8439 vectors on both backends, all 325 Wycheproof cases, 1,136 one-bit flips, 100,000 random cases against OpenSSL with 0 differences, 18 of 18 mutants killed. 135 MB/s on 16 KiB against OpenSSL’s 2.29 GB/s. Found: the mutant script was weaker than the test it mirrored (one survivor until it checked the exact tag), and the duplication check found the little-endian store already in examples/vsock, now std.bytes.store_le32. Not done: a timing test (#208), guaranteed key erasure (no primitive for it), the independent review (#209)
#229SHA-384, std.hmac and std.hkdf#201, the TLS 1.3 key schedule’s hash, HMAC and HKDF (RFC 5869, RFC 8446 §7.1), each length rule refused with its own tag. Found: every hash in std trapped on a 64 KiB message (the padded copy went into one arena), and so did ed25519.sign, which crypto.md §4 and sha512.md §3 had claimed otherwise; SHA-2 now streams over a caller’s state, and a 64-byte SHA-256 costs 0.99 µs instead of 10.7. SHA-256’s checked adds and not32’s subtraction were overflow tests on a hashed key’s words; now they are not. CAVP (SHA-384 short, long, Monte Carlo), RFC 4231, RFC 5869, three TLS 1.3 key schedules, 517 Wycheproof cases, 800,000 differential checks with 0 differences, 19 of 19 mutants killed (one survived every fixed vector until the streamed cases). Not done: the cancho-hooks switch, which needs this merged; RFC 8448’s application secrets
#230A TLS 1.3 client with no C library: the design#198, before any protocol code. Primitives with vectors in std and protocol in packages, with both costs written down; a sans-io API (feed/take/send/recv/event over engine-owned slots) that the OpenSSL backend’s memory BIOs already fit and that takes no capability; TLS 1.3 with ChaCha20-Poly1305 and X25519 only; a threat model naming the secret path; SAN-only name matching; a refusal tag per actionable failure mapped to X509_V_ERR_*. Found: the language has no randomness source (the caller seeds a ChaCha20 DRBG from /dev/urandom through Fs, as hooks’ SCRAM nonce already does), and three of the OpenSSL error numbers in the first draft were wrong until checked against x509_vfy.h. Eight questions left for a person
#231X25519, and the field it shares with Ed25519#200. std.field25519 (16-bit limbs, wrapping, a masked cswap, caller-owned scratch) under std.x25519 and, replacing its bit-serial field, std.ed25519, now 31 to 35 times faster. RFC 7748 on both backends, 518 Wycheproof X25519 and 151 Ed25519 cases, 24,000 differential checks with 0 differences, 19 of 21 mutants killed (one equivalent, one unreachable). Checked with Valgrind, not only by reading: with the scalar marked secret Memcheck reports nothing for X25519, and 252 errors for a planted branch. Found: Wycheproof caught two older Ed25519 bugs, a short signature trapping verify and an R with x = 0 and the sign bit accepted; both fixed
#232Checked output: flush_out#215, gap L1 of the agent toolbox: a failed write to standard output was invisible (write_bytes answers the buffered count; libc flushes at exit and ignores the result). flush_out(io) -> [io_write] Done, edition 5, on both backends. Found: fflush alone is not enough – glibc answers 0 once an earlier failure has emptied the buffer – so it is fflush then ferror, and an earlier failure answers EIO. /dev/full, a closed descriptor, an earlier failure, a pipe and /dev/null each checked from outside; seven mutants killed
#233packages/x509: strict DER and X.509 v3 certificates#202. A package, importing nothing: every TLV strict DER, a 40-word view of offsets into the certificate (nothing copied), 19 refusal codes each with a tag, an unknown critical extension refused, two leniencies accepted and flagged. All 128 system roots equal pyca/cryptography’s reading field for field; 30,379 x509-limbo certificates with no trap and every refusal real when read; 36 damaged certificates, one tag each; 1,000,000 fuzz rounds on both backends with no trap. Found: a v2 certificate reported as v3 (so v2 with extensions passed), one oversized PEM block ending a whole bundle, and two comments about the system roots that measuring proved wrong. Not done: signatures, chains and names (#206), publishing (#205), the review (#209)
#234std.bigmod and std.rsa: RSA signature verification#203. Montgomery arithmetic on 30-bit limbs to 4,096 bits (checked arithmetic, no wrapping_*: an overflow would be a bug, so it traps), and RSASSA-PKCS1-v1_5 (the expected block built and compared whole, never parsed) and RSASSA-PSS (explicit MGF1 hash and salt length) verification, each refusal tagged. Wycheproof: 694 valid verify, 2,569 invalid refused; NIST SigVer all right; 100,000 pow_mod cases against Python and 2,000 signatures against OpenSSL with 0 differences; 23 of 23 mutants killed. 2,800 RSA-2048 verifications a second on LLVM, 18 times slower than OpenSSL. Found: a limb-loading bug the first differential caught; a lazy-reduction mutant no random input could kill, until prime-power moduli were added; two equivalent mutants. Not done: SHA-1/224, signing, the review (#209)
#235Zero-filled heap slices: calloc#214’s L-side follow-up. A box_slice whose fill is a literal zero (0, false, 0.0, byte_of(0)) is calloc and no fill loop, on both backends; anything else, -0.0 and a zero computed at run time included, keeps malloc and the loop. cancho-tools’ jsonq on 16 MiB: 0.46 s to 0.15 s, peak RSS 427,540 KB to 88,888 KB. Zero kinds read back zero in freshly freed memory full of nines; a 512 MiB zeroed slice stays under 64 MiB resident. 7 of 8 mutants killed, one equivalent (clang deletes the zero stores)
#239std.ecdsa: ECDSA verification on P-256 and P-384#204. DER (strict) and raw signatures over any digest length, Jacobian points with the formulas’ uncovered cases handled explicitly, Shamir’s trick; on a new register API in std.bigmod, so std keeps one Montgomery multiplication. Curve constants printed from OpenSSL and checked by a script. Wycheproof: 1,370 valid verify, 1,728 invalid refused; NIST 90 of 90; 4,000 OpenSSL cases and 100,000 register operations with 0 differences; 21 of 21 mutants killed. P-256 675/s on LLVM, 20 times slower than OpenSSL’s hand-tuned code. Found: a contract of load_reduced that no vector could see, since n acts as 0 under Montgomery; now tested directly. Not done: P-521, prime-specific reduction, the review (#209)
#240Copying between slices: copy_into#214’s L-side follow-up. copy_into(dst, src), edition 5: all of src onto the front of dst as one memmove (the checker lets a unique and a shared view of one buffer meet in one call, so overlap is real; memory-moves.md’s claim otherwise corrected in place), trapping unless it fits. std.buffer’s reserve and append use it: 640 MiB of 4 KiB appends from 2.00 s (Cranelift) and 0.47 s (LLVM) to 0.026 s on both. 11 of 12 mutants killed; memcpy for memmove is equivalent on glibc and documented
#241Finding a byte: index_of_byte#214’s L-side follow-up. index_of_byte(text, b), edition 5: the first b as one memchr, -1 when absent. std.bytes’ find (candidates by the needle’s first byte) and count_byte use it: on 64 MiB, find 2.82 s to 0.61 s (Cranelift) and 0.72 s to 0.15 s (LLVM), count_byte 3.4x and 2.3x; a needle whose first byte is common (a space) regresses on LLVM, 0.46 s to 0.66 s, measured and kept. Found: Cranelift refused a program that declares memchr itself as a compiler bug; the byte is now passed at 64 bits. 11 of 12 mutants killed, one equivalent
#242packages/tls: the design#205, 1 of 3, before any protocol code. Three PRs (this design; the protocol offline; the engine and the network); four modules under 2,000 lines; a slot as a fixed stride; the state machine; certificates accepted only when pinned until #206 (a client that skipped chain checks would trust anyone). Found: RFC 8448’s Simple 1-RTT trace is AES-128-GCM, so a ChaCha20-only client cannot replay it, and the RFC is unreachable from here; the key schedule comes from s2n-tls’s transcription of it, every value reproduced in Python (the application secrets docs/hkdf.md lacked included), and the byte-for-byte gate is a recorded ChaCha20 handshake against tlslite-ng with fixed randomness. A question for a person: whether that meets the issue’s gate
#245packages/tls: records, messages, the client#205, 2 of 3: the protocol with no network. tls_record, tls_message and tls_client (each under 2,000 lines): ChaCha20-Poly1305 records, strict parsers, the state machine, transcript and key schedule, pinned certificates until #206, RSA-PSS/ECDSA/Ed25519 CertificateVerify, KeyUpdate. Two handshakes recorded against tlslite-ng replay byte for byte on both backends, also one byte at a time and coalesced; 15 crafted ServerHellos and records each refused with its own tag; the record layer matches pyca on 4,000 records. Found: RFC 8448’s application secrets (from s2n-tls; hkdf.md corrected), the slot is 179 KiB not 100 (tls-pure.md corrected), and two bugs caught reading the code before it ran. Not done: the engine, live servers as tests, the misbehaving server, mutants (PR 3)
#246packages/tls: the engine and the network#205, 3 of 3. tls.cho, the engine of tls-pure.md §2.2 (slots, pins, a fast-key-erasure DRBG), and tls_many, up to 256 connections on one thread through the Poller. 64 connections at once against Python ssl (five key types), openssl s_server and tlslite-ng, one byte and 64 KiB a read, all ok; a server closing without close_notify fails every one tls-peer-closed. A deterministic lying server: 29 connections replayed byte for byte on both backends, every row of §6.3 refused with its tag and alert; 64 recorded connections served from Rust in cargo test. 22 of 22 mutants killed. Found: the slot-overlap mutant survived until the poller read once a wakeup and the request filled a record (the first poller never interleaved one-byte reads); §4’s change_cipher_spec rule was stricter than the code and the RFC (corrected); §8 claimed the end of a connection erased the slot and only drop did (now true); the API’s three differences are noted in tls-pure.md §2.2
#248packages/x509: verifying a server’s chain, the design#206, 1 of 3, before any code. Two modules beside x509.cho (x509_names, x509_verify); an API over the sent chain’s ranges, so packages/tls verifies in place; depth-first path building with backtracking, signatures first, no certificate twice, a 64-signature budget; what leaf, intermediate and root are each checked for (the root’s cA, pathLen and constraints, not its signature or dates); dNSName and iPAddress name constraints, any other type refused. Measured: 9,572 of x509-limbo’s 9,802 cases are BetterTLS name-constraint cases, and the 14 online:: real chains all end at roots in this machine’s 128-root bundle, so the issue’s saved-chain gate needs no network
#249packages/x509: verifying a server’s chain#206, 2 of 3. x509_names (the host, SAN matching, dNSName/iPAddress name constraints) and x509_verify (a store that skips bad roots; depth-first path building with backtracking, AKI/SKI and a 64-signature budget; RSA PKCS#1 v1.5 and PSS, ECDSA, Ed25519). x509-limbo: 9,743 of 9,802 pass, every BetterTLS case among them; 39 disagreements, each checked against openssl verify and listed, 5 of them accepted here and refused by OpenSSL only for its self-issued-anchor rule. An OpenSSL CLI matrix of 33 cases, none accepted that OpenSSL refuses; limbo’s 14 real chains against the 128 system roots, 84 checks; 22 of 22 mutants killed. Found: tls-pure.md §5.1’s claim that OpenSSL accepts an expired root was false (it refuses one; corrected, and roots’ dates are checked); AKI/SKI matching, self-issued depth and five RFC 5280 MUSTs added because limbo showed them
#250Directory handles: opening beneath a directory, following no link#227 slice 1 (gap L6). Edition 6 Dir: open_dir (the anchor, open_read’s prefix check), dir_enter and dir_open_read (one component each, openat with O_NOFOLLOW; empty, ., .., /, NUL and over-long names are EINVAL with no call), dir_close; label dir_read, discharged by an owned Fs; std.dirs walks a whole path. A link to a file or directory outside is refused on both backends; 22 of 22 mutants killed. Found: O_NOFOLLOW does nothing about .. (a per-component walk opened ../outside in the probe). Slices 2 (create, rename, remove beneath) and 3 (cancho-tools, M8 flips) not done
#253packages/tls verifies the server’s chain#206, 3 of 3. The pins are gone: tls.trust loads roots through x509_verify.store_load, and Certificate is verified against them, the host and clock_unix_ms; the verifier’s tags are TLS codes with RFC 8446 §6.2’s alerts. Every recording made again with a CA, still byte for byte; live, 64 connections at once against Python ssl, openssl s_server and tlslite-ng with CA-issued leaves all succeed, and a wrong host or another CA’s root fails every one with its own tag; 24 of 24 mutants killed. Found: tls_many first passed the monotonic clock_ms, so every certificate was “not yet valid”; and two test CAs sharing a name are one issuer by name, so the “another CA” test said x509-bad-signature, correctly, until each had its own
#254Directory handles slice 2: writing beneath a directory#227 slice 2. Edition 6 dir_open_new (O_EXCL), dir_open_append, dir_rename (renameat, one directory), dir_remove (unlinkat) and dir_sync, each name one component, label dir_write discharged by an owned Fs. No link is ever written through: create over a link (dangling too) is EEXIST, append through one ELOOP, remove takes the link and leaves its target. openat’s variadic mode is declared variadic on LLVM and shaped like fcntl’s on Cranelift for Apple AArch64; a created file is 0644. 14 of 14 mutants killed. Slice 3 (cancho-tools, M8 flips) not done
#261TLS parity with the OpenSSL backend: the design#207, 1 of 5. The issue’s number (receivers needing 1.2 or AES-GCM) cannot be measured from here, and the maintainer’s requirement replaces it: equivalence with the OpenSSL backend, scoped to AEAD parity. Measured: OpenSSL 3.0.13’s ClientHello offers 31 suites, 10 groups, one X25519 share and the extended master secret. The plan: constant-time bitsliced AES-GCM, constant-time P-256/P-384 key exchange, TLS 1.3’s AES-GCM suites and HelloRetryRequest, TLS 1.2 with the six ECDHE AEAD suites and EMS required; what stays different (DHE, CBC, static RSA, X448, P-521, FFDHE, resumption) is listed with its tag. Found: RFC 7905, which the issue names for vectors, has none
#262Constant-time AES-GCM, std.aes and std.gcm#207, 2 of 5. Bitsliced AES-128/256 (BearSSL’s aes_ct) and GHASH by masked integer multiplies (ghash_ctmul32), with std.chacha20’s seal/open. Checked: FIPS 197, 1,500 NIST CAVP GCM cases on both backends, every Wycheproof case, 10,000 random cases against OpenSSL with 0 differences, 24 of 24 mutants killed, no secret-dependent branch on either backend, and a dudect Welch t-test at 10^6 samples per test with max |t| 3.13. Measured: 32.6 MB/s on 16 KiB records, a quarter of ChaCha20-Poly1305 and 1/170 of OpenSSL with AES-NI. Found: nested regions cost 8 brk calls per seal (23 µs for 64 bytes, now 5 µs with one region); ghash_ctmul64, as the design said, cannot be written with a checked 64-bit int (corrected in place); and the first timing harness reported a false leak (t = 174 for plain XORs) from decoding input just before each timed call
#264std.ecdh: P-256 and P-384 key exchange, and value_barrier#207, 3 of 5. Complete RCB formulas, a 4-bit window table read under masks, the peer’s point checked first, and std.bigmod’s reductions made branchless. Checked: every Wycheproof ecpoint case, NIST’s KAS validity cases on both backends, 4,200 checks against OpenSSL with 0 differences, 16 of 16 mutants. Found: clang -O2 turned the reduction’s and the table select’s masks back into branches on the secret (
#265packages/tls: AES-GCM suites and HelloRetryRequest#207, 4 of 5. The ClientHello offers all three TLS 1.3 suites and X25519, P-256 and P-384; records seal under the suite’s AEAD; the key schedule runs on SHA-256 or SHA-384, both transcripts kept until the suite is known; one HelloRetryRequest to P-256 or P-384 is followed with std.ecdh, its scalar drawn by HKDF from the X25519 secret so the caller’s entropy is unchanged. Checked: five re-recorded tlslite-ng traces (two through a retry), 41 lying-server cases (14 new), RFC 8448’s AES-128-GCM flight opened to its plaintext, every suite against every group on openssl s_server, 38 mutants killed. Found: the design’s “existing traces replay unchanged” was false, since the ClientHello itself changed (corrected in place); Python’s ssl now negotiates AES-256-GCM with this client; and the ServerHello’s group check is defence in depth, its mutant equivalent
#266packages/tls: TLS 1.2 with ECDHE, AEAD suites and EMS required#207, 5 of 5. TLS 1.2 ECDHE-RSA and ECDHE-ECDSA with AES-128-GCM, AES-256-GCM and ChaCha20-Poly1305, offered beside TLS 1.3 as OpenSSL’s ClientHello does; the extended master secret required (tls-extended-master-secret) and renegotiation refused (tls-renegotiation); the ServerKeyExchange signature checked against the leaf’s key type. Checked: six openssl s_server TLS 1.2 traces, one per suite; 63 lying-server cases (22 new for 1.2); the PRF against Python on 1,000 inputs with 0 differences; 20,000 fuzzed server flights with no trap; 59 mutants killed; the live matrix. Found: the design’s EMS tag and its explicit-nonce case were wrong, corrected in place
#267The assembled TLS stack under test: the design#208, 1 of 4. How each of #208’s bars is met: AFL++ through the LLVM backend’s CLANG and CC, so the code fuzzed is the code shipped with no compiler change; five harnesses fed only the bytes an attacker controls; a budget of 100 million executions from measured rates; the differential and interop matrices over the servers measured installable; dudect for X25519 and ChaCha20-Poly1305; resource bounds under a hostile server; the CI job. Found: the existing text driver, fuzzed for a minute, gave 107 trapping inputs, all in the driver’s hex decoding or its test-only record operations given keys of the wrong length, none reachable from a server’s bytes, so the harnesses read raw bytes
#270packages/tls: interop with eight servers, and the differential against OpenSSL#208, 3 of 4. Interop: Go crypto/tls, rustls, nginx, GnuTLS, wolfSSL, BoringSSL, mbedTLS and Botan, every certificate type and suite each can be told to use, 126 rows, all ok. Differential: 21 handshakes against openssl s_server and rustls give this client and openssl s_client the same version, suite, group and signature scheme; the lying server’s 66 cases against s_client agree but for 16 alerts RFC 8446 leaves open and 6 differences on purpose, each named with why. Found: every TLS 1.2 connection to nginx failed, since its ServerHello’s empty server_name (RFC 6066 §3) was refused; and the downgrade sentinels were refused in a TLS 1.3 ServerHello, where RFC 8446 §4.1.3 does not check them. Both fixed, with tests and two mutants (61 of 61 killed)
#272packages/tls: fuzzing, timing and hostile-server bounds#208, 2 and 4 of 4. Fuzzing: five AFL++ harnesses fed only where a server’s bytes go (DER, chain, messages, the whole client, and the TLS 1.3 flight past the AEAD), built through the LLVM backend’s CLANG and CC so the code fuzzed is the code shipped: 176,224,422 executions, 17 core-hours, no crash and no hang, plus 20.8 million in an interrupted first run; the minimised corpus (1,387 inputs) replays on both backends in conformance/tls_fuzz.rs, and CI fuzzes each harness for 2 minutes. Timing (dudect, 20,000 samples, both backends, an Apple M4 Max): X25519 and ChaCha20-Poly1305 pass; ECDH with scalar 1 fails on both backends and AES-GCM’s data tests fail on Cranelift, with equal instruction counts for both classes, and Arm’s DIT bit removes the ECDH failure and most of AES-GCM’s – so ecdh.md §3 and tls-parity.md §3.1, which said they pass, are corrected in place. Hostile server: 6,140 KiB mapped in every case, at most 0.055 s of CPU a connection (10,000 tickets), 0.003 s for a 31 s stall
#281The pure TLS backend in cancho-hooks#210. The design, then what building it corrected: tls-pure.md §2.1’s “no adapter, switch by dependency” was false (hooks’ tls.cho owns the socket I/O, the engine owns its slots, and no single source can carry both capability rows: no effect polymorphism), and §9’s “no ffi at all” cannot hold for hooks, which also holds libc; both corrected in place. Built in cancho-hooks#42 as a second binary from a generated source (exact-match replacements, line numbers kept), a project of its own. The https tests on both builds: 97 checks, 96 the same, 1 different on purpose (SSL_CERT_FILE: cancho reads no environment). Foreign symbols 34 to 2. 17 adapter mutants, 14 killed, 3 argued. A full handshake costs 4 to 7 times OpenSSL’s CPU, no resumption, about 106 KiB a held handshake against 58. The default stays OpenSSL until #209. Found an LLVM codegen failure for fs_read as an operand
#286TLS 1.3 session resumptionDesigned and built. The pure client’s 2.8 ms handshake is two X25519 and two ECDSA verifications (0.57 and 0.82 ms each, measured); PSK with (EC)DHE removes the verifications and keeps forward secrecy. Eight rules bound the hazard that a resumed session skips verification: the same name, the same trust store, before the leaf’s notAfter, an hour from the verification, single use, no 0-RTT, TLS 1.3 only, every server choice checked (tls-illegal-psk). Building it found two bugs: a ticket saved after a clean close had lost its host name (overwritten with the keys), so nothing resumed; and Go’s and rustls’s servers send no ticket to a client that does not advertise psk_key_exchange_modes (RFC 8446 §4.2.9), which OpenSSL, nginx, GnuTLS and wolfSSL do anyway (tls.set_resumption). Resumes against OpenSSL, Go, rustls, wolfSSL, nginx and GnuTLS; 11 lying-server cases (78), 12 engine-rule cases, 88 mutants (87 killed, 1 argued equivalent), 8.5 M fuzz executions. Measured: a resumed connection costs half a full one’s CPU (1.63 against 3.26 ms; the saving is the two verifications to the hundredth)
#338std.ecdsa_sign: ECDSA P-256 signing in constant time, and P-256 keysStep 1 of the TLS server (tls-server.md §8). k·G on std.ecdh’s ladder, s in std.bigmod’s masked registers, RFC 6979’s nonce hedged with 32 bytes of the caller’s randomness, the signature verified before it is given out; x509_key reads PKCS#8 and SEC 1 PEM (P-256 only, base64 decoded without branching on key characters) and matches a key to its certificate. Checked: RFC 6979 A.2.5 on both backends; 10,000 signatures accepted by OpenSSL 3.0.13 and std.ecdsa, all 10,000 bit-flipped refused by both; 10,000 equal byte for byte to RFC 6979 in Python; 100 openssl genpkey keys in both formats; 43 key files, every tag; 37 of 37 mutants. Timing (dudect, 10^6 a test, i7-1260P, LLVM): max
#339packages/tls: the TLS 1.3 serverStep 2 of the TLS server. One Engine with a role: open_server, add_identity (PEM chain and P-256 key, checked), replace_identity, set_alpn, serve; three suites (AES-128-GCM first with AES instructions), X25519, P-256, P-384 with one stateful HelloRetryRequest, SNI over 16 identities, ALPN, middlebox compatibility, early data skipped to 16 KiB, every refusal tagged; the signer called in one place and checked before sending. Checked: interop 88 of 88 (OpenSSL, curl, Go, wolfSSL, mosquitto pub and sub; suites, groups, retries, SNI, ALPN); a lying client of 110 cases replayed on both backends; against openssl s_server on 72 ClientHellos, 59 agree, 7 alert only, 6 documented; 12.9 M AFL++ executions over two new harnesses and 20,000 mutated connections, 0 crashes; 71 of 71 mutants. Measured: 3.5 to 4 ms of server CPU a handshake on X25519 or P-256, 6 ms on P-384, 17 to 20 times OpenSSL’s. Found: the design’s legacy_version rule breaks RFC 8446 §4.2.1 (OpenSSL disagreed); accept is a builtin’s name; tls_mutants.py stale on main since #334
#346examples/tls_echo: the TLS server to copyStep 3 of the TLS server. A non-blocking echo over std.conns and one Poller, the engine between each socket and the echo, no foreign code (report bounded and pinned). Certificates read beneath a Dir opened once, so the loop and the reload hold dir_read and never the Fs; handshakes bounded in progress and per second, the excess delayed in a queue rather than refused, a deadline per phase, close_notify when idle; SIGHUP reloads with replace_identity, SIGTERM sends close_notify to all. Checked: conformance/tls_echo.rs (64 connections through tls_many, reload taken and refused, stop) and scripts/tls_echo_test.py (OpenSSL over 3 suites × 3 groups and HRR, 200 concurrent Python ssl connections, reload by serial, the bounds, idle, stop), 8 of 8 on linux-aarch64 and macOS; 8 of 8 mutants. Measured: 3.0 ms of CPU a handshake on the M4 (330 to 340 a second a core), 5.5 ms on CI’s x86-64 (the first x86-64 figure), so the default rate is 100 a second, not 200: at 200 the bound sat above what an x86-64 core can do. Found: step 2’s 3.9 ms included 0.9 ms of tls_serve’s loop (§6 corrected); event says closed before the server’s close_notify is queued; http.server cannot take bytes from anything but its own sockets (http-server.md §11 proposes the change)
#360http.server byte-fed mode, and examples/https_hellodocs/http-server.md §11, built. A server opened with open_bytes has no sockets and no listener: attach, room, input, end_input, ready, output, closing, detach, with the clock an argument, one parser and one back-pressure rule shared with the socket path (the 21 api and 9 http_server tests pass unchanged); stream for an answer larger than the output room, sent as the connection takes it. examples/https_hello puts it behind packages/tls: keep-alive, pipelining, JSON echo, a 64 MiB streamed body, a client that stops reading stops being read, certificate reload and the handshake bounds as in tls_echo, whose shared half moved unchanged to examples/tls_echo/front.cho; authority bounded and pinned, the same labels as tls_echo. Checked: 19 byte-fed tests with no socket (every framing, one byte at a time, half-close, back-pressure, idle on the caller’s clock, 24,000 random operations), 79 of 90 mutants killed and 11 argued equivalent (the first run killed 74 and found seven missing tests), conformance/https_hello.rs, and scripts/https_hello_test.py against curl, openssl s_client and Python (14 of 14 on linux-x86_64 in CI and linux-aarch64: 2,000 requests on one connection, 300 pipelined, 200 connections, 64 MiB byte for byte, a stalled reader, 1,500 hostile requests). Measured: on CI’s x86-64, 23.4 µs of server CPU a request over TLS against 5.6 plain on one core (4.2 times; 42,000 requests a second against 178,000), 83 MB/s streamed; on Docker’s aarch64 VM 9.8 against 2.7 (3.6 times). Not profiled. Corrected: open_bytes is (heap, size, out, idle, max) and answers Opening (a kernel poller cannot be absent), the clock is an argument, room/end_input/stream were missing from the proposal, and it was 330 lines, not 100. Found: the socket path’s ready queue could in principle overflow (serve_events did not compact it); scripts/publish_packages.py did not cover packages/http-server
#364narrow into several, and examples/tls_echo_fixednarrow(fs, "a", "b", ...) consumes an Fs or Exec and answers a tuple of capabilities, one narrowed to each literal: each literal checked as the single form is, no two equal or nested, refused on Ffi, Net and Signals with the set form named; no edition, no new tag, no backend change (the checker answers an Expr::Tuple of zero-sized parts). The report format is unchanged and names exactly the paths narrowed to and read. Checked: conformance/narrow_many.rs (both backends: two and three paths, order, confinement both ways round, /tmp vs /tmpevil, the TLS setup with open_dir, Exec), 4 accept and 17 reject fixtures, 6 of 6 mutants killed. Payoff: examples/tls_echo_fixed narrows to /dev/urandom and a fixed /etc/cancho/tls_echo; its pinned report has no fs_read("") (cost: the directory is fixed at build time, --dir refused). Found: a capability inside a tuple or struct parameter does not discharge its label (unchanged)
#368packages/http-client: a non-blocking HTTP/1.1 client, and examples/http_fetch_nbdocs/http-server.md §11.8 item 4, built. A table of N connections fed bytes and returning events (Connect, Head, Body, Done, Failed, Close), the clock an argument, no callbacks, and nothing reached but the heap (authority pinned). A request body streamed with back-pressure and Expect: 100-continue; status and headers as soon as complete; 1xx skipped; the body by length, chunked (decoded in place) or until close; a response before the request is sent read, its connection not reused; every refusal tagged (the gateway’s response.*). A pool with the strict reuse rules of cancho-hooks §53, the newest idle connection first, eviction for another upstream, and one retry as a per-request flag, replayed from the output buffer. Timeouts on the caller’s clock (connect with the handshake, head, between body pieces, the whole exchange, Expect). TLS in the example’s driver (fetch_io.cho) with the caller’s trust store and the name verified; DNS the caller’s. 118 unit tests on both backends, 517 mutants (461 killed, 56 argued equivalent), a million damaged responses with no trap, 17 live cases over plain TCP and TLS 1.3 against a raw-socket server, examples/https_hello, nginx and a hostile server. Cost: 2.9 µs of client CPU a plain keep-alive request, 7.5 µs over TLS, 3.2 ms for a new TLS connection (aarch64 VM). Corrected in place: a harness whose byte assertions could not fail, a pool default that made a benchmark churn, and a driver that spun on a blocked socket and lost an answer when a write failed first.
#369http.server: streaming request bodies and Expect: 100-continuedocs/http-server.md §12, designed first (committed alone, with two measured numbers) and built. A server that calls limits gets a request whose body is still arriving (or does not fit size) from next as soon as its head is complete, and the held request’s ticket pulls the body: body_part / body_take / body_state / body_total, and proceed for 100 Continue, which the server never sends by itself, so a gateway can refuse by route or size first (413, no 100). Input is never taken past the end of a length body; chunked bodies are decoded in place by a byte-at-a-time machine (extensions ignored, trailers checked and discarded); every refusal has a status and a rule tag (X-Rule); a deadline for the head and a stall timer for the body, on the caller’s clock; the same room back-pressure as answers, in both directions, over the socket path and the byte-fed path with one implementation. Without limits nothing changes. examples/https_hello has POST /upload (any size through the 16 KiB buffer, answered with its byte count and SHA-256), POST /upload/refuse, --read-timeout, --max-body. Checked: 22 tests without a socket and 6 with, a 50-wire chunk corpus, 8 seeds of random streams, 232 mutants (215 killed, 17 argued equivalent) and 9 socket-only ones killed, scripts/https_hello_test.py (uploads of 0 bytes to 1 GiB hashed, length and chunked; curl -T with Expect, a refused one uploading 0 bytes; stalled clients answered 408; 1,500 mangled uploads), 18 of 18 on linux-aarch64. Measured: 2,338 MiB hashed at 142 MiB/s with the server’s RSS within 116 KB; the socket path without limits and https_hello with it cost the same per request as before. Found: enqueue could write past the ready queue when a held connection was answered between inputs (§11’s shared code); https_hello resets a different connection when a client keeps sending after its own was ended (on main, filed); the file is 1,990 of 2,000 lines in cancho fmt’s form, so the next addition splits the package
#391TLS performance: the profile and the design#378, first deliverable. A full server and client handshake profiled on an i7-1260P and an M4 VM beside OpenSSL (the server 15 times OpenSSL’s cycles, the client 11). Three operations are 77 to 91% of a handshake and each costs inside it what it costs alone; the engine’s copies, zeroing, hashing, HKDF and loop are about 5%. The surprise: every region is a 64 KiB malloc and free, and glibc trims and regrows the heap each time: 6 to 10% of a handshake and 45 to 53% of a request on a kept connection (told not to trim, https_hello was 1.9 to 2.5 times cheaper per request on x86-64 and equal to an OpenSSL server). Ranked levers with gains computed from the measured parts: the 5-times end of the target is reached without a new builtin, by 1% (arithmetic) (the allocator, a fixed-base table for the signature and one per identity for the server’s own check, X25519 on 25.5-bit limbs, NIST-prime reduction); wide multiply is the one thing the language cannot do. Targets and gates stated first; scripts/tls_perf.py check counts instructions under valgrind against benches/tls_baseline.json and runs in CI. Nothing in the stack changed; the clock of the shared x86-64 box moved by 2, so the figures are cycles and instructions
#394packages/tls: session tickets for the TLS 1.3 server#379, step 5 of the TLS server (tls-server.md §8), part of #378. Stateless tickets sealed with ChaCha20-Poly1305 under a per-ticket subkey (a zero nonce, so none repeats across the processes of a fleet), psk_dhe_ke only, never 0-RTT. A ring of four ticket keys, made by the engine and replaced every lifetime or supplied by the program (set_ticket_keys, declarative, the same file for every process). Bound to the name, to the SHA-256 of the issuing identity’s leaf (a renewed certificate refuses the old tickets, a reload of the same one keeps them), to the suite’s hash and the ALPN protocol; lifetime capped at the certificate’s end; the age within 30 s; the binder in constant time; the engine says why a ticket was not taken (ticket_verdict). Not single use, argued and bounded (§12.4). 190 lying-client connections, 146 of 146 mutants, the interop matrix (OpenSSL, curl, Go, wolfSSL, GnuTLS, rustls, the package’s own client; mosquitto does not resume), a differential against openssl s_server, fuzz seeds. A resumed handshake costs 0.48 of a full one on the M4 (1.52 ms against 3.15) and a third to a half on x86-64. Corrected in place: a harness that waited for established while tickets were queued, and a lying client whose own binder was wrong.
#398packages/tls: client certificates (mutual TLS) in the TLS 1.3 server#384, step 4 of the TLS server (tls-server.md §8), built on #394 (merged). tls.client_auth_mode (off, optional, required), tls.trust_clients (a store of its own, never the system bundle: at most 32 roots and 32 KiB, strict, replaced at run time with a generation count), the CertificateRequest, the client’s chain verified for clientAuth with x509_verify.verify_chain and its CertificateVerify (P-256, P-384, RSA-PSS, Ed25519), and the verified identity given to the program per slot (peer_verified, peer_certificate, peer_subject, peer_serial, peer_fingerprint, peer_san): the engine authenticates, the program authorizes. No slot memory. On the tickets: none is sent to a client that presented a certificate, none is accepted when certificates are required. Interop with OpenSSL, curl, Go, wolfSSL and mosquitto (75 rows), a lying client of 78 connections, a differential against openssl s_server, a fuzz harness, 202 mutants; examples/tls_echo, https_hello and tls_echo_fixed take --client-ca and --require-client-cert. A client certificate costs the server 1.7 ms (P-256 under the CA) to 4.2 ms (Ed25519) on an M4
#237Signals: Signals/SignalWatch, edition 6Part (b) of #237 (PR number to be added when it is opened). A program that only needs to know it was asked to stop no longer needs Ffi("libc"): a capability narrowed to the signals it claims (signals("INT,TERM") in the row and in cancho authority, which stays bounded), a bitmask signals_pending, a handle the Poller waits on (a stop wakes the loop in 0.1-0.5 ms, not at the next 50 ms poll), and signals_close for “a second signal kills at once”. signalfd on Linux, kqueue EVFILT_SIGNAL on macOS (written, not run). Eight claimable signals; every other is refused under the new rule signal-not-claimable. Split gets an eighth field as a fourth declaration (edition 6), not a field on edition 5’s, which 48 files destructure. 20 conformance tests on both backends send real signals; 37 mutants killed. A local hiding a qualified std function found (min and math.min), not fixed
#260Directory listing: the names beneath a Dir#222 slice 1 (gap L2). Decided on the design’s PR: listing goes on a Dir handle, not a path (fs_list). Edition 6 dir_list (fdopendir on openat(dir, "."), a position of its own), dir_next (one name into the caller’s buffer, kind from d_type, never ./.., ENAMETOOLONG rather than a cut name), dir_list_close; label dir_read; std.dirs.list sorted bytewise with a cap. A hostile directory of 100,010 entries lists equal to Rust’s read_dir on both backends; readdir 250 ns an entry, flat memory. 19 of 19 mutants killed. Found: the design’s dup shared the position between two listings. Slices 2 (dir_stat) and 3 (list tool) not done
#263Directory listing slice 2: a file’s status beneath a Dir#222 slice 2 (gap L3). Edition 6 dir_stat (fstatat with AT_SYMLINK_NOFOLLOW on one checked component) answering DirStat::Ok(kind, size, mtime); a link is reported, never followed, and a FIFO answered without opening it; struct stat read at a per-target offset table. Equal to lstat on every hostile entry on both backends; 12 of 12 mutants killed. Found: Status, the design’s name, is too common to take into the prelude; it is DirStat. Slice 3 (the list tool) not done
#268Processes: the design#237 (a), cancho-tools#10. Starting a program was the one authority cancho reached only through Ffi("libc"). Edition 7 Exec("prefix") bounds which programs start, with Fs’s path rule; the child gets exactly what is passed (no inherited descriptor, environment, console or blocked signal; LD_*/DYLD_* trap); Child is linear, so an unreaped child is a compile error and a kill cannot reach a reused pid; the child’s pipe end is consumed by exec_spawn; socket pairs, so a dead child is EPIPE, not SIGPIPE; posix_spawn, not fork. Found: every descriptor a builtin opened crossed an exec
#271Processes slice 0: close-on-exec everywhereEvery descriptor a builtin opens is close-on-exec, set by the call that makes it on Linux (openat with O_CLOEXEC replaces creat and open; SOCK_CLOEXEC, accept4, F_DUPFD_CLOEXEC) and by fcntl straight after on Darwin. A child that listed twelve of its parent’s descriptors now lists 0 1 2 and its own, on both backends. 12 of 14 mutants killed; the two survivors are equivalent on Linux, since glibc’s fdopendir sets the flag itself (measured)
#274Processes slice 1: Exec, children and channelsEdition 7 Exec("prefix") (in Split, narrowed by Fs’s path rule, labelled exec("prefix") in the authority report), exec_spawn by posix_spawn with exactly the arguments, environment and three streams passed (LD_/DYLD_ entries trap; closefrom on Linux, CLOEXEC_DEFAULT on Darwin; signals reset to their defaults), child_wait (Code/Signaled/Failed), child_kill by a std.signals bit, and channels as socket pairs (EPIPE, never SIGPIPE). 8 reject fixtures, 8 conformance tests on both backends, 22 mutants: 20 killed, 2 equivalent. Carries #273’s bound-check fix, found by the first Exec program built with LLVM.
#275Processes slice 2: watching a child and a channelEdition 7 poller_add_pipe (a channel as a Conn) and poller_add_child: a pidfd on Linux, opened by exec_spawn and carried in the Child’s high half so child_wait can give it back; EVFILT_PROC/NOTE_EXIT on Darwin. A refused pidfd_open is the errno, carried and answered. Six conformance tests on both backends (output and exit, an already-ended child, a deadline then a kill, no descriptor leak, the refusal under seccomp, EMFILE), two reject fixtures, emission tests for all four triples. Found on macOS: kqueue will not watch a child that has already exited (ESRCH, 200 of 200), so that exit is reported as an EVFILT_USER event added and triggered in one change (two changes lost the token); and EMFILE is reachable, by a spawn whose three streams are Null, so §4.8 was corrected and a sixth test reaches it. Mutants: 13 of 13 killed (scripts/process_mutants.py; six by a hang).
#276Processes slice 3: std.processstd/process.cho: Argv (add refuses an entry containing \0, an outcome), channels, and capture(heap, clock, child, to_child, from_child, input, most, timeout) -> (Buffer, Ran). Corrected: §7’s run(heap, exec, ...) cannot be built, since no parameter takes an Exec of any prefix, so the caller spawns under its own; and Args/arg are taken. Measured on both kernels: a channel holds 8,192 bytes (macOS) and 180,224 (Linux), and write-all-then-read deadlocks at 32 KiB and 512 KiB, so input and output share a poller; draining to Again after the exit kept every byte in 600 of 600 runs per kernel, where waiting for the end waits 2 s for a background sleep. Nine conformance tests on both backends. Mutants: 7 of 11 on the first run, 11 of 11 after four sharper tests (a lingering child past the bound, the unwatchable path, two spins caught by wait4’s processor time). Found: a qualified call resolves against local bindings, reported separately.
cancho-tools#17Processes slice 4: the MCP serverIn cancho-tools, on std.process: a cancho program serving the eight tools over stdio MCP, holding Exec narrowed to their directory (baked in at build, since narrow takes a literal) and no Fs, Net or Ffi. Each call is a fixed argv: the server’s --root first, flags as --name=value, operands after --. 14 conformance tests, among them each tool byte for byte equal to its CLI, --root not overridable, the limits, the protocol, a client that waits for each answer (catches a missing flush) and 300 hostile requests. 9 of 9 mutants killed after two sharper tests
#292Processes slice 5: the working directory and standard errorThe two prerequisites of a run tool in cancho-tools. exec_spawn_in(&Exec, &Dir, ...): the child starts in a lent Dir (posix_spawn_file_actions_addfchdir_np, added before the closefrom, since a chdir after it is EBADF on Linux, measured), one IR node and both backends. std.process.capture_both: standard error on the same poller as the output, each channel with its own bound, one deadline, both buffers back on every path (reading the output to its end first deadlocks from 16 KiB of errors on macOS and 256 KiB on Linux; one poll set does 1 MiB each). Corrected: §4.1’s relative program path traps under a narrowed Exec. 15 new mutants killed, the eleven capture ones re-run 17 of 17; the drain after the exit has no mutant, since it is redundant by construction. Stdio::Merge would be an edition and is not built
(PR to be added)Foreign authority: symbols, and a scope that is a setcancho authority said UNBOUNDED and nothing more for any foreign call, one statx and 25 OpenSSL functions alike. It now says unbounded_by: every reachable foreign symbol as scope:symbol, sorted, one a line, so a CI pin diffs an added symbol as one added line; bounded keeps its meaning. Ffi("libc,libssl") is a set of libraries like Signals("INT,TERM") (canonical, covers is containment not a text prefix, so Ffi("libc") no longer narrows to Ffi("libcrypto")), each extern fn names one library, and a function is lent the part it needs: two libraries in one program, which could not be written. Found, and fixed: an extern fn with no Ffi was accepted – system ran a shell on both backends while the report said bounded: true and never touches foreign code; now foreign-declaration. New rule foreign-scope. Symbol-level rows and capabilities weighed and declined (37 symbols in one TLS chain; no splitting narrow). Found, pinned, not fixed: a slice is a pointer plus a length so statx(AT_FDCWD, path, flags, mask, buf) cannot be declared as C has it (strace: EINVAL), and a string has no \x
#278Threads on aarch64 Linux: the counter is alignedEvery Cranelift binary that spawned died with SIGBUS on aarch64 Linux: DataDescription defaults to alignment 1, glibc’s one-byte completed.0 put the thread counter on an odd address, and aarch64’s exclusive load refuses it. macOS was aligned by luck and LLVM by type. Word data now asks for 8 and byte data stays at 1; checked in both object formats. 14 failing tests pass on the same container. Found: ulimit -c 0 lowers the hard limit, so the differential harness could not set RLIMIT_CORE to 1; a hard zero is now left alone
#351Directory handles slice 4: a rename that never replaces a nameEdition 7 dir_rename_new(dir, from, to) -> [dir_write] Done: renameat2(RENAME_NOREPLACE) on Linux, renameatx_np(RENAME_EXCL) on macOS, so a taken destination is EEXIST with its bytes untouched and there is no look-then-rename window. A filesystem without the flag (NFS v3/v4.2 and ntfs-3g measured on Linux, ExFAT on macOS) is EOPNOTSUPP/ENOTSUP, never a replace; Linux’s EINVAL is mapped because a bad name is EINVAL too. Conformance table and a race on both backends; 10 of 10 mutants killed. The unsupported-filesystem test needs CANCHO_RENAME_UNSUPPORTED_DIR and is vacuous in CI. cancho-tools’ move not changed
#363std.json.parse_with and read_bytesTwo asks from cancho-table’s reader design (its PR 25). json.parse_with(src, tape, st): parse with the two ints of state passed in, no region per call; per line on 1M JSON lines, macOS 567 → 94 ns (and parse is 2.9x slower at 16 threads than at 1, parse_with 9.6x faster), Linux 237 → 233 (re-measured after the rebase onto edition 9, load 10 to 20 on the Mac); identical to parse, answers and tapes, on 58,733 documents and the 318 files of JSONTestSuite (json.md §3.1). Edition 9 read_bytes(io, into) -> [io_read] Read: fread on the stdin stream getchar reads, file_read’s Got/End/Failed; 75 → 5,465 MB/s on macOS, 438 → 4,290 on Linux, with the row still io_read where /dev/stdin through Fs would say fs_read; Cranelift, LLVM, wasm32-wasip1. No identity moved (1,805 std ids identical on the rebased branch; the only additions are parse_with’s body and sig). Not done: a stdin kind query, because seeking needs a File for descriptor 0 and an lseek that are not there (standard-input.md §7.5)
#312Atomics and a bounded channel, designedDesign only. Hard constraint or nobody asked? Nobody asked: the compiler already emits an atomic in every threaded binary (disassembled: lock xadd, ldaxr/stlxr), both backends have all five operations sequentially consistent, and &Atomic crosses to a thread under today’s payload rule. The real constraints are elsewhere: a borrow copies (a shared one writes nothing back), so an atomic cannot be the value and is a handle to heap words; a struct names no region, so a worker gets one pointer. And a finding the question did not ask for: the checker does not stop two threads writing one value – &! copies, two copies spawned lose updates on both backends (benches/atomics/race.cho) – so threads.md §3 is corrected in place. SC only (x86 SC store 6.08 ns against 0.34, nothing elsewhere), row conc so the folder never treats a load as pure, edition 8; a channel as a library over int messages. A gate fixed before any code: litmus tests whose plain versions are shown to fail, stress, nine mutants, a model check. Not built
#344packages/x509: a chain verified without a host nameFor cancho-pg’s verify-ca and the TLS server’s client certificates. verify_chain checks the path for a purpose (serverAuth, clientAuth) and no name; verify_name is the name as its own step; verify is the two and answers as before (every recorded vector byte for byte). verify-ca follows libpq: OpenSSL’s default ssl_server purpose, no name. A refused chain’s leaf view is zeroed. A 56-case no-name matrix against openssl verify -purpose sslclient/sslserver; 33 of 33 mutants killed. Found: the design’s “a root’s EKU is not read” was wrong for client certificates (OpenSSL refuses such a root; it is read for clientAuth now), and for a server’s purpose it is a disagreement verify already had, listed for a person
#356UDP: datagram sockets, the connected half#355, asker cancho-dns#18. The design first (a Net bound is a literal, so there is no sendto with a free destination: a connected socket for one upstream with net_out(bound), a bound one for a server with net_in, replies only through a runtime-issued peer ticket), then slice 2: Udp, udp_connect/udp_send/udp_recv/udp_local_port/udp_nonblocking/udp_close and poller_add_udp on both backends, reusing tcp_connect’s node and conn_write’s emitter. Checked: nine conformance tests on both backends (empty datagram as Got(0), oversize as Truncated(100), a stranger’s datagram never delivered, Again, poller readiness, the bound enforced, the authority row), five reject fixtures, 9 mutants killed. Found: two mutants survived the first tests (a port read from the wrong bytes; a hang instead of a failure) and the tests were strengthened; four count-pinning tables moved (selfhost tables, WASI refused set 48 to 55, label count, prelude counts) and were corrected in place. Not done: udp_bind, peer tickets, udp_send_to; the round-trip measurement against C; Darwin
#358UDP: the bound half#355, 2 of 2 after #356. udp_bind (tcp_listen’s node without listen, net_in(bound)), udp_recv_from and udp_send_to on both backends. A bound socket has no free destination: the sender is written into a 65,536-entry runtime ring and the caller gets a ticket; udp_send_to sends only to a ticket that is positive, current, and issued to this very socket, else Failed(EBADF). Checked: a server answering its sender; two senders answered in the opposite order; five forged tickets refused with nothing sent; another socket’s ticket refused; a ticket older than the ring refused (the program feeds itself 65,536 datagrams); the authority row has net_in and no net_out; mutants killed on each backend. Found: the first design’s udp_peer was unnecessary (an out-parameter needs no per-socket state), corrected in udp.md §4; the selfhost tables and the WASI refused count (55 to 58) moved with the new builtins. Not done: the round-trip measurement against C; a std table of Udp handles
#370UDP: tickets for datagram sockets#355 slice 4, asker cancho-dns D3. A forwarder holds a connected socket per query in flight and a Udp is a resource std.vec cannot hold, found by reading its design against the language. udp_detach / udp_attach are conn_detach / conn_attach with a kind bit (bit 31 of the descriptor half) on the one epoch table, so a Conn’s ticket does not redeem as a Udp or the reverse; std.udps works over a std.conns Table, whose slot bookkeeping is now public so it is shared, not copied (the duplicate-body test caught the first version). Eight forged redemptions refused, a three-socket table, six mutants of the kind bit killed on both backends. Not measured: the per-datagram cost of the table
#399UDP: who a ticket names#355 slice 5, asker cancho-dns (access list, rate limiting). A bound socket answers through a ticket, deliberately not an address, so a program could not read who wrote to it. udp_peer(&Udp, ticket, &![byte]) writes the sender in the 19 bytes conn_peer (#367) uses, after the three checks udp_send_to makes (EBADF otherwise, EINVAL for a short buffer), so std.addr reads it. Data, not reach: there is still no send to an address. My first version of this, conn_peer and conns.peer with another encoding, collided with #367, which had landed meanwhile; it was dropped, not merged. Tested over real sockets on both backends; eight mutants killed
#395TLS memory per connection#383 of #378. Measured first, at 50 to 10,000 connections on arm64 and x86-64 beside OpenSSL’s server: an established idle tls_echo connection was 119 KiB resident in a 263 KiB slot (OpenSSL 14 to 49), 80 KiB of it the integers std.ecdh’s work area and the transcripts, which every handshake writes; the 64 KiB buffer was never the cost, and nothing was ever released. Built: a core per slot (4.5 KiB server, 8.3 KiB client) and a pool of work areas leased while a handshake runs or bytes are in flight; the engine’s API and what it accepts and refuses are unchanged, open_with_areas takes fewer areas than slots (tls-pool). 17 KiB idle, 53 after a record; 12.8 KiB for a client and a server slot together at 100,000 connections; a released area is cleared (tls.residue), a connection that finds no area waits, and --areas mode ends a connection holding one past --handshake-timeout; about 1.1 microseconds (16%) more server CPU a request. 25 work-area mutants, tls_pool.cho, --areas
#367The peer of a connectionconn_peer(&Conn, &![byte]) (getpeername, 19 bytes, no row, edition 5, both backends, refused on WASI), std.addr (a pure Peer, RFC 5952 text and a parser, IPv4-mapped IPv6 normalised to IPv4, a /32 and /64 key) and conns.peer; asked at call time, not stored at accept (237 ns against 21-25 us a loopback connection; a reset connection has none, ENOTCONN on Linux and EINVAL on macOS). examples/tls_echo logs peer= on every connection line and has --per-address and --per-address-rate. Checked: eleven clients with their own ports, slot reuse, FIN and RST, the buffer limits, an IPv6 and a mapped peer through a dual-stack socket put under the listener, 9,000 lines against Rust’s std::net, the authority report unchanged (and pinned for tls_echo), a client from 127.0.0.2 unaffected by a bound on 127.0.0.1 (Linux). Found: every cancho socket is IPv4, so no cancho listener can be reached from ::1 until tcp_listen is dual-stack; the first draft’s duplicate hex helpers were refused by the duplication check. Not done: X-Forwarded-For in packages/http-server, the broker’s limit, a UDP peer
#396TLS client: client certificates and ALPN#386. The two of the issue’s four gaps a gateway needs, built, and the other two decided in writing. Client certificates (mutual TLS to an upstream): tls.open_mutual, add_client_identity (four P-256 identities chosen by the host name, never sent to a host they do not name), a CertificateRequest answered with the chain and a CertificateVerify (std.ecdsa_sign.sign_checked, hedged) only if the request lists ecdsa_secp256r1_sha256 and its authorities are empty or name an issuer of our chain, otherwise the empty Certificate; TLS 1.3 and TLS 1.2; a ticket is not offered after an identity changes or the client certificate expires. ALPN: an offer, the choice reported, a choice not in the offer refused. Against OpenSSL, nginx, Go and wolfSSL (92 rows) and openssl s_client (67 flights, 0 unexplained); 77 lying-server and 10 engine cases; the signature costs about 1.8 ms of client CPU. Found: OpenSSL accepts an unoffered ALPN choice and sends its certificate to a request that does not name its issuer. Decisions, proposed: revocation: build nothing now (the public web is leaving OCSP; the stapled path is 400 to 800 lines of parser for a soft check); X25519MLKEM768: not now (no server found that requires it; about 700 lines and a constant-time audit; triggers to revisit written down)
#421std.crc#352, two askers (cancho-cache’s cluster slots, cancho-log’s record check). CRC-16/XMODEM (Redis’s crc16 loop, table-free at 16 bits), CRC-32/IEEE, and two CRC-64s (CRC-64/XZ and CRC-64/REDIS, the “Jones” polynomial) — whose state genuinely uses the top bit, which a 64-bit signed language whose >> is arithmetic and whose all-ones mask is not a writable literal cannot hold in one word. The design is two 32-bit halves, state and table both, no mask ever needed; docs/crc.md §2 records why an unsigned-shift emulation was tried and rejected. Corrected in place (twice): the issue’s CRC-64 check value 0xE9C6D914C4B8D9CA is right, and it is Redis’s: the Jones polynomial 0xad93d23594c935a9, reflected, init and xorout 0 gives exactly it (computed independently in Python; it is the value in Redis’s own crc64 test). An earlier version of this row said it matched no combination, which was false (it had been tried against the wrong polynomial); std.crc ships both crc64_* (CRC-64/XZ, ECMA-182, check 0x995DC9BBDF1939FA) and crc64_redis_*, and the accept test gates both
#421Net bounds#362, asker cancho-dns. Design only: the one-Net-one-bound-two-meanings defect a listen-and-dial program cannot carry, the host-prefix that admits ns1.example.com.attacker.net, and the report that cannot say “several”. Three steps: fix the report’s lie first (bounded: true on an empty bound, a one-PR change that is also the cheapest honesty fix in the file), then several-children-from-one-narrow mirroring Fs, then the direction split and exact host matching together in the next edition. Deliberately no runtime connect-to-address, no inbound host bounds, no DNS resolution in the checker
#421One store, several threads#354, asker cancho-cache. The answer the issue asked for: yes, the design exists, but on two primitives that do not — T-P3’s cross-thread lend (a checker rule first: a new region relationship, checkable locally or not at all) and a poller wake-up from another thread (pure runtime, no language change, meaningful only with the lend). Recommendation: the epic stays process-per-core — which it already says it does not depend on — and the shard design is recorded so it is not re-derived. Sequencing argument recorded: T-P3, then the wake-up, then §2
#415Structured ingest#411. The missing half of cancho print: the canonical AST as JSON (print --output json) and back (cancho ingest), so an agent writes data and never a token-level spelling error. The two §2a contracts hold over JSON too, enforced by the same walker over the same corpus — every accept and reject fixture, the examples, the packages and the whole stdlib, identity-preserving and a fixed point. Three new rules (ingest-json, ingest-node, ingest-arity) because they are new ways to be wrong, not new wordings: not-JSON, an unknown vocabulary word, a known word with the wrong shape, each answered with a position into the JSON. Building it found one real bug in the encoder: float bits crossed as a signed integer, so a pattern above i64::MAX came back negative — the bits cross as a number’s text now, for the reason canonical-ast.md §3 gives. Names are checked against the lexer’s own rules, so a tree built from data cannot contain a name no .cho text could write
#420Foreign linking#388. extern fn free collided with the LLVM backend’s own arena declaration — invalid redefinition of function 'free', answered internal, and cancho-robot’s first spike had to leak the string instead. Fixed by declaring malloc and free only when the program does not (the guard calloc, listen, accept and the rest already had); the backend calls through its own call-site type. A first version moved the arena to undefined @cancho_malloc/@cancho_free names and no --backend llvm program linked; the regression test now builds and runs a program with its own free/malloc beside a region and a boxed slice
#422 T1Tty#422 T1, asker cancho-robot. The serial-port capability’s design: both termios ABIs from the spike’s measurements (72/8 vs 60/4 bytes, the macOS two-step and its measured IOSSIOSPEED — which pyserial hard-codes wrong, and neither belongs in a program), the variadic-ioctl trap recorded as the argument for the whole epic, the seven-verb surface, tty_read/tty_write labels answer never touches the filesystem-while-opening-a-device (finding 6), and the four open decisions answered: prefix narrowing (non-empty, for the reason net-bounds.md §2.4 records), a plain validated integer for speeds, edition 8 (shared with #400’s word-scan), V4L2 deferred. One new rule tag, tty-path. T2–T4 implementable from this; T5 is the robot’s, hardware-gated
#410vcs sync#403 items 1 and 2. The store rides inside git: cancho vcs sync recomputes every declaration’s identity per file and writes a sorted plain-text .cancho/ids.txt (merges as text, conflicts land on the declarations that truly collided) plus op records (AddFunction, ModifyBody, RemoveFunction) in the OpLog shape vcs publish writes; --check writes nothing and exits 1 on any semantic change, for CI. A signature change is reported and counted but has no op vocabulary yet, so its index entry is overwritten in place (said so in the output). A root-module name declared differently in two files is reported as a collision and turns --check red rather than silently overwriting; symlinks are never followed. A full sync of this repository indexes 3,777 declarations
#424Tty, the capability#422 T2. res Tty, prefix-narrowed as Fs is; the Port handle (Udp’s shape — one name cannot be two types, so the capability keeps Tty and the handle is named, as Udp is beside Net); the tenth Split field (after edition 7’s exec), edition 8; seven builtins across both backends, with the measured termios numbers in cancho_ir::tty so the two backends cannot disagree. The macOS IOSSIOSPEED step is the variadic-ioctl trap written once as a fixed call, not nine extern fn declarations ABI-hacked per program — the spike’s control run is the whole argument. The row: tty_open(prefix) argument-carrying, tty_read/tty_write path-free (the prefix spent at open, udp_recv’s shape), so a robot-shaped program reports bounded: true with no foreign line. WASI refuses the family (tty.md §9: no termios). One rule re-used (capability-not-narrowable); examples/tty_hello.cho opens-configures-pings-closes on cranelift, with the failed-open path exercised for real
#424Tty: the poller, the measurement, the report#422 T3+T4. poller_add_tty was already the family’s sixth member from T2’s dispatch — T3’s deliverable is the honest end-state: examples/tty_wait.cho puts the port on the poller and measures: ten 50 ms waits, timed by clock_ms, printed — 502 ms with nothing registered on this machine, the spike’s own 501 ms baseline reproduced by an example rather than asserted by a log. T4’s acceptance: cancho authority answers bounded: true with tty_open("/dev") argument-carrying and no foreign line — the robot’s founding sentence, now a command output. The narrowing fixture (tty_narrowing.cho) pins the prefix story with the path inside the prefix (a path outside traps at run time by design — the check is the capability’s teeth, Fs’s own behaviour, confirmed identical); the reject fixture pins the widening refusal. One honest detour recorded: the out-of-prefix trap first read as a compiler bug and was bisected across Fs before being recognised as the security mechanism working
#400Scanning a word at a timeThe gap the cancho-table CSV scanner found (gap-scan.md there): no word load, no bit counts, no vector compare mask. Edition 9 load_le64, byte_mask64, trailing_zeros, leading_zeros, popcount: pure; an out-of-range load or mask traps as indexing does, the counts are total. Both backends (LLVM IR; Cranelift ctz/clz/popcnt/vhigh_bits) and wasm32-wasip1 (vectors scalarised, still 69 refused). On 1 GiB the mask counts a byte 15-21x faster than the byte loop on arm64 and x86-64, both backends (a loaded Mac, a shared gram: instruction counts are the result); against memchr for a first match it is 1.1x (LLVM) to 2.1x (Cranelift) slower. Found: x86-64 popcount is a software sequence on both backends; a pure kernel in a timing loop is hoisted or dropped by LLVM, so an in-program clock lied; the conformance tests’ shared scratch directories fail when two checkouts run them together. Differential test against Rust, 20 trap-edge calls, 21 of 21 mutants killed; no existing hash moves
#412cancho satisfy#406. The manifesto’s spec-to-implementation loop as the smallest composition of machinery that exists: a contract is a .cho file whose test_* functions call the candidate; satisfaction is check plus test against the pair, and the verdict names each candidate declaration’s recomputed SigId. Exit codes are the composed commands’ own (0 satisfied, 1 checker refusal, 4 test failure) plus 2 for a contract with no tests of its own or one that never calls the candidate, so a vacuous contract is refused, not passed. The first committed contract/candidate pair (max) runs in CI
#409Stability plateau, held mechanically#405. hash-stability.md §3’s number was a paragraph that had to be corrected when it climbed. scripts/history.py --explain now replays every distinct .cho revision under std/ and examples/ through today’s compiler and exits 1 on any failing rule not in the committed exemption file, on any rule whose revision count grows past its recorded count, and on a listed rule that no longer fails; the stability-plateau CI job runs it on every push. Baseline: 210 revisions, 140 failing (67%): duplicate-declaration 105, unknown-name 34, not-a-function 1. Found while reconciling the figures: the harness only replays .cho paths, so the 382 older .ls revisions are not in the number, and 67% is not comparable with #163’s 39%
#393mul_wide, add_carry, sub_borrowThree edition-7 pure builtins over int pairs (unsigned words, nonzero carry), lowered without branch or libcall on LLVM (x86-64, aarch64, wasm32) and Cranelift; std.x25519 moved onto std.field25519_51, five 51-bit limbs. Checked: 6 M random cases against u128 on both backends, 200,000 rounds against OpenSSL, dudect 10^6 on LLVM at max abs t 1.90 (x86-64 and M4), ctgrind 0 reports with its planted control seen, 20 of 20 lowering and 36 of 39 field mutants. Measured: X25519 23x faster on arm64 (0.575 to 0.025 ms), 18x on a loaded x86-64. Found: LLVM turns a 32-bit-halves multiply back into __multi3 on wasm32; Cranelift’s X25519 fails dudect on the M4 unless DIT is set; review found the field’s stated input bound false (fixed)
#397std.p256: faster P-256#380 of #378. Profiled first (field operations 93-98% of every P-256 operation; a bigmod.mul 115 ns, half of it checked arithmetic). A ten-limb 28-bit field with straight-line kernels written by a generator that proves their wrapping_* cannot wrap, the prime’s form in the reduction, lazy reduction with every formula’s bounds checked by a script; k·G as 65 table entries (a static) read under masks; wNAF verification. M4: public_key 768 → 35 µs, sign 820 → 59, verify 814 → 106, shared 755 → 138. dudect at 10^6 on x86-64, the branch audit, OpenSSL and RFC 6979 differentials at 10,000, mutants (§8). Found: a function a static calls is emitted into every --std program; a lazy element has many zeros. Not done: P-384, carry-free additions
#392Multi-block AES-GCM and aggregated GHASH#382 under #378 (TLS performance). A profile first: the AES instructions were 17% of a 16 KiB record, the rest a byte-at-a-time XOR around a call a block and GHASH reduced a block at a time, and 88% of a 64-byte record on Darwin a region’s malloc. aes_ctr32 (eight blocks interleaved, the XOR inside), GHASH in the reflected order with twisted powers of H and one reduction per eight blocks, the tag in one call with no allocation; ghash_update retired but kept, so earlier edition-7 programs still read. Measured 5.9 to 11.2 times at 16 KiB; the record layer is what dominates a TLS record now.
#444TLS performance as built#378, after #392 to #398. The profile of #391 measured again on the same i7-1260P and M4 VM beside OpenSSL: a full server handshake 2.86 times OpenSSL’s cycles (was 15.1; arm64 2.12 times its CPU), the client’s 2.77 (was 11.4), a resumed one 2.34, a kept request 1.11 to 1.15; T1 to T4 met, T5 not. §4.3 had projected 4.9 times with two levers that were not built: the operations became 6.8 to 17 times cheaper where 1.7 to 4 times on their field multiplications was assumed. The allocator is now the largest cost of every handshake (the same 0.9M cycles, now 32% of a full one and 52% of a resumed one, 112 brk calls); glibc told not to trim made them 34% and 55% cheaper (1.87 and 1.05 times OpenSSL); a kept request no longer reaches it, and spends 24 to 27% on #395’s work-area copies. Next levers ranked by measured gain (A1 in #375, P-256 on mul_wide, the per-identity table, a SHA-256 builtin, HMAC pads, the work areas, in-place AEAD: #445 to #448 new, #380 and #381 reopened). False claims corrected in place; the guard’s linux-aarch64 entry re-recorded, 33 of 33 rows on both entries; tls_perf.py openssl, scripts/tls_handshake_cost.py and tconn ... resume added
#450A verified client’s leaf outside the work areaThe memory cost #398 took on purpose, measured with #395’s harness on arm64 and x86-64 beside OpenSSL’s server verifying the same certificate: a tls_echo connection whose client presented a verified P-256 certificate was 126.0 / 127.7 KiB idle at 10,000 connections against 17.0 without (OpenSSL 31.5 / 61.0, 19.8 / 33.9 with its buffers released), because the leaf in b_leaf kept the whole work area; --areas 64 held 64 of 200 such clients, and --areas ended every one record-timeout (a bug, found measuring). The identity fields alone cannot keep peer_certificate and peer_san’s ranges, so the leaf’s DER goes in a 2,048-byte room a server slot, copied when the Finished verifies, emptied on failure and drop and counted by tls.residue; a larger leaf keeps the area. After: 19.0 KiB on both, 200 of 200 on 64 areas; the API and the recordings unchanged; 16 new server mutants, 223 of 227 killed, 4 equivalent
#457Region chunks kept per threadA1 of #378 and #375, one mechanism. Every region was a 64 KiB malloc and free, and glibc trimmed and regrew its heap for nearly each: 112 brk calls a full TLS server handshake, 136 a resumed one. Each thread now keeps the chunks its regions give back, at most 16, last in first out (the region stack), and the bytes a region used are cleared when it ends; a pthread key’s destructor frees a thread’s chunks; both backends, a global on WASI. Measured against its base, alternating: 9 brk calls in a whole run; the server’s full handshake 2.821M to 1.844M cycles on x86-64 (1.86 times s_server; arm64 1.50 times its CPU), a resumed one 2.001M to 0.884M (1.04 times OpenSSL’s resumed: T5 met for the server, not for the client at 1.21); clearing +0.1 to +0.3% of the instructions; macOS, 16 threads of 300,000 regions, 8.76 s to 0.04 s; RSS at 10,000 connections unchanged a connection. 14 of 14 mutants killed
#463Work areas that rest, and a record layer that seals in placeL1 and R1 of #378 (#447, #448). The 24% of a kept request #395’s lease and release cost was the core’s 296 words copied one at a time through the engine’s boxes: block copies, and an established connection that rests in its area until another needs one (at most 32, tls.set_resting; a handshake takes a resting area first). seal_in_place/open_in_place for AES-GCM (both paths) and ChaCha20-Poly1305; the record sealed where it is sent from, opened straight into the received data, no region. A kept https_hello request 40.0k to 31.4k cycles on x86-64 (1.20 to 0.94 times OpenSSL’s oserv); a record through both engines 5.7 (64 B) to 1.5 (16 KiB) times cheaper; idle memory unchanged, at most 2.1 MiB more per engine, the high-water mark 16 KiB a connection lower. 27 new TLS mutants, 9 GCM, 8 ChaCha20
#465A table for a key that verifies many signaturesB2 and B3 of #378, #380. A P-256 key that verifies signature after signature (a TLS server’s own, checking each it makes; a client’s trust anchor) keeps a 10,305-byte table built once: its odd multiples in four runs of 65 wNAF digits, so a verification has 64 doublings, not 256. The table goes where the point went, so no std signature changed (a first version’s _tabled twins broke the stability plateau). One per identity in the server’s configuration, one per P-256 root (at most 16) after the client’s store; never per slot. gram: a verification 688k to 326k cycles (1.29 times OpenSSL), the server’s full handshake 1.85 to 1.46 times s_server (−24% instructions), the client’s 1.62 to 1.41 times tconn; arm64 1.23 and 1.31; nothing a connection, +44 to 48 KiB a process. Every vector through both paths; 49 of 53 mutants killed, 4 argued. P-384 measured (9 to 16 times OpenSSL, 7 of 31 real-chain signatures) and decided: worth it, on #381’s field
#466SHA-256 on the CPU’s instructions, and HMAC with a key’s pads hashed once#445 and #446 under #378 (levers D3 and D4). hw_sha256, sha256_blocks and sha256_block_words (edition 9, pure; SHA-NI and ARMv8 SHA-2 on LLVM, a trap on Cranelift and WASI), std.crypto on them with its partial block as words, and prepared HMAC keys in std.hmac/std.hkdf and the TLS key schedule. SHA-256 at 0.78 to 1.00 of OpenSSL’s cycles on x86-64 (was 4.6 to 9.8); a full server handshake 11.5% cheaper (1.65 times s_server), a resumed one 27.6% (0.76 times OpenSSL’s), arm64 alike: 90% and 88% of the projected cycles. Found: Valgrind hides SHA-NI, so the guard’s x86-64 entry counts the software path (and Valgrind is a CPU without SHA-NI for the forced-off tests); hmac.final writing its inner digest into the caller’s out made the time depend on out’s contents; the pooled dudect harness measured where a record lay. KDF differential, CAVP with SHA-NI hidden, 34/34 std mutants and 33/33 IR mutants, dudect at 10^6 all under 4.5
#464P-256, bigmod and Poly1305 on mul_wide#381’s remaining scope. P-256: four 64-bit limbs and five 56-bit limbs measured first; five 56-bit limbs inside the generated kernels chosen (R, the representation, #380’s files and the lazy bounds unchanged), 25 mul_wide a multiplication. std.bigmod: 64-bit limbs, CIOS with two carry chains. Poly1305: 44-bit limbs, a block loop. Measured (x86-64 cycles): P-256 ECDH 1.73x (3.21 times OpenSSL), sign 1.45x, verify 1.64x; RSA-2048 verify 6.8x; P-384 3.9 to 5.1x; Poly1305 2.3x; a full server handshake −19.9% (1.48 times s_server), a client’s −20.3%. Checked: every vector, differentials at 0, dudect 10^6 (P-256, P-384, ChaCha20-Poly1305) under 2.5, the branch audit, mutants all killed or argued; both guard entries re-recorded. Found: #397’s estimate that 4x64 stays inside the generator was wrong; a stale ecdh_mutants mutant since #397
#503tcp_listen_on#498: tcp_listen binds every interface, so no program could say it listens on loopback and cancho authority printed net_in("8080") for a server meant for a local proxy. One new edition-9 builtin, tcp_listen_on(net, host, port, backlog, flags), whose host is a string literal holding an IPv4 address (names, IPv6 and run-time hosts refused, no DNS); the inbound Net bound now names the host and the port, so a loopback listener’s row is net_in("127.0.0.1:PORT") and a Net can forbid the all-interfaces form. tcp_listen is unchanged (same behaviour, same row) except that on a Net whose bound names a host it is now the compile error listen-host where it used to trap on every run. Tested over real sockets on both backends: a loopback listener is reachable on 127.0.0.1 and refused on the machine’s LAN address. 15 mutants killed. macOS aarch64 only; IPv6, DNS and udp_bind with a host are not done.
#538Stopping what a detached worker started: child_kill_group#533 (found by cancho-forge’s S7 spike), #536. child_kill_group(&Child, signal) (edition 9): child_kill’s signals to the process group the child leads, kill(-pid), in both backends and as builtin 138 of the compiler in cancho. Row child_signal, so the report shows a signal on children and a supervisor needs no /bin/kill and no exec("/bin") (tests/accept/process_group_kill.cho reports exec("/bin/sleep")). A plain child is not signalled and the parent cannot be: the group -pid is not anyone else’s, so the answer is ESRCH (measured), which is why no type split was needed. std.process.capture sends the group KILL before the pid’s, so the S7 scenario (a worker’s sleep 77, a 300 ms deadline) leaves no live grandchild; the plain-worker control leaves it S. Four mutants killed (capture, Cranelift, Rust LLVM, the runtime of the compiler in cancho). Not closed: a supervisor that crashes has no Child (§9). processes.md’s status line said slices 3 and 4 were not built; it is rewritten with a table of what is built where, checked by grep, and wasm.md’s builtin counts, stale since #524, are corrected

The pattern, if there is one

Twenty-one of the first twenty-nine slices found a bug, falsified a claim the project had already written down, or both — and three of those were soundness bugs reachable from ordinary code. Re-counted over the twenty-one slices since, on the same criterion and by reading the rows above: seventeen did, so thirty-eight of fifty. The four that did not are utf8.md, AGENTS.md, the sort port and the backend audit — the last of which checked four claims and found all four true, which is the outcome this paragraph should be able to record without the count looking like a failure. That is not an accident of luck: each slice is built by writing the thing the previous document said was possible, and the documents keep being wrong in the same direction — optimistic about what generality the type system already had.

The convention that follows is worth stating, because it is why the documents are trustworthy at all: a falsified claim is corrected in place, in the document that made it, rather than quietly edited. sharing.md corrects linearity-and-effects.md §9; collections.md corrects both standard-library.md §4 and mode-polymorphism.md §1, the second of which was itself a correction; and #36 corrects reading-references.md §2.0 and sharing.md §2.1, where a refusal turned out to be broader than the hazard it was written for; and #41 corrects a refusal that named () as a foreign result, in a language whose grammar refuses () on purpose — two correct rules with a loop between them, which only a program walking into it would find; and #42 corrects README.md’s performance expectation and defined-behaviour.md §2.1, which had put a number on the overflow trap that nobody had ever measured — the one claim here that an outside reader caught before the project did. And #55 corrects canonical-ast.md §8 — the section whose whole job is to say what is not a contract — which had described a struct literal’s field order as a gap it does not have and given the wrong reason for the one gap it did have. A wrong entry there is worse than most, because it is the entry everyone else reads to find out what they may rely on.


What is next

NextWhy it is next
Does &! mean unique?Answered, no — aliasing.md. This row priced the decision as “it refuses programs that compile today”, and across all 82 programs the repository builds it refuses one fixture, written to document the behaviour being changed. The price is elsewhere, in two places that each settle it alone. There are three ways to alias a &!; two close syntactically and the third, both(head(s), s), does not — nothing in head’s signature says the result borrows its argument, and a shared region is a shared arena, not a shared object, so carrying that fact across a call is lifetimes and a borrow checker, which the README’s design commitments rule out by name. And even proved, the fact has no consumer: Cranelift has no noalias, only three fixed WebAssembly alias regions. The two conditions that would reopen it — an LLVM backend, and parallel lanes to make it a correctness argument rather than a speed one — are §6 there
A vectoriser, or a backend with oneBuilt — llvm-backend.md. layout.md §3 and overflow-cost.md §3.2 turn out to be the same wall wearing two hats: a trapping add is not reassociable, so the loop does not vectorise, so neither contiguity nor reassociation can be spent. bulk-io.md §4 puts a third hat on it — a byte-at-a-time slice copy costs 6 ms per 8 MB here and nothing measurable in C, which is now the largest single thing between base64/ and coreutils. It is the one thing that would move several of these numbers at once. And check-cost.md says where the traps are in the way: six of eight loop-body checks make their operation non-reassociable, so a backend with a vectoriser would collect the gain only outside those, and the list of places is §2’s table there. And backend-limits.md checks the case against the backend rather than from memory: there is no aliasing fact to emit, no purity fact to emit, and no pass that makes SIMD out of scalar code — each a property of Cranelift’s design rather than a version it has not reached. The three arguments for LLVM are one argument: this language computes facts its backend has no vocabulary for. How, not whether, is now settled, and its first five slices are built — hello.cho itself now builds through it. llvm-backend.md: textual IR shelled out to clang, no new build dependency, and a spike that found the obvious way to emit the overflow trap raises the wrong signal on one target (confirmed on both this project’s targets by this backend’s own tests). §5’s doorway, checked arithmetic, control flow (needing no phi, because every local here is already memory), slices/strings, and structs/enums (match reusing the same memory-backed reasoning a third time) are all cancho-codegen-llvm behind --backend llvm, checked against the Cranelift path’s own output — examples/hello.cho, ci.yml’s own smoke test, was the fourth slice’s own target, byte for byte the same through both backends. Whether clang -O2 actually vectorises a loop-heavy checked-arithmetic kernel through this backend is now measured, and the answer is no (#102, llvm-backend.md §7): objdump on the -O2 object for both sum_checked.cho and mandelbrot.cho finds zero %xmm/%ymm/%zmm instructions — this document’s and check-cost.md’s own “a trap is not reassociable” finding holds on a real backend, not only Cranelift’s absence of one. What -O2 bought instead was scalar: run_clang had never passed clang an optimisation level at all (a real bug, fixed), so mem2reg never ran and every leaf stayed a real stack slot — once fixed, register allocation and instruction selection alone are enough to put cancho/llvm within noise of C on mandelbrot.cho (0.96×–1.00×, against Cranelift’s 1.6×–1.8×) and 8%–54% ahead of Cranelift on the three kernels that build on both backends today. #103 closed wrapping_add/sub/mul and found that removing an observable trap lets LLVM delete a whole no-op loop outright, the same fact from the other side. #104 is where the vectorised half of this row’s own claim is finally measured, not just theorised: region/alloc_slice closed, sieve/scan build on both backends, and their wrapping halves actually vectorise (409 SIMD instructions in scan_wrapping.cho, 22 in sieve_wrapping.cho) where their checked twins compile to zero, objdump-confirmed. So the claim holds in both directions now, not only the negative one: a checked loop does not vectorise, and the same loop with the trap removed does. #105 closed heap boxing and, underneath it, multi-leaf function returns (reduce_checked.cho — docs/gpu.md’s own kernel — confirms the same vectorisation split a fourth time). #106 closed getchar/s[a..b] and found revcomp.cho’s own boundary is Place::Field/Place::Deref, not getchar as assumed. #107 through #119 closed everything that was left: field/deref access and revcomp.cho itself (#108); arg_count/arg and fannkuch.cho (#109); single-value alloc/box/unbox and binarytrees.cho (#110); Type::Float, the last gap with a benches/ target (#111); matching through a reference, closing every gap this document had a target for (#112); then Net itself, scoped directly rather than by a target — listen/accept (#113), bind (#114), connect (#116), fully built on both backends, checked against a real accepted connection and an all-LLVM listener-and-client pair talking over real loopback; Ffi/extern fn (#117), which found the “only gap left” claim false three PRs running before it — Fs had been unbuilt and unnamed the whole time; Fs itself (#118), closed alongside a byte-comparison bug hardcoded since this backend’s first slice; and Expr::Static/Expr::BitNot (#119), the last two Expr variants, closed alongside the discovery that the backend’s own expr match was not actually exhaustive despite four separate “only gap left” claims saying so. With #119, body/expr.rs’s match has no unhandled Expr variant and no wildcard arm — rustc itself now refuses to build the crate if a future variant goes unmatched, and every fixture in tests/accept/ and examples/ builds on --backend llvm. The one thing still refused, a user’s own extern fn socket colliding with this backend’s internal declaration, is #92’s already-accepted exposure repeating itself, not a missing feature
exp, log, powBuilt — float-math.md §7. Three programs asked by name (examples/growth.cho, decay.cho, entropy.cho), clearing the two-asker bar this row itself said was unmet. Library code in std.math, checked against Rust’s own f64::exp/f64::ln/f64::powf within 1e-9 relative error — not correctly rounded, the same honest limit sqrt itself could not clear as a hand-rolled function. sin stays open: nothing has asked, and it needs its own range reduction
File handlesBuilt — file-handles.md. open_read, file_read, file_close, a res File, and a three-constructor Read so the end of a file and a failure stop being two meanings of -1. Building it settled the three things §6 left open and found one the design had not seen: Result[File, int] cannot be a builtin’s signature, because Result is std and std is opt-in rather than a prelude. Failed(int) now carries the real errno, which is the one argument for handles that survived §1’s other two evaporating. The price nothing had counted: three prelude names cost 31 fixtures and the extern fn read in examples/serve/, so the two verbs are file_read and file_close rather than read and close
fasta and reverse-complementBuilt — benchmarks-game.md §7. The Game’s spec was reachable this session, and the measurement corrects the prediction rather than confirming it: fasta is not nearer base64’s 1.59×, it is faster than C, at 0.54×
cancho code in lex-vcsAnswered, yes, and built — vcs.md §7. This row’s own plateau question — whether 27 commits (#93–#119) touching zero builtin/effect/prelude files was enough, given hash-stability.md’s own disagreeing pair of numbers (the encoder moved 20 times unobserved; the language moved enough that 71% of this repository’s .cho history no longer type-checks) — is answered yes, conditional on tagging every op’s edition from day one so the thing a plateau insures against costs a reinterpretation rather than a rewrite. crates/cancho-vcs exists (#123–#125): a native crate, no dependency on lex-lang, sharing lex-vcs’s scheme and no code — the same “shares the idea and no code, no cross-repo Cargo.toml edge” relationship lex-os/cancho already committed to, with one detail changed on contact (OpId is BLAKE3, not lex-vcs’s SHA-256, for the same reason cancho-id already picked it). Built: the Operation/OpId/SigId/StageId/EffectSet foundation checked against real cancho-id hashes, the apply→gate pipeline (gate.rs, checking §3’s claim directly rather than only arguing it), a loose-file op log, and a hash-chained attestation log sealed with Ed25519 (ed25519-dalek, the same crate lex-os-audit uses). What §3 called “largely unmodified” and is still unbuilt — whole-function merge, multi-file merge sessions, typed issues, predicate branches, the op log’s own history index — has no asker yet; vcs.md §8 tracks it
A line readerAnswered, no — line-reading.md. This row said examples/cut/ and examples/tally.cho “both read getchar into a fixed buffer”, and tally.cho has no buffer: it streams. Five programs call getchar, one of them reads a line at a time, and the bar here is two. What the row was right about is that something was wrong: cut was silently printing the wrong field on a long line — a truncated line loses its delimiters, so it switched to the no-delimiter rule and answered 60 KB of a with exit 0. Fixed by growing on the heap, at 1.18×, and the one function that was earned is buffer.clear, at 13.0× against the alternative
Effect polymorphismAnswered, no — effect-polymorphism.md. This row and mode-polymorphism.md §8 were the entire design, and counting by reading says the feature has nothing to quantify over: 537 functions here write a row and not one could be polymorphic in it. A row is fixed at the declaration (Signature.effects, read straight off it by every caller), and the two things a function is generic over — types and regions — cannot select a callee. The nearest asker, io.write_all against io.error_all, differs by the builtin as well as the row, so collapsing it needs a function value: row polymorphism is the shadow of higher-order code, and Rule::NoFunctionValues refuses that with a fixture

Superseded by the row above, kept as it was written. Everything from here to “not ours to do” argues for building an LLVM backend, written before it existed. It exists now — the row above this one, and #127 (docs/llvm-backend.md) made it the default. The 1.17–2.58× this essay names below is the number that argument was made against, not a current figure: docs/benchmarks-game.md §8 re-ran the same five kernels on --backend llvm and found a narrower, less uniform range, one kernel (fasta) still faster than C. The reasoning stays, for the record of why this was prioritized; the number it ends on does not.

An LLVM backend is not next, and the case for it is now argued from two directions at once — which is worth keeping written down, because the two arrive at opposite conclusions about its priority and agree about its necessity.

purity.md §4.2 shrank it: the effect row’s advantage needs a compilation boundary, cancho compiles whole programs, and LLVM infers the same fact itself when it can see every body. So the row buys less than it looked like it would.

gpu.md §2.3 grew it, from somewhere else entirely. Asked what a GPU would cost the design, the measurement found that deleting the overflow trap does not reach C: cancho without traps is still scalar, 2.27× off vectorised clang and 1.55× off scalar clang, so the trap is 1.83× of its own cost and everything remaining is Cranelift. Removing the trap is necessary and not sufficient. That makes LLVM the prerequisite for two things rather than one — the CPU gap, and any GPU future at all, whichever language the kernels would be written in.

It remains the answer to the 1.17–2.58×, and that is a performance problem in a language that is not yet usable. But it is no longer only a performance problem: it is the gate in front of the one direction this project has been asked about and cannot currently go.

jo instead of seto/test/jne (overflow-cost.md §3.4) was listed here as ordinary work blocked by nothing, and emitted-checks.md §2 says otherwise: that sequence is Cranelift’s x64 lowering of trapnz, so it is an upstream change rather than one this repository can make. It stays worth doing and is not ours to do.

Flag parsing has moved off this list and into flags.md — and it was not the small thing this line called it.

Standard error is done — standard-error.md. This list called it “a gap reach.md §6 upgrades from an omission to a named gap”, and reach.md §6 never mentioned it; the citation was copied from file-handles.md, which had invented it. Closed as a third label on Io, after measuring that every failing program here produced zero bytes on every stream and that the workaround both corrupts a pipeline and loses the message on a trap.

Environment variables moved off that list and onto reach.md’s: getenv returns a char *, so they are not reachable through FFI at all and want a capability with builtins, the way Args has.


Deliberately excluded

Borrow checker, traits, comptime, an own optimiser, incremental compilation, LSP, async.

Each is “yes, later” rather than “no”. Saying yes early is what turns three months into three years, and the non-goals say which of them are “no” outright.


Beyond

Inside cancho, the next phase is next-phase.md: not a feature list — “What is next” above has nothing left unstruck — but a proposal to replace hunting for stale docs and duplicated code with a mechanical check for the latter, argued from MANIFESTO.md and from three of this session’s own mistakes. Its §3 found a real instance (ten examples duplicating std.io’s print_nat/write_all, most of them still on the pre-bulk-I/O putchar loop docs/bulk-io.md replaced) and migrated seven of them; §3.1 records why the other three stayed (modular/text.cho’s own worked example for docs/modules.md, hello.cho’s load-bearing role in three unrelated tests, buffer/main.cho’s copy was dead code and just deleted) and a root-namespace import collision the migration found along the way. §4’s own standing check — the actual proposal — is built now (duplication::no_function_body_is_duplicated_across_files), and being alpha-equivalence-aware rather than text-based, it immediately found three more real duplicates the original hunt’s regex could not have (§4.1): two under a different name on each side (larger/max, append/put), and a package (net.connect) whose own extraction never reached one of its three source files.

Beyond cancho, the first real target is lex-os — production systems work, no rewrite risk.

Self-hosting the lex-lang toolchain was a spike before a plan, and the spike has run: self-hosting.md. Not against the “~136k-op corpus” this line used to name — no such corpus is checked into any repository this project can reach — against the real .lex corpus that does exist. The canonicalization algorithm ports byte-for-byte (168/168 real Stages), and nothing structural blocks it (recursion, heap-allocated recursive data, and a named way around the Cranelift dependency all check out). The decision is not yet: 29,368 real lines of compiler and no asker for a cancho-hosted compiler today. Revisit self-hosting.md when one exists, rather than re-running this spike.