Skip to content
Wantzel Studio
Wantzel documentation

Library

Every module in lib/, and a compact reference for the ones most programs touch directly.

The library is part of the compiler: import io; reads the module io from the compiler you run, never from disk, so a program and a compiler file always build the same executable. wantzel --lib lists the modules and wantzel --lib io prints the copy inside your compiler, source text included. Read it and step into it; to experiment with a module, copy it and include the copy as a file of your own.

Import and include

import io;                  // a module: a bare name, no quotes, no .wz, no path
import json;
include "model.wz";         // a file of your own, next to this file
include "store/orders.wz";  // a file of your own, in a directory next to this file

import reads a module from the library inside the compiler; include reads a file, relative to the directory of the file that names it. A module never comes from disk and a file never from the library, so a directory called lib/ or a file of your own that happens to share a module's name changes nothing about an import. Both are textual and read once; a module imported by another module is there for the rest of the program too. The complete rules and every error message are in language.md §1b; the mistakes people make with them, and an example of files including each other, in writing-wantzel.md.

All modules

modulefor
acmegetting a certificate from a certificate authority (RFC 8555)
aeadChaCha20-Poly1305 as one operation (RFC 8439 §2.8)
aesAES-128 and AES-256 (FIPS 197), with GCM (NIST SP 800-38D) and CBC with PKCS#7 padding
autocerta certificate a server gets and renews by itself, from inside its own event loop (ACME, HTTP-01); http.https uses it
base64base64 encoding and decoding (RFC 4648)
certstorekeeping keys and certificates on disk, beside the binary
chacha20the ChaCha20 stream cipher (RFC 8439)
chaindoes a certificate chain up to something trusted?
csvreading and writing CSV (RFC 4180), with , ; or a tab between the fields
csra certificate signing request, and a self-signed certificate (RFC 2986, 5280)
derreading DER, and the parts of X.509 a TLS client needs (X.690, RFC 5280)
dnshostnames to IPv4 and IPv6 addresses: a DNS stub resolver over UDP, and TCP when truncated
ed25519Ed25519 signatures (RFC 8032) and XEdDSA, signing with an X25519 key (ed.xsign, ed.xverify), and libsignal's form (ed.sigverify)
fsdirectories, file metadata and reading, on raw syscalls
hashopen-addressing hash tables on caller-supplied arrays
hkdfHKDF (RFC 5869) and the TLS 1.3 key schedule (RFC 8446 §7.1)
hmacHMAC-SHA256 (RFC 2104 / RFC 4231)
httpa non-blocking HTTP/1.1 server on epoll, plain and HTTPS, with automatic certificates
iooutput, numbers and little-endian byte packing
jsonJSON scanning primitives
jsonschemavalidating JSON against a JSON Schema (2020-12), the useful subset
jwsJSON Web Signature with ES256, and the JWK thumbprint (RFC 7515, 7638)
kvhelpers for a small JSON object kept as compact text
logone JSON line per event on stderr, for journald or a file
lzLZ compression, packing and unpacking: the format the compiler stores this library in
mathreal arithmetic beyond the operators: exp, log, pow, trigonometry, text
mcpModel Context Protocol over JSON-RPC 2.0
mcphttpMCP over HTTP: the endpoint routine for app.request
moneyamounts as whole cents: reading 1.234,56 or 1,234.56, writing them back
netsockets and epoll, straight on top of the system calls; IPv4 and IPv6 (IPv6 on Linux only for now)
oauthan OAuth 2.1 authorization server for MCP clients
openapian OpenAPI 3.1 document and a Swagger UI page, generated from a tools block
p256the NIST P-256 curve and ECDSA verification (FIPS 186-4, SEC 2)
p384ECDSA over NIST P-384 (secp384r1), verification only
pga PostgreSQL client, read-only: connect (SCRAM-SHA-256), run a query, read the rows
poly1305the Poly1305 one-time authenticator (RFC 8439)
procprocesses: forking, reaping, and knowing how it ended
protobufreader for the protobuf wire format
randrandom bytes and numbers from the kernel
routera few helpers on top of http.wz for routing by path
rsaRSA-2048 signature verification, for certificate chains that are not ECDSA
searcha small search index: words, prefixes, no difference between é and e
sha1SHA-1 (RFC 3174) -- not a security primitive; kept only for protocols that name it (websocket.wz's handshake)
sha256SHA-256 (FIPS 180-4)
sha384SHA-384 and SHA-512 (FIPS 180-4): sha384.init512, sha384.final512, sha384.hash512
storedurable tables of fixed-size records: an append-only log
timecalendar time: dates, ISO weeks, adding months, dd-mm-yyyy, Central European Time, running at a fixed time of day
tlsTLS 1.3: a blocking client, and a server that runs inside an event loop
toolsMCP and REST glue for a declared tools block
toolsmcpthe MCP half of the glue for a declared tools block
uuidUUID version 4 (RFC 9562)
websocketa WebSocket server (RFC 6455) that takes over a connection from http.wz, on the same port
x25519X25519 key agreement on Curve25519 (RFC 7748)
x509reading an X.509 certificate (RFC 5280), on top of der.wz

tools.wz/toolsmcp.wz are the transport for a declared tools ... end; block — see language.md §7b. Together tls.wz, x25519.wz, chacha20.wz, poly1305.wz, aead.wz, hkdf.wz, hmac.wz, sha256.wz, sha384.wz, p256.wz, p384.wz, rsa.wz, der.wz, x509.wz, chain.wz, csr.wz, certstore.wz, jws.wz, acme.wz are a TLS 1.3 client and server with nothing linked; autocert.wz puts them together into a server that keeps its own certificate.

The sections below cover the modules most programs touch directly. For the rest, the routine comments in the module text (wantzel --lib <module>) are the reference, and writing-wantzel.md already records several sharper pitfalls (store.*, kv.*, oauth.*, sha256.hex).

io — output, numbers, byte access

import io;

The lowest layer: no buffering, no allocation, every call a syscall or a few instructions over a buffer you already own.

routinemeaning
io.write(fd, a, n): intraw write(2); returns bytes written, or negative on error
io.read(fd, a, n): intraw read(2); same contract
io.out(fd, a, n)write and forget (a procedure, not a function)
io.puts(fd, s: str)write a string literal, not a buffer
io.putn(fd, v: int)write the decimal form of v
io.fatal(s: str)write s and a newline to STDERR, then halt(1) — never returns
io.push(b, at, s: str): intappend literal s to b at at; returns the new position
io.pushnum(b, at, v: int): intappend the decimal form of v
io.digits(v: int): intrender v into io.nbuf; returns the length
io.put32(b, at, v)write v as 4 bytes, little-endian
io.get16/get32/get64(b, at): intread 2/4/8 little-endian bytes
io.now: intmonotonic nanoseconds, for elapsed time
io.realtime: intwall-clock nanoseconds since the epoch

STDIN, STDOUT, STDERR are the fd constants. io.push/io.pushnum **truncate rather than fail**: a negative at passes through unchanged (so a -1 from a refusing routine elsewhere travels a chain undamaged), and a full buffer gets as much as fits, returning len(b). Compare the returned position to the one passed in to detect either case.

io.now/io.realtime call io.fatal if the underlying clock read fails, rather than risk returning a stale or zero time that looks real.

Limits: no formatted output beyond decimal integers, no line-oriented reading.

import io;

var
  buf: array[0..63] of char;
  n: int;

begin
  n := io.push(buf, 0, "count = ");
  n := io.pushnum(buf, n, 42);
  io.out(STDOUT, addr(buf[0]), n);
  io.puts(STDOUT, "\n");
end.

import fs; (pulls in io)

Read-only: nothing here creates, writes, renames or deletes. fs.open is a convenience for the read-only case; writing goes through sys3(SYS.open, ...) directly.

routinemeaning
fs.stat(a: int): boolfill fs.size, fs.mode, fs.mtime for the NUL-terminated path at a; false if unreachable
fs.isdir: bool / fs.isfile: boolread fs.mode from the last fs.stat
fs.open(a: int): intopen the path read-only; returns the fd, or negative on failure
fs.opendir(a: int): intopen for reading directory entries; returns the fd, or negative (with fs.err/fs.errno) on failure
fs.close(fd)close a descriptor, and free its directory slot if it had one
fs.next(fd): boolstep to the next entry (skips ./..); fills fs.name, fs.namelen, fs.type; false when exhausted or on error (see fs.err)
fs.forget(fd)free fd's directory slot WITHOUT closing fd — for a caller that owns the fd's lifecycle itself (see below)
fs.find(h, from, upto, n, nlen): intfirst occurrence of n[0..nlen) in h[from..upto); -1 if absent

fs.stat takes a raw address, not a str — build it with io.push plus a manual chr(0) terminator. fs.type is one of the kernel's DT_* constants (DT_DIR, DT_REG, DT_LNK). fs.find works on any array of char, not just file content, and uses the SIMD scan builtin for its leading byte.

A recursive walk works: each fs.opendir gets its own read buffer and position, so opening a child directory while a parent's fs.next loop is still in progress does not disturb the parent — recursing (using fs.type = DT_DIR) is the caller's job, same as before, but nesting it is now safe. Limits: at most 16 directories open at once, process-wide (FS.MAXOPEN in wantzel --lib fs); a 17th fs.opendir fails with fs.errno = 24 (EMFILE) and fs.err naming it, until an open one is fs.closed.

If you open a directory your own way (not through fs.opendir — your own path resolution or an openat wrapper, say) and still read it with fs.next, that fd gets a slot the same as fs.opendir would give it. Close that fd your own way too, and **call fs.forget(fd) first.** Operating systems reuse fd numbers: without fs.forget, the next thing opened can get the same number, and fs.next on it would find the old slot still claimed and resume reading from the old directory's position instead of starting fresh. A caller that always goes through fs.opendir/fs.close never needs fs.forget — that pair already does this for you.

import fs;

var
  path: array[0..15] of char;
  n, fd, count: int;

begin
  n := io.push(path, 0, "/tmp");
  path[n] := chr(0);
  fd := fs.opendir(addr(path[0]));
  if fd < 0 then io.fatal("cannot open directory");
  count := 0;
  while fs.next(fd) do count := count + 1;
  fs.close(fd);
  io.putn(STDOUT, count);
  io.puts(STDOUT, "\n");
end.

time — calendar time without a time zone

import time; (pulls in io)

A moment is an int: seconds since 1970-01-01T00:00:00Z. Proleptic Gregorian calendar, UTC only, no DST, no leap seconds. Day arithmetic is exact for years 1..9999 and beyond.

routinemeaning
time.days(y, m, d): intdays since 1970-01-01 for the civil date
time.join(y, m, d, hh, mm, ss): intthe full moment for a civil date and time
time.split(t: int)fills time.year, .month (1..12), .day, .hour, .minute, .second, .weekday (0=Mon..6=Sun), .yday (0=Jan 1) — a procedure, read the fields immediately
time.leap(y: int): boolis y a leap year?
time.daysin(y, m: int): intdays in month m of year y; 0 outside 1..12
time.iso(dst, at, t: int): intwrite t as YYYY-MM-DDTHH:MM:SSZ; returns the new position
time.parseiso(s: array of char): intparse ISO 8601; returns the moment, sets time.ok
time.pad(dst, at, v, w: int): intwrite v as w zero-padded digits
time.num(s, at, n: int): intread n decimal digits at s[at]; -1 if not digits
time.nowsec: intcurrent moment (io.realtime div 1000000000)
time.weekdayof(t): intthe weekday of t: 0 = Monday .. 6 = Sunday
time.isoweek(t): intthe ISO 8601 week (1..53); time.isoyear is the year that week belongs to
time.adddays(t, n): intn days later (earlier when negative)
time.addmonths(t, n): intn months later; a day past the end of the new month becomes its last day (31 January + 1 = 28 or 29 February)
time.putdmy(dst, at, t, sep): intthe date as dd-mm-yyyy with sep (-, /, .); returns the new position
time.parsedmy(s): intd-m-yyyy (also with / or .) as midnight UTC; sets time.ok
time.cet(t): intthe offset of Central European Time at t: 3600, or 7200 in summer (the EU rule: last Sunday of March to last Sunday of October, 01:00 UTC)
time.nextat(now, hh, mm, cet: bool): intthe first moment after now whose clock reads hh:mm, in UTC or (cet) Amsterdam time
time.sleepuntil(t)wait until moment t; returns at once for a moment that has passed

time.parseiso accepts a bare date, no zone (read as UTC), a fractional part (dropped), Z, or a +hh:mm offset (subtracted to reach UTC); a space is accepted for the T, as Postgres writes it. It signals failure through time.ok, not the return value — -1 is itself a valid moment, so always check time.ok.

Limits: no time zones beyond UTC and Central European Time (time.cet), no duration/interval type. Running at a fixed time of day is a loop: `t := time.nextat(time.nowsec, 9, 0, true); time.sleepuntil(t); ...do the work... -- 09:00 Amsterdam time, summer and winter. time.nowsec shares io.realtime`'s fatal-on-clock-failure behavior.

import time;

var
  s: array[0..15] of char;
  n: int;
  t: int;

begin
  n := io.push(s, 0, "2026-09-11");
  t := time.parseiso(s[0..n - 1]);
  if time.ok then io.putn(STDOUT, t) else io.puts(STDOUT, "bad");
  io.puts(STDOUT, "\n");
end.

csv — reading and writing CSV

import csv; (pulls in io)

Rows of fields (RFC 4180): a field may be quoted, and inside quotes the separator and a line end are text and a quote is written twice. csv.sep is the separator -- , while you leave it, ; for a Dutch export (whose decimal mark is ,), a tab -- and csv.detect picks it from the first line.

routinemeaning
csv.detect(b): charthe separator of the first line: whichever of ; , tab occurs most outside quotes
csv.next(b, at, dst): intone field from b[at..], unquoted into dst[0..csv.len - 1]; returns the position after it. csv.eol: it ended its row; csv.cut: it did not fit dst; csv.bad: a quote was never closed
csv.put(dst, at, s): intappend a field, quoted only when it must be (the separator, a quote, a line end, a space at either end)
csv.putn(dst, at, v): intappend a whole number
csv.putsep(dst, at): int / csv.puteol(dst, at): intbetween fields / after a row: \r\n, or \n with csv.lfonly
csv.sep := csv.detect(b[0..n - 1]);
at := 0;
while at < n do
begin
  at := csv.next(b[0..n - 1], at, field);      // field[0..csv.len - 1]
  if csv.eol then ...                          // the last field of its row
end;

money — amounts as whole cents

import money; (pulls in io)

Money is never a real. An amount is an int of cents. money.parse reads what people and exports write -- 1.234,56, 1,234.56, 12,5, -7, € 12,30, EUR 1.234,56, 12,30- -- and sets money.ok; the last . or , with one or two digits after it is the decimal mark, and a last group of exactly three digits groups thousands (1.234 is 1234,00). More than two decimals is refused.

routinemeaning
money.parse(s): intcents; money.ok false (and 0) when it is no amount
money.put(dst, at, cents, dec): intcents as text with dec (, or .) as the decimal mark and the other one grouping thousands (not with money.nogroup); always two decimals
money.percent(cents, pct): inta share, rounded half away from zero: money.percent(1999, 21) is 420

search — a small search index

import search; (pulls in io)

For the listings on a website: add each text once under a number of your own; a query finds the numbers whose text has EVERY query word as the start of one of its words (vrij amst finds Vrijstaande woning in Amsterdam), without case and without accents (é is e, ç is c, ñ is n, ß is ss). The index lives in fixed global arrays: 1 MB of words, 131072 words, 16384 texts; a query uses at most 8 words. Hits come in the order the texts were added; nothing is ranked.

routinemeaning
search.clearempty the index
search.add(doc, text): booladd a text under number doc; false (and nothing kept) when the index is full
search.find(query, out): intthe matching numbers into out[0..k - 1]; returns k (an empty query finds nothing)

pg — a PostgreSQL client, read-only

import pg; (pulls in io, net, dns, sha256, hmac, base64, rand)

A running PostgreSQL over TCP, with the simple query protocol: one query string, the rows as text. Signing in: SCRAM-SHA-256 (the default since PostgreSQL 14), a cleartext password, or trust; MD5 is refused. Right after signing in the session is read-only: an INSERT, UPDATE or DELETE is refused by the database itself (SQLSTATE 25006). The connection blocks.

routinemeaning
pg.connect(host, port, user, password, db): boolconnect, sign in, set read-only; false with pg.err[0..pg.errn - 1] (the server's message and its SQLSTATE)
pg.query(sql): boolrun one query; its rows then come with pg.next; pg.ncols columns
pg.next: boolthe next row; false after the last
pg.value(i, dst): intthe text of column i; its length, or -1 for NULL
pg.int(i): intcolumn i as a whole number (0 for NULL)
pg.colname(i, dst): intthe name of column i
pg.closeend the session

A message larger than 1 MB (one very wide row) is refused with a clear error. To read a SQLite file instead of a server, there is no module yet.

math — real arithmetic beyond the operators

import math; (pulls in json, and through it io)

Plain Wantzel over real: range reduction plus a series, no C math library. A few ULP of accuracy over the ranges the routines target, not IEEE-754-exact at every corner. Constants: MATH.PI, MATH.E, MATH.LN2, MATH.HALFPI, MATH.TWOPI.

routinemeaning
math.abs/max/min(...)absolute value, larger, smaller
math.floor(x): real → int / math.ceil(x)round to int; x must fit in an int
math.isnan(x): boolis x NaN?
math.pow2i(k: int): real2^k, exact
math.powi(x: real; n: int): realx^n, integer n, by repeated squaring
math.pow(x, y: real): realx^y; NaN if x < 0 and y non-integer
math.exp(x): realsaturates: +inf-shaped above 709.7, 0.0 below -745.0
math.log/log10/log2(x): realNaN for x < 0, large negative for x = 0
math.sin/cos/tan/asin/acos/atan/atan2(...)trig; asin/acos NaN outside [-1, 1]
math.radians(deg) / math.degrees(rad)conversions
math.fixed(dst, at, x: real, decimals: int): intfixed decimals (clamped 0..15), rounds half away from zero
math.text(dst, at, x: real): intshortest general form, up to 15 significant digits
math.parse(s: array of char): realparse a decimal number; sets math.ok

math.parse signals failure through math.ok, not the return value — 0.0 is both a legitimate result and the failure value. math.ok is also false for a partial match: the whole slice must be one number.

Limits: no hyperbolic functions, no complex numbers, no statistics, no arbitrary precision. Three routines are internal only — math.reduce, math.sinr, math.cosr — call math.sin/ math.cos instead; the three give a silently wrong answer outside the first quadrant.

import math;

var
  buf: array[0..31] of char;
  n: int;

begin
  n := math.fixed(buf, 0, math.pow(2.0, 10.0), 2);
  io.out(STDOUT, addr(buf[0]), n);
  io.puts(STDOUT, "\n");
end.

json — JSON scanning and writing primitives

import json; (pulls in io)

No tree: a value stays in the input buffer, described by (offset, length). The generated schema parsers are built on this; reach for it directly only for open-ended input (a proxy, a log line) where a schema does not fit.

routinemeaning
json.ws(b, at, last): intskip whitespace; first non-blank position
json.skip(b, at, last): intstep over one whole value, however nested
json.string(b, at, last): intscan a string; fills json.sat/json.send
json.number(b, at, last): intscan a number's integer part into json.ival
json.real(b, at, last): intscan a number as real into json.rval, up to 18 significant digits
json.boolean(b, at, last): intscan true/false into json.bval
json.isnull(b, at, last): boolis the value null? (does not consume)
json.literal(b, at, last, s: str): intdoes b at at spell s?
json.strend(b, at, last): intgiven at just past an opening quote, find the closing one
json.eq(b, at, last, s: str): booldoes b[at..last) equal s?
json.escaped(b, from, upto): booldoes b[from..upto) contain a backslash escape?
json.unescape(src, from, upto, dst, at): intdecode a string body into dst; -1 on malformed escape or no room
json.copystr(src, from, upto, dst): intthe same, always from dst[0]
json.putstr(dst, at, s: str): intappend s as a quoted, escaped string — truncates
json.putslice(dst, at, src, from, upto): intquoted, escaped slice — refuses (-1), does not truncate
json.escslice(dst, at, src, from, upto): intescaped, no quotes — truncates
json.putb(dst, at, c: char): intone raw byte — refuses
json.putraw(dst, at, src, from, upto): intverbatim, no escaping — truncates
json.putreal(dst, at, v: real): intup to 15 significant digits; NaN/infinity become null — truncates

All readers take (b, at, last) with last exclusive and return -1 on malformed or truncated input — there is no position to continue from on failure. **Two different write contracts**: putstr/escslice/putraw/putreal truncate silently (compare the returned position to detect it, like io.push); putslice/putb refuse and return -1 — always test their result, because they back the generated <Schema>.write, where a half-written object is a syntax error, not a shorter one. A negative at passed to any of them comes back unchanged, so a -1 can travel a chain of appenders and be tested once at the end.

Limits: no tree, no path lookup, no in-place mutation. Field lookup by key is kv.find/kv.first/kv.next in wantzel --lib kv; a whole typed object is a schema declaration.

import json;

var
  buf: array[0..127] of char;
  n: int;

begin
  n := io.push(buf, 0, "{\"name\":");
  n := json.putstr(buf, n, "probe");
  n := io.push(buf, n, ",\"count\":");
  n := io.pushnum(buf, n, 42);
  n := io.push(buf, n, "}");
  io.out(STDOUT, addr(buf[0]), n);
  io.puts(STDOUT, "\n");
end.

http — a non-blocking HTTP/1.1 server on epoll

import http; (pulls in net, json and autocert, which brings the TLS stack and dns)

One process, one event loop, no threads, no allocation per request. The application defines procedure app.request, called once per complete request, reading the request through the module's globals. A request is parsed in place in http.inbuf; the reply is built in http.outbuf. The same loop terminates TLS itself — see HTTPS below.

routinemeaning
http.serve(port, workers: int)bind and run the event loop; never returns. workers > 1 forks that many, sharing the port via SO_REUSEPORT
http.maxbody(cap: int)accept request bodies up to cap bytes (above INBUF, each read into a region of its own connection's); call before http.serve
http.maxbodymem(total: int)what all large bodies being read at once may hold together (256 MB); past it, 503 with Retry-After
http.maxreply(cap: int)allow reply bodies up to cap bytes (at least OUTBUF); a larger reply becomes 500
http.maxconn(n: int)at most n connections at once (1..MAXCONN); call before http.serve
http.timeouts(idle, header, body, send: int)the deadlines in seconds, 0 keeps the default; call before http.serve
http.addport(port: int)listen on one more port too (listener 1, 2, ... in call order; at most 3, one fewer with http.redirect); call before http.serve
http.bindto(a, b, c, d: int)listen on one IPv4 address instead of every address -- http.bindto(127, 0, 0, 1) for a server only this machine reaches (behind a reverse proxy); holds for every listener; call before http.serve
http.https(host, email, store: array of char; staging: bool)HTTPS with a certificate from Let's Encrypt, obtained and renewed from this loop; the port given to http.serve speaks TLS, and port 80 redirects. Call before http.serve
http.httpsfiles(certfile, keyfile: array of char): boolHTTPS with the chain and key in PEM files; false and http.tlswhy when they do not load. Call again to replace the certificate while serving
http.redirect(port: int)the plain port of HTTPS mode (80 unless set): challenges, redirects, never the application
http.istls: boolthe request being served came over TLS
http.listen(port, workers: int) / http.poll(timeout: int): inthttp.serve in two halves, for a program with its own loop: bind (and fork), then one round per call — waits up to timeout ms (-1: until something happens, at most until the next deadline check)
http.method: intHTTP.GET/POST/PUT/DELETE/PATCH/OPTIONS/HEAD, or 0
http.pathis(s: str): bool / http.pathstarts(prefix: str): boolpath match; the rest is http.inbuf[http.restat..http.restend)
http.header(name: str): boolcase-insensitive lookup; value at http.inbuf[http.hvat..http.hvend)
http.bearer: boolthe Authorization: Bearer token
http.param(key: str): boolquery-string value at http.pval[0..http.pvlen)
http.form(key: str): boolapplication/x-www-form-urlencoded body value
http.urldecode(src, from, upto, dst): intpercent-decode; -1 if malformed or no room
http.add(s: str) / .addn(v) / .addc(c) / .addb(b, at, n)append to the reply body
http.hdradd(s: str) / .hdraddslice(name; b, from, upto)add a response header before finish
http.finish(status: int, ctype: str)write status line, headers, body; queue the reply — must be the last call for this request
http.logging: boolset true for one JSON line per request on STDERR
http.fd, http.slot, http.listhe connection being served: its fd, its slot, the listener it came in on
http.nopen, http.refused, http.evicted, http.timedout, http.queuedcounters: open now, answered 503 (or, on a TLS port, closed) for want of a slot, closed to make room, closed by a deadline, replies that waited for the socket
http.tlsrefused, http.tlsfailed, http.tlskeptTLS counters: closed at once (no certificate yet, or no session free), sessions that failed (not TLS, a bad record, an alert), reads that decrypted to more than the slot could take at once

http.param/.form/.header share result buffers — a second lookup overwrites the first; copy out what you still need. http.hdradd/.hdraddslice silently drop a header if the 2 KB header buffer is full.

Large requests. A body larger than INBUF (256 kB) gets 413 unless http.maxbody(cap) was called. Then each body up to cap is read into a region of its own connection's, mapped when its headers arrive and unmapped as soon as it is answered — several can arrive at once, and an answered one costs nothing. All of them together may hold http.maxbodymem (256 MB by default); a body that would go past it is answered 503 with Retry-After, and one larger than cap (or than that total) 413. Inside app.request, http.bodyinbig: bool says whether to read http.inbuf[http.bodyat..] or view(http.bigbase, http.bodylen); mcp.http and tool.rest do this themselves, http.form does not. A client that sends `Expect: 100-continue (curl, for a body over 1 MB) is answered 100 Continue` once its body is accepted, and gets the refusal instead when it is not.

Large replies. A reply is built in http.outbuf until it outgrows it; then it moves, whole, to a region of its own that doubles as it grows, up to http.maxreply (OUTBUF, 512 kB, by default). Past that the reply becomes a clean 500. In the clear the region itself becomes the reply's queued output, so a large reply is never copied a second time; over TLS it is sealed into one region that is. What waits for a slow reader counts against http.pendmax (128 MB): a connection that would push the total past it is closed, the others are not affected. Code that writes into http.outbuf itself (`json.putstr(http.outbuf, http.wpos, ...)) must stay within its first 512 kB; past that, append with http.add/addb/addc/addn`.

Limits, per worker process:

size
connections at onceMAXCONN = 4096lower with http.maxconn; any fd below HTTP.MAXFD = 65536
request (headers + body)INBUF = 256 kBmore through http.maxbody, a region per connection
large bodies being read, togetherhttp.maxbodymem = 256 MBpast it, 503 with Retry-After
reply bodyOUTBUF = 512 kBmore through http.maxreply; past the limit, 500
input every connection ownsHTTP.SLOTIN = 16 kBa larger request borrows a chunk
shared input chunksHTTP.CHUNKS = 64 of 256 kBthat many large requests can be arriving at once
unsent reply bytes heldhttp.pendmax = 128 MBall connections together

Static memory is about 86 MB (4 MB of it the TLS stack), most of it pages that are never touched unless a connection uses them. TLS sessions add a region of 68 MB, mapped on the first TLS connection and likewise touched only as records arrive in pieces. Large bodies and replies take memory only while they exist, within the limits above: at most http.maxbodymem (256 MB) of bodies once http.maxbody is set, one reply being built (up to http.maxreply, and as much again while it is sealed for TLS) and http.pendmax (128 MB) of output waiting for slow readers. http.serve raises the soft limit on open files to 65536 (never above the hard limit).

Deadlines. A connection that makes no progress is closed. Defaults, changed with http.timeouts:

waiting fordefaultcounted
the request line and headers10 sfrom the first byte, or from the accept; never extended
the body30 s + 1 s per 16 kB of Content-Lengthfrom the end of the headers
the next keep-alive request60 sfrom the end of the last reply
the client to read the reply30 swithout progress

When it is full. With every slot taken, a new connection closes the quiet connection nearest its deadline (one between requests, or that has not sent anything) and takes its place. If every connection is in the middle of a request, the new one gets 503 with Retry-After: 1 and is closed. A request that needs a chunk while all 64 are borrowed gets 503 too. Pipelined requests are answered in order, one reply after the other.

Your own loop. http.listen(port, workers) once, then http.poll(timeout) repeatedly, with whatever else the program drives in between (ws.poll, a timer). The fd-based routines of older loops — http.accept, http.readable(fd), http.flush(fd), http.drop(fd), http.open[fd] — still work, but only http.poll enforces the deadlines.

Any import that brings in http — tools, mcphttp, router, openapi — makes app.request mandatory, even if http.serve is never called; an empty body suffices. A stdio-only MCP server imports toolsmcp instead and needs no HTTP at all.

HTTPS. One call before http.serve makes its port speak TLS 1.3 (tls.wz); nothing in app.request changes:

http.https("www.example.com", "admin@example.com", "/var/lib/myapp/certs", false);
http.serve(443, 1);

http.https configures autocert.wz and adds a plain listener on port 80 (http.redirect moves it). The listeners open, a certificate in the store is installed, and then http.poll steps the certificate manager: it wakes at least once a second and calls autocert.tick, so the challenge is answered by port 80 while an order is pending. Port 80 answers /.well-known/acme-challenge/<token> for the order in progress (404 otherwise), redirects everything else to https://<host>[:port]<path> — 301 for GET/HEAD, 308 for the rest, which keeps the method — once a certificate is installed, and says 503 with Retry-After before that. It never runs the application. On the TLS port, before the first certificate, a connection is closed at once. autocert.* settings (setdirectory, settiming, pin, quiet) can be changed between http.https and http.serve.

http.httpsfiles(certfile, keyfile) is the same without an authority: the chain (one or more CERTIFICATE blocks, the server's own first) and a P-256 key (EC PRIVATE KEY or PRIVATE KEY) from PEM files — for development, tests, or a private authority. It may be called again while serving: the next handshake uses the new pair, open connections carry on. With http.redirect(port) the plain port redirects to the Host the request named. A program that installs its certificate itself with tls.setcert sets `http.tlsmain := true` instead.

What changes on a TLS connection, and what does not:

  • the request is decrypted in http.pull and the reply sealed in http.emit; parsing, pipelining, keep-alive, chunks, the big-body region and every limit above are the same code. A read is at most 32 kB; what it decrypts to beyond the room in the slot is kept for the next read (http.tlskept), never dropped — in a region that exists only while it holds bytes, 32 MB for all connections together (past that a connection is closed)
  • the header deadline also covers the handshake: from the accept to the end of the first request's headers, never extended — a client that stops mid-ClientHello is closed by it
  • a slot's TLS session is freed with the slot; closing sends close_notify, except after a reply that did not go out whole
  • a refused request (413, 503) is not closed at once: the connection lingers up to 5 s, swallowing what the client still sends, so a client busy uploading reads the refusal instead of a reset
  • no slot free: the connection is closed rather than answered with a plaintext 503
  • The websocket module works on plain listeners only; on a TLS connection ws.take refuses

Where the time goes: a new handshake costs milliseconds of CPU (the P-256 signature), so new connections per second are in the low hundreds per core; a request on an established keep-alive connection costs a few times what it does in the clear. Keep connections alive. HTTPS mode runs one worker (http.serve(443, 1)): two would each order a certificate. A certificate-manager step blocks the loop for one request to the authority, bounded by its timeout (10 s), a few times per order.

Limits: no routing table beyond router.wz, no middleware, no chunked REPLIES (every reply carries Content-Length), no HTTP/2. A chunked REQUEST body is read and decoded, up to the connection's buffer (256 kB; larger gets 413).

import http;

procedure app.request;
begin
  if http.pathis("/health") then
  begin
    http.add("ok"); http.finish(200, "text/plain"); return;
  end;
  http.add("hello from wantzel\n");
  http.finish(200, "text/plain");
end;

begin
  http.serve(8080, 1);
end.

mcp — an MCP server, and how large its messages may be

import toolsmcp; for a server over stdio (pulls in mcp and json); import tools; and import mcphttp; to serve the same tools over HTTP. The tools themselves are a declared tools ... end; block — see language.md §7b. mcp.stdio reads one JSON-RPC message per line; mcp.http, called from app.request, answers one per POST.

Limits. Three variables, set before the transport starts; 0 keeps the default.

variabledefaultboundspast it
mcp.maxin64 MBone request message: a stdio line, an HTTP bodyJSON-RPC error -32600 (over stdio the line is dropped and the next one read)
tool.maxout64 MBthe result JSON of one tool callJSON-RPC error -32603; over REST (tool.rest) a 500
mcp.maxout160 MBone reply messageJSON-RPC error -32603

A reply carries a tool result twice — escaped as a text block and as structuredContent — so mcp.maxout is larger than tool.maxout. The error names the limit that was hit, in the message and as data.limit, and carries the caller's id when the request had one:

{"jsonrpc":"2.0","id":12,"error":{"code":-32603,"message":"the tool result is larger than the maximum of 8388608 bytes","data":{"limit":8388608}}}

Nothing is ever cut short: a writer that does not fit makes the whole reply this error.

Memory. A message that fits the static buffers — mcp.in and mcp.buf, 4 MB each, and tool.out, 1 MB — costs no system call. A larger one moves once into an anonymous mapping as large as its limit; on Linux a page of it costs memory only when written. The request and reply mappings are given back as soon as the message is answered. Tool results are written into a mapping of tool.maxout made at the first call and kept for the next, and given back after a result larger than 1 MB; with tool.maxout at 1 MB or less, tool.out is used and nothing is mapped. Worst case with the defaults, while one maximum call is answered: 64 + 64 + 160 = 288 MB on top of the program's own records; between calls, at most 1 MB more than the static buffers. mcp.mapped and mcp.mappedbytes say what is held right now.

Over HTTP the message must fit the HTTP layer too: call http.maxbody(n) before http.serve to accept requests larger than 256 kB, and http.maxreply(n) to send replies larger than 512 kB. A reply larger than http.maxreply is answered with the JSON-RPC error for mcp.maxout, naming that smaller size.

A transport of your own calls n := mcp.handle(b, at, last): the reply is n bytes at view(mcp.obase, n) — in mcp.buf whenever it fits there — and mcp.done gives a mapping back once they have been sent. A text/json view field in the output schema is built in tool.vbuf, 1 MB; a handler that fills it past that limit is refused with a handler-side failure naming the tool and the limit (`tool <name>: reply exceeds 1048576 bytes`), not a reply built from silently truncated text. For a result that may exceed it, use text[N] fields and arrays instead.

autocert — a certificate that a server gets and keeps by itself

import autocert; (pulls in tls, chain, acme, certstore, dns)

A server on http.wz does not call any of this itself: http.https does (see HTTPS in the http section). The routines below are for a program with a loop of its own.

A state machine that a server's own event loop steps: it orders a certificate from Let's Encrypt (or any ACME authority) with the HTTP-01 challenge, keeps it on disk, renews it, and hands each new one to the TLS server with tls.setcert — no certbot, no cron, no second process, no restart. Every request to the authority is HTTPS whose certificate must chain to a trusted root.

routinemeaning
autocert.sethost(h: array of char)the name the certificate is for (one name)
autocert.setemail(e: array of char)a contact address for the account (optional)
autocert.usestaging: booltrue: Let's Encrypt's staging authority. Default: production
autocert.setdirectory(url: array of char)another authority's directory URL (https:// only); overrides staging
autocert.setstore(dir: array of char)where keys and certificates live; default certs/ beside the binary
autocert.pin(name, a, b, c, d: int): boola fixed IPv4 address for a name, used instead of DNS (the authority's name is otherwise resolved with dns.resolve)
autocert.settiming(checkms, renewsec, retryms, timeoutms: int)check interval (1 day), renew window (30 days), first retry (1 h), one request (10 s); 0 keeps a value
autocert.start: boolload what is stored, install a usable certificate, check the configuration; no network yet
autocert.tickcall from the loop's timer; does nothing until something is due, then at most one request
autocert.waitms: intmilliseconds the loop may sleep before the next tick matters
autocert.answer(path, dst: array of char): intfor GET /.well-known/acme-challenge/<token>: the key authorization in dst and its length, or -1 (answer 404)
autocert.havecert: bool, autocert.notafter: inta certificate is installed; its expiry in seconds since the epoch
autocert.msg[0..autocert.msgn)the last log line; autocert.quiet := true keeps them off STDERR

What happens, and when. At start a stored certificate that parses, matches its key, names the host and is in date is installed at once, and not ordered again while it has more than 30 days left — a restart is free, which matters because Let's Encrypt limits orders per name. The certificate is renewed when fewer than 30 days (or a third of its lifetime) remain. An order is directory → nonce → account (key created once and kept) → new order → authorization → challenge → poll → finalize with a new key and CSR → poll → download; the chain is then checked (host, our key, dates, each link signed by the next), saved, and installed — the whole chain, leaf first, since a browser has only the root. A failure keeps the old certificate in service and retries after 1 h, 6 h, then every 24 h — or later if the authority sends Retry-After — and that schedule is stored, so a crash-looping service does not hammer the authority either.

The loop's side. The challenge is fetched by the authority *while the order is pending*, so the loop must keep serving port 80 between steps — which is why each tick does one request and returns:

if not autocert.start then halt(1);
while true do
begin
  wait := autocert.waitms;
  if wait > 1000 then wait := 1000;
  nev := net.wait(ep, addr(evs[0]), MAXEV, wait);
  ...   // port 80: n := autocert.answer(path, body); n >= 0: 200 with body[0..n), else 404
  ...   // port 443: a tls.open session per connection, fed by the loop (as http.wz does)
  autocert.tick;
end;

The store is one directory (0700, files 0600, every write atomic): account.key, <host>.pem (the certificate key and chain in one file, so a crash cannot pair a new key with an old certificate) and <host>.retry. The keys are PEM EC PRIVATE KEY, which openssl reads. A missing or read-only store works — without a cache — and says so.

Limits: HTTP-01 only (no wildcards), one name per certificate, no revocation. A step blocks the loop for the length of one HTTPS request: the name lookup (at most 12 s), the connect and every TLS call are each bounded by the request timeout (10 s). examples/autocertd.wz is a complete server on http.https.

websocket — a WebSocket server on the same port as http

import websocket; (pulls in net, base64, http and sha1)

RFC 6455, on the SAME listening socket http.wz already has. This module never opens a socket of its own; it takes over a connection that app.request hands it.

routinemeaning
ws.take(fd: int): boolcall from app.request after http.detach := true; reads Sec-WebSocket-Key, sends 101, returns true on success
procedure app.wsframe(fd: int)(application-defined) once per complete frame; payload at ws.in[ws.slot * WS.INMAX + ws.at .. +ws.len), opcode in ws.op
procedure app.wsclose(fd: int)(application-defined) once per connection end, any reason
ws.poll(timeout: int): intdrive the loop; timeout ms, 0 = don't block, -1 = block; returns connections served
ws.sendtext(fd; a: array of char; n: int): boolone text frame
ws.send(fd; opcode; a; n): boolgeneral form: WS.TEXT, WS.BIN, or a control opcode
ws.sendempty(fd; opcode): boola frame with no payload
ws.closefd(fd; code: int)send a close frame and drop the connection

The takeover: on an upgrade request, set http.detach := true and call ws.take(http.fd) before returning from app.request — that is the only point where http.wz's parsed headers still describe the request. If ws.take returns false, undo the detach and close the fd yourself. Control frames (ping/pong/close) never reach app.wsframe. WebSocket runs on plain listeners only: on a TLS connection (http.istls) ws.take returns false.

Connections live in slots, not at their fd — the same shape http.wz uses for its own MAXCONN connections. ws.in (and every other per-connection table) is sized and indexed by slot, not by fd, so inside app.wsframe/app.wsclose the base into ws.in comes from ws.slot (set by this module right before the call), never from fd — an fd can be arbitrarily large (up to WS.MAXFD, matching http.wz's own fd range) while only WS.MAXCONN connections are open at once.

Close codes: 1000 normal; 4000–4999 reserved by RFC 6455 for application-defined meanings.

Limits: WS.MAXCONN 256 connections open at once, WS.MAXFD 65536 (the fd range ws.take accepts — matches http.wz's own), WS.INMAX 64 KB buffered input per connection (over that: ws.closefd(fd, 1009)), WS.OUTMAX 64 KB per outgoing frame; ws.take refuses once 256 connections are already open, whatever the fd. http.serve cannot also call ws.poll, so a program wanting both protocols runs its own loop — http.listen once, then http.poll and ws.poll(0) on every wake — instead of calling http.serve. A program that never imports websocket is unaffected.

import websocket;

procedure app.wsframe(fd: int);
var base: int;
begin
  base := ws.slot * WS.INMAX;
  ws.ignored := ws.sendtext(fd, ws.in[base + ws.at .. base + ws.at + ws.len - 1], ws.len);
end;

procedure app.wsclose(fd: int);
begin
end;

procedure app.request;
begin
  if http.pathis("/ws") and http.header("upgrade") then
  begin
    http.detach := true;
    if not ws.take(http.fd) then
    begin
      http.detach := false;
      net.close(http.fd);
    end;
    return;
  end;
  http.start;
  http.add("plain HTTP still works on this port\n");
  http.finish(200, "text/plain");
end;

dns — hostnames to IPv4 and IPv6 addresses

import dns; (pulls in net and rand)

A stub resolver: it asks the nameservers the system is configured with, over UDP, and again over TCP when an answer comes back truncated. dns.resolve/dns.start/dns.poll are A (IPv4) only, unchanged in shape from before: an address is one int, a shl 24 + b shl 16 + c shl 8 + d. dns.resolve6/dns.start6/dns.poll6 are their AAAA (IPv6) counterparts: an address does not fit in one int (128 bits against 64), so these take a flat byte buffer instead, 16 bytes per address, network order. A name that is already a literal (a dotted IPv4 quad for the *4/plain calls, the colon form for the *6 ones) comes back as itself, and localhost as 127.0.0.1 or ::1, without a query.

routinemeaning
dns.resolve(name: array of char; out: array of int): intlook up A records and wait, at most the lookup's total time (12 s); the number of addresses written to out, or a negative DNS.* code
dns.resolve6(name: array of char; out: array of char): intthe AAAA counterpart; out's length must be a multiple of 16
dns.connect(name: array of char; port: int): intresolve BOTH A and AAAA (concurrently, so one slow record type does not delay the other), then connect to the first address that accepts — IPv6 addresses tried before IPv4, each in the order its records came back; the fd, or a DNS.* code (DNS.NOCONNECT when it resolved but nothing accepted)
dns.start(name: array of char): int · dns.start6(name: array of char): intbegin a lookup without blocking; a query number, or DNS.BUSY
dns.poll(q: int; out: array of int): int · dns.poll6(q: int; out: array of char): intnever blocks: DNS.PENDING (0) while running, then the count or a DNS.* code; collecting frees q (use dns.poll6 for a query started with dns.start6)
dns.fd: intthe resolver's own epoll set — watch it for EPOLLIN in yours (shared between A and AAAA queries)
dns.wait: intmilliseconds until the next deadline, for your epoll_wait; -1 when nothing runs
dns.cancel(q: int)drop a lookup, of either kind
dns.error(code: int): stra sentence for a DNS.* code
dns.parseip(s: array of char): inta dotted-quad literal as an address, or -1
dns.ipstr(dst, at, ip: int): intappend an IPv4 address as text; returns the new position
dns.parseip6(s: array of char; out: array of char): boolan IPv6 literal (with at most one :: run) into 16 bytes, network order
dns.ipstr6(dst, at: int; ip6: array of char): intappend an IPv6 address as text, uncompressed (eight groups of four lower-case hex digits); returns the new position
dns.loadconf(path: array of char): intread nameservers and options from a resolv.conf-style file; the count, or a negative errno
dns.noservers · dns.addserver(a, b, c, d, port: int): boolreplace the configured servers (at most DNS.MAXNS, 3)
dns.settimeout(tryms, totalms: int)per attempt (5000, as the C library) and per lookup (12000); 0 keeps a value
dns.flushempty the cache (both A and AAAA entries)

The failures are kept apart, because they mean different things: DNS.NXDOMAIN (the name does not exist), DNS.NODATA (it exists, without an address of the type asked), DNS.SERVFAIL, DNS.REFUSED, DNS.TIMEOUT (no server answered), DNS.BADNAME (not a hostname: a bad label, or a last label of digits only, such as a broken address), DNS.BADREPLY (an answer that does not parse, or a CNAME chain that loops), DNS.NETWORK (no socket, or nothing listens), DNS.BUSY, and from dns.connect DNS.NOCONNECT. DNS.NODATA is type-specific: a name with only AAAA records answers DNS.NODATA to an A query and its address to an AAAA one, and the other way round.

Where the servers come from. /etc/resolv.conf, on the first lookup: up to three nameserver lines with an IPv4 address, and options timeout:n attempts:n. No server at all means 127.0.0.1, as the C library does.

What a lookup does. Every attempt has a fresh random id and its own socket on a random source port, connected to the server; a reply counts only when its id and its question (including the type asked, A or AAAA) match, so a forged datagram is ignored rather than believed. A silent server costs one attempt's timeout and the next server is asked; SERVFAIL, REFUSED and an unreachable port move on at once; NXDOMAIN is final. CNAME chains are followed through the answer, under either type — only records on the chain count — and a chain that stops short is asked for under its last name. Compressed names are read with pointers followed only backwards, so a loop cannot hang it. Answers are cached by name AND type (an A and an AAAA lookup of the same name are two cache entries), for their TTL (32 names total); failures are not.

Limits: no search domains and no /etc/hosts beyond localhost, so give a full name; DNS.MAXQ (8) lookups at once, of either type, sharing the same pool of slots. dns.connect's IPv6 support goes through net.connect6.

import dns;

var
  addrs: array[0..DNS.MAXADDR - 1] of int;
  line: array[0..63] of char;
  n, i, at: int;

begin
  n := dns.resolve("localhost", addrs);
  if n < 0 then
  begin
    io.puts(STDERR, dns.error(n));
    io.puts(STDERR, "\n");
    halt(1);
  end;
  for i := 0 to n - 1 do
  begin
    at := dns.ipstr(line, 0, addrs[i]);
    at := io.push(line, at, "\n");
    io.out(STDOUT, addr(line[0]), at);
  end;
end.

From an epoll loop, without blocking: start the lookup, put dns.fd in your set once, wait no longer than dns.wait, and ask dns.poll after every wake. examples/host.wz is the blocking form as a command-line tool.

q := dns.start("example.com");
if not net.watch(ep, EPOLL_ADD, dns.fd, EPOLLIN) then ...
while true do
begin
  n := dns.poll(q, addrs);                 // DNS.PENDING (0), a count, or a DNS.* code
  if n <> DNS.PENDING then break;
  r := net.wait(ep, addr(ev[0]), 16, dns.wait);   // serve your other fds here too
end;

tls — TLS 1.3, a blocking client and an event-loop server

import tls; (pulls in the crypto modules it needs)

TLS 1.3 only, one cipher suite (TLS_CHACHA20_POLY1305_SHA256), one group (X25519). The server signs with one P-256 key. No TLS 1.2, no resumption, no client certificates, no HelloRetryRequest, no ALPN (clients then speak HTTP/1.1).

The client is blocking and handles one connection at a time:

routinemeaning
tls.connect(fd: int; host: str): boolthe handshake on a connected socket; tls.connectb(fd, hb, hn) takes the name as bytes
tls.write(b: array of char; n: int): boolsend application data
tls.read(b: array of char; max: int): intreceive; 0 at close_notify, -1 on error
tls.closesend close_notify
tls.verified: boolthe server proved its key and its chain reaches a root in the trust store (chain.wz)
tls.deadline: intmilliseconds each call above (and tls.accept) may take; 0 means 30 s. Past it the call fails with tls.err = -110. The socket is switched to non-blocking mode for this

The server never touches a socket. The program's event loop owns each connection, hands its session the bytes that arrived, and sends the bytes it gets back. Nothing blocks, so one thread holds up to TLS.MAXSESS (4096) connections, and a client that stalls halfway through its handshake costs its own session and nothing else.

routinemeaning
tls.setcert(chain: array of char; n: int; key: array of int): boolinstall the chain (DER certificates back to back, leaf first) and the leaf's P-256 private key; refused, keeping the old pair, unless the key matches the leaf
tls.open: inta session for a new connection; -1 when no certificate is installed or all are in use
tls.feed(s: int; src, app, wire: array of char): intthe bytes that arrived, src; returns how many were taken, -1 when the session failed. Afterwards app[0..tls.appn) is decrypted data and wire[0..tls.wiren) must be sent — also on -1, when it is the alert
tls.seal(s: int; src, wire: array of char): intencrypt src into wire; the number of bytes to send, -1 when not established or wire is too small (it needs len(src) + 22 per 16 KB)
tls.shut(s: int; wire: array of char): intour close_notify into wire: its length, or 0
tls.free(s: int)give the session back; its keys are wiped
tls.state(s: int): intTLS.ST.HANDSHAKE, TLS.ST.OPEN, TLS.ST.CLOSED (the peer sent close_notify), TLS.ST.FAILED, TLS.ST.UNUSED
tls.code(s: int): int, tls.why: strthe alert that ended a failed session, and a sentence saying why
tls.accept(fd: int): boolthe blocking form: the whole handshake on one socket, then tls.read/tls.write/tls.close

One step of the loop, when a TLS socket is readable:

n := net.recv(fd, addr(raw[0]), len(raw));
used := tls.feed(s, raw[0..n - 1], inbuf[have..len(inbuf) - 1], outbuf);
// send outbuf[0..tls.wiren) -- the handshake flight, or the alert when used < 0
have := have + tls.appn;                 // decrypted request bytes
if used < 0 then ...                     // close the connection after sending
if used < n then ...                     // inbuf is full: keep raw[used..n-1], feed it later
  • Partial input is held by the session. A ClientHello split across TCP segments or records, a record cut in half by a read — tls.feed keeps the piece and takes all of src. The one exception: a record is not opened until its plaintext fits in app, and then tls.feed returns less than len(src). An app of TLS.MAXPLAIN (16384) bytes always has room for one record.
  • wire needs TLS.MAXREC (18432) bytes free while the handshake runs: the server flight is built whole the moment the ClientHello is complete, and carries the certificate chain. After it, tls.feed only writes a KeyUpdate answer or an alert.
  • Malformed input fails that session only: an alert goes into wire, tls.state says TLS.ST.FAILED, and every other session carries on. The caller closes the connection and calls tls.free.
  • tls.setcert is safe while sessions are open. The next handshake uses the new chain; open sessions keep their keys.
  • The client and the server keep separate state: a blocking tls.connect (to an ACME authority, say) can run while server sessions are open.
  • A deadline is the caller's: a session never times out on its own, so the loop closes a connection that has not finished its handshake or request in time.

Memory: 256 bytes per session in a static table, plus 16645 bytes of reassembly each in a region mapped by the first tls.open (68 MB of address space for 4096 sessions; only pages that a record arrives into in pieces are ever touched). A program that only uses the client maps nothing. Throughput on one core, measured on an x86-64 laptop: about 150 handshakes a second (ECDSA signing is most of it) and about 30 MB/s of encrypted data.

examples/serve.wz is the complete server: HTTP and HTTPS on one loop, the ACME challenge, and a certificate picked up from disk while running. examples/localhttps.wz is the smallest one, on tls.accept.

lz — packing and unpacking

import lz; (pulls in nothing)

The simplest LZ block format: runs of literal bytes and back-references into what was written already, no entropy coding. Source text packs to about half its size; unpacking runs at a few hundred MB/s. It is the format the compiler stores this library in, so it is exercised on every import.

routinewhat it does
lz.pack(src, n, dst): intsrc[0..n) packed into dst; the packed length, or -1 when dst is too small
lz.unpack(src, n, dst): intsrc[0..n) unpacked into dst; the original length, or -1 when dst is too small or src is not a whole, valid stream
lz.bound(n): intthe largest result lz.pack can give for n bytes — size dst by it and packing never fails

lz.unpack checks every count and offset before it uses it: damaged or hostile input gives -1, never a read or write outside a buffer and never a runtime error. The same input always packs to the same bytes. The format itself is described at the top of wantzel --lib lz.

Read next