slang

Packages

The standard library, and how imports resolve.

Markdown version · llms.txt · api.json

Standard packages

time, net, json, proc, fs, log, crypto, sql, regex, os, io, strings, encoding, compress and math are compiler-provided native packages — no source files, just import "time"; / import "net"; / import "json"; / import "proc"; / import "fs"; / import "log"; / import "crypto"; / import "sql"; / import "regex"; / import "io"; / import "encoding"; like any other package.

http, httpc, http2, pg, flags, builder, byteutil, sort, math/stats and math/numtheory are slang-source stdlib packages under stdlib/. import "http" / import "byteutil" resolve to a local directory first, then a native package, then stdlib/<path> (SLANG_STDLIB or the compiler's SLANG_STDLIB_DIR).

time#

import "time";

let t0 = time.mono();     // monotonic clock; a `duration` (int64 ns)
time.sleep(20000000);     // sleep for a duration (ns)
let elapsed = time.mono() - t0;   // duration arithmetic
let deadline = time.mono() + 5000000;  // timeout math for net calls

let wall = time.wall();   // unix epoch time in nanoseconds
let d = time.http_date(); // "Tue, 13 Oct 2026 09:30:15 GMT", the current second

time.put_http_date(w, off) writes the same 29 bytes into a wire and returns off + 29 (with a zero-length wire it only counts): what http uses for Date. For dates other than now, use timez.

timez#

Calendar time, written in slang on top of the native time clock: instants, dates, durations, text formats, time zones, test clocks and cron. Import "timez".

import "timez";

let t = timez.now();                               // a Time: an instant, UTC
println(t.rfc3339());                              // 2026-10-13T09:30:15Z
println(t.rfc3339_milli());                        // 2026-10-13T09:30:15.000Z (toISOString)
println(t.http_date());                            // Tue, 13 Oct 2026 09:30:15 GMT
println(t.format("%A %e %B %Y, %H:%M"));          // Tuesday 13 October 2026, 09:30

let r = timez.parse_rfc3339("2026-10-13T11:30:15+02:00");   // offsets applied
let due = t.date().add_months(1);                  // Jan 31 + 1 month = Feb 28/29
let ttl = timez.parse_duration("1h30m") ?? 0;      // nanoseconds; "7d" works too
println(timez.relative(t.add(-3 * timez.MINUTE), t));  // 3 minutes ago

let zr = timez.load_zone("Europe/Berlin");         // the system's tz database
guard let berlin = zr else let e = err_of(zr) { println(e); exit(1); }
println(t.in_zone(berlin).format("%H:%M %Z"));     // 11:30 CEST

let cr = timez.parse_cron("*/15 9-17 * * MON-FRI");
guard let cron = cr else let e = err_of(cr) { println(e); exit(1); }
let next = cron.next_after(t, berlin);             // opt[Time], DST-aware

Values.

  • Time is Unix seconds plus nanoseconds, so it spans 0001-01-01 to 9999-12-31 and beyond. 64-bit nanoseconds stop at 1677-2262.
  • Date is a calendar day: birthdays, due dates, report days.
  • Durations are int nanoseconds, the unit time.sleep and deadlines take. Constants run from NANOSECOND to HOUR and DAY (24 hours).
  • The calendar is proleptic Gregorian, with no leap seconds.

Calendar.

  • Fields: year() to nanosecond(), weekday(), year_day(), iso_week(), is_leap_year, days_in_month.
  • Arithmetic: add(d), sub(t), and add_days, add_months and add_years, which clamp to the end of the month.
  • Rounding and periods: truncate(d), round(d), and start_of_* and end_of_* for minute, hour, day, week (Monday), month and year.
  • Comparing: before, after, equal, compare, min, max.
  • Dates: days_until, and years_until, which counts an age by whether the anniversary has passed.

Text.

  • rfc3339, rfc3339_milli, rfc3339_nano and rfc3339_at(offset).
  • parse_rfc3339 is strict: it refuses a timestamp with no offset, because one without names no instant.
  • parse_date.
  • http_date, and parse_http_date for all three forms RFC 9110 has recipients accept.
  • rfc2822 and parse_rfc2822.
  • format(layout): strftime directives, locale-free.
  • parse_duration and format_duration, Go's format plus d.
  • relative.

Time zones.

  • Loading. load_zone(name) reads the IANA database, from $ZONEINFO or /usr/share/zoneinfo. It follows each file's POSIX-TZ rule past its last transition, and refuses names that could reach other files. A missing database is an error that says how to install it, never a silent UTC. fixed_zone(name, offset) gives a zone at a fixed offset.
  • Reading. t.in_zone(z) gives a ZonedTime: local fields, abbrev, is_dst, rfc3339 with the offset, and format with real %z and %Z.
  • Local arithmetic. add_days, add_months and start_of_day work in local wall time, so a day across a DST change is 23 or 25 hours.
  • Wall time to instant. zoned(z, y, m, d, h, mi, s, how) converts a local wall time to an instant. how decides the skipped and repeated hours: COMPATIBLE moves forward through a gap and takes the earlier of an overlap, as Temporal and java.time do. The others are EARLIER, LATER and REJECT.

Operations.

  • system_clock() / fixed_clock(t): a Clock to pass in state, so tests advance and set the time instead of sleeping.
  • start_stopwatch(), which measures on the monotonic clock.
  • interval(a, b): half-open, with contains, overlaps and intersection.
  • deadline(t) gives the until that socket and channel calls take.
  • parse_cron: five fields, names, steps and @daily macros. When both day fields are set, a day matches either (Vixie). next_after(t, zone) evaluates in the zone's wall time: a run in a skipped hour happens when the clock reaches it, and a repeated hour runs once.

In JSON and Postgres. A timez.Time field of a struct is written by json.encode as an RFC 3339 string and read back by json.decode (see json). pg.arg_time(t) binds one for a timestamptz parameter and pg.get_time(rows, r, c) reads one out; pg.timestamp_rfc3339(cell, has_tz) turns Postgres's own timestamp text into RFC 3339.

net#

TCP listener/dialer built on bytes and fixed-width ints; every fallible call returns a result[_, str] unwrapped with guard let.

import "net";

let lr: result[i32, str] = net.listen(8080);   // 0 = ephemeral port
guard let lfd = lr else { exit(1); }

let pr: result[i32, str] = net.port(lfd);      // assigned port number

let ar: result[i32, str] = net.accept(lfd);    // blocks until a peer connects
guard let cfd = ar else { exit(1); }

net.send(cfd, b"hello");
let rr: result[bytes, str] = net.recv(cfd, 4096);
let data: bytes = rr ?? b"";

net.nonblock(cfd);                              // switch to non-blocking mode
let wr: result[bytes, str] = net.recv(cfd, 16); // "would block" err if idle
net.close(cfd);

net.idle_alive(fd) -> bool and net.tls_idle_alive(ssl) -> bool report whether an IDLE connection is still reusable: true only when the peer has neither closed nor sent anything. One non-blocking MSG_PEEK, nothing consumed; the TLS form is also false when OpenSSL holds decrypted-but-unread bytes. They exist for connection pools, and they are a primitive rather than a recv_until with an expired deadline because recv_until checks its deadline before touching the socket — it would report every dead connection as alive.

Deadlines#

net.recv and net.send wait for as long as the peer takes, which on a public listener is indefinitely: a client that connects and then neither sends nor reads parks the serving task on the reactor forever, holding its stack and its GC roots. That is slowloris, and the defence is recv_until / send_until, which take an until — an absolute monotonic instant, not a duration:

import "net";
import "time";

let deadline = until_of(time.mono() + 5000000000);   // 5s from now
let rr = net.recv_until(cfd, 4096, deadline);
guard let data = rr else let e = err_of(rr) {
    if e == "timeout" { net.close(cfd); return; }    // peer went quiet
    log.error("recv: " + e);                         // peer broke
    return;
}

"timeout" is a reserved error string: it means the deadline passed. "closed" is the other: another task called net.close on this fd while this call was parked on it. Those are the only error texts these calls invent rather than take from the OS. Every other error is strerror/OpenSSL text as before.

One asymmetry worth knowing: a send_until that times out has already written some bytes, and result[i32, str] has no room to report both "timed out" and "wrote this much". A "timeout" from send_until therefore means the stream is at an unknown offset and the connection must be closed, not retried. For a framed protocol that is the right contract regardless — a half-written frame is unrecoverable.

net.tls_recv_until / net.tls_send_until are the same thing over TLS, with the same reserved string. The link API takes an until on accept/send/recv already.

net.dial_until(host, port, deadline) bounds connecting: the DNS lookup and the TCP connect together. A lookup still running when the deadline passes is abandoned to the resolver thread (getaddrinfo cannot be interrupted), so the caller gets "timeout" on time. Every address the name resolves to is tried in turn — net.dial does the same, without a deadline.

DNS SRV and TXT lookups#

Atlas connection strings use DNS SRV and TXT records. net.lookup_srv_until and net.lookup_txt_until expose those records without blocking a slang worker; each lookup takes an absolute until deadline:

import "net";
import "time";
let deadline = until_of(time.mono() + 5000000000);
let seeds = net.lookup_srv_until("_mongodb._tcp.cluster.example", deadline);
guard let records = seeds else let e = err_of(seeds) { println(e); exit(1); }
// Each SRV item is "priority:weight:port:target".

let defaults = net.lookup_txt_until("cluster.example", deadline);
guard let options = defaults else let e = err_of(defaults) { println(e); exit(1); }
// Each TXT item is one record with its DNS character-strings concatenated.

An empty TXT list means the domain has no TXT defaults. SRV targets retain priority and weight so a client can apply its own ordering policy. The resolver checks the response question and each returned record owner, rejects malformed names and oversized or truncated answers, and returns "timeout" when the deadline expires. At most 64 SRV/TXT lookups may be outstanding per process, bounding their answer buffers to about 4 MiB; excess calls return "DNS resolver request limit reached". The C resolver library is linked only by programs that call either record lookup; ordinary net programs keep their existing link dependencies.

Unix-domain sockets#

net.dial_unix(path, deadline) connects to a Unix-domain stream socket and net.listen_unix(path) listens on one (accept with net.accept). The fds work with every fd-based call — send/recv and their _until forms, close, idle_alive. listen_unix refuses a path that already exists rather than deleting it, since the file may belong to a server that is still running; remove a stale one with os.remove first. A path longer than the platform allows (104 bytes on macOS, 108 on Linux) is an error, not truncated.

See examples/httpd/ for a minimal HTTP server on link plus the http stdlib package.

TLS#

net.tls_* adds a TLS listener/dialer on top of the plain net primitives above, built on OpenSSL (linked automatically, and only when a program actually calls one of these — a plain-TCP net program stays dependency-free). A SSL_CTX-equivalent config is created once (tls_server_ctx / tls_client_ctx) and reused across many connections; each connection is a separate rawptr handle.

import "net";

// server: load a cert + key once, reuse the context for every connection
let sctx_r: result[rawptr, str] = net.tls_server_ctx("cert.pem", "key.pem");
guard let sctx = sctx_r else { exit(1); }

let lr: result[i32, str] = net.listen(8443);
guard let lfd = lr else { exit(1); }
let ar: result[rawptr, str] = net.tls_accept(lfd, sctx);  // TCP accept + handshake
guard let sconn = ar else { exit(1); }
net.tls_send(sconn, b"hello");
net.tls_close(sconn);

// client: verify against a CA file, or "" for the system trust store
let cctx_r: result[rawptr, str] = net.tls_client_ctx("");
guard let cctx = cctx_r else { exit(1); }
let dr: result[rawptr, str] = net.tls_dial("example.com", 443, cctx);
guard let cconn = dr else { exit(1); }
let rr: result[bytes, str] = net.tls_recv(cconn, 4096);
net.tls_close(cconn);

Client verification is strict by default: tls_client_ctx enables peer verification, and tls_dial checks the certificate against both the CA and the hostname you asked for (SSL_set1_host — the check that's easy to forget and, if skipped, leaves you with "TLS" that validates a certificate chain without checking it belongs to the host you're actually talking to). Sending/receiving is blocking, same as plain net — call these from a spawned task if you need a connection handled without stalling anything else.

ALPN (RFC 7301) negotiates the protocol during the handshake, which is how HTTP/2 over TLS is selected — there is no in-band upgrade. tls_ctx_alpn(ctx, "h2,http/1.1") sets the list on a server context (in preference order, so the server decides) or the offer on a client one, and tls_alpn(conn) returns what was actually negotiated, or "" if the peer offered nothing that overlapped. The list is comma-separated, not the length-prefixed wire form; building that by hand is an easy way to produce a subtly broken handshake. A client offering no protocol we support completes the handshake without ALPN rather than failing, so it simply falls back to HTTP/1.1.

net.tls_ctx_alpn(sctx, "h2,http/1.1");
let conn = ...;                  // after tls_accept
if net.tls_alpn(conn) == "h2" { serve_h2(conn); } else { serve_h1(conn); }

Mutual TLS: tls_ctx_require_client(sctx, client_ca) on the server context demands a client certificate chained to that CA (SSL_VERIFY_FAIL_IF_NO_PEER_CERT). The client presents one with tls_ctx_use_cert(cctx, cert, key). Extra server names on one listener: tls_ctx_add_sni(sctx, host, cert, key) swaps in that cert when the ClientHello SNI matches; unmatched names keep the default tls_server_ctx cert. require_client applies to SNI certs too, regardless of call order. TLS 1.3 can let tls_dial return before the server has rejected a missing client certificate; the first send or recv then fails.

STARTTLS: tls_upgrade(fd, host, ctx) runs a client handshake on a socket from net.dial that has already spoken cleartext — how Postgres, SMTP and IMAP switch to TLS. Verification is exactly tls_dial's, hostname included, which is why the host is an argument: an fd does not remember what was dialled. On failure the fd is left open for the caller to close; on success it belongs to the returned handle and tls_close closes it. Read no further than the server's go-ahead before upgrading: anything a man in the middle queued behind it would otherwise be trusted as if it had arrived encrypted (libpq's CVE-2021-23222). tls_upgrade_until(fd, host, ctx, deadline) bounds the handshake; a server that stops answering part way through gives "timeout". tls_dial_until(host, port, ctx, deadline) is tls_dial with one deadline over the lookup, the connect and the handshake: an unreachable host gives "timeout" at the deadline instead of after the OS connect timeout, which is over a minute on macOS. Like dial, both try every address the name resolves to.

TLS on a link: c.tls_accept(ctx, deadline) runs the server handshake on a link you already accepted, and from then on the link's send/recv go through the session. Everything written against a link -- http.read_frame, http.write, a whole server loop -- serves HTTPS without a second code path, and a plain link pays one pointer test per call. Run it in the connection's own task, not the accept loop, so one slow handshake holds up nobody else:

fn serve(c: link, ctx: rawptr) {
    let hr = c.tls_accept(ctx, until_of(time.mono() + 10000000000));
    guard let _h = hr else let e = err_of(hr) {
        return;                  // a scanner, a plaintext client, a timeout
    }
    // read_frame / write on c exactly as over plain TCP
}

It returns result[int, fault]: timeout when the deadline passes, closed when the client hangs up during the handshake (browsers' preconnects do), and io with OpenSSL's reason otherwise (tls http request is a plaintext client). Client certificates are checked as tls_accept checks them. The session is freed, with a close_notify, when the link is; a second tls_accept on the same link is an error.

Reading and writing from two tasks: a link is move-only, so one task owns it -- and a task parked in recv cannot also send. c.split() returns a second link on the same connection for a writer task, which is what a server pushing to a client needs (a WebSocket broadcast, server-sent events):

fn writer(w: link, out: chan[bytes]) {
    let mut_w = w;
    while true {
        let m = chan_recv(out);
        guard let b = m else { return; }          // closed: done
        let sr = mut_w.send_bytes(b, until_of(time.mono() + 10000000000));
        guard let _n = sr else { return; }        // the peer is gone
    }
}

let sr = c.split();
guard let w = sr else { return; }
let out: chan[bytes] = make_chan(256);
spawn writer(w, out);
// this task keeps reading c; anyone holding `out` can write to the client

From then on c only reads and the returned link only writes: a send on the reader or a recv on the writer is an io fault saying which to use, since two senders could interleave partial writes. Ending the reader (its binding goes out of scope) shuts the connection down for both halves, so a writer blocked on a client that stopped reading fails at once; ending the writer leaves the reader working. Each half holds its own fd, so a half never writes to a number the kernel has reused. Over TLS, start TLS first (tls_accept on a split link is refused): both halves then share the session, one OpenSSL call at a time. A link splits once.

builder#

Assembling a result one piece at a time with + is quadratic: a + b allocates and copies both sides, so the cost is the sum of every intermediate length. Building 80 KB one byte at a time takes about two seconds, and a megabyte would take minutes. builder collects the pieces and copies each once, at the end.

import "builder";

let b = builder.new_str();
b.write("hello").write(", ").write(name).write_int(42).write_line("!");
let s = b.finish();                    // one allocation, linear in the total

let y = builder.new_bytes();
y.write_byte(104).write(b"ello").write_str(" world");
let raw: bytes = y.finish();

Str is for text and Bytes for binary data. Both chain, report size() in bytes without assembling anything, and offer finish() (which can be called again after more writes), take() (finish and start over) and reset(). The chunk is made on the first short write, so a builder that only takes long pieces never pays for it, and take() hands back a lone piece without copying it. Bytes.write_byte fills a 512-byte chunk in place, so a million single-byte writes make about two thousand allocations rather than a million: 4 million of them take 136 ms. Bytes keeps a copy of what you write, because bytes are mutable and a later change of yours must not rewrite what was already written.

For assembling a [bytes] you already hold, strings.join_bytes(parts, sep) is the counterpart of strings.join. bench/builder/ prints the naive loop beside the builder.

json#

json.decode/json.encode (de)serialize str/bytes against a concrete slang type — the target type for decode is inferred from the binding's annotation, the same mechanism ok()/err() already use to infer result[T,E]. There is no dynamic "JSON value" type: every decode is checked field-by-field against the struct shape you asked for, and a mismatch is a result error, not a silent null or a runtime panic.

import "json";

gc struct Address { city: str, zip: str }
gc struct Person {
    name: str,
    age: i32,
    email: opt[str],      // JSON null / missing key <-> none
    tags: [str],
    addr: Address,         // structs nest
}

let p = Person{ name: "Ada", age: 36, email: some("ada@example.com"),
                tags: ["math"], addr: Address{ city: "London", zip: "SW1" } };
let s: str = json.encode(p);

let r: result[Person, str] = json.decode(s);
guard let p2 = r else { exit(1); }

Plain structs work as well as gc structs, and are cheaper to decode: a plain struct is filled in place -- in the binding, or in a list's or map's own slot -- so a [Item] of a plain Item is one buffer, where a gc struct Item is one heap object per element. For a large array of small records (a request body with thousands of line items) that halves the allocations. Use gc struct when the record must be shared by reference.

Supported: gc struct (a plain struct is a compile error naming it, since the codecs read and build structs through a pointer), opt[T], [T], map[str, V] (JSON object keys are always strings — a map with any other key type is a compile error), every scalar, bytes (RFC 4648 base64 strings on the wire), and timez.Time (an RFC 3339 string: written as rfc3339_nano() writes it, read with parse_rfc3339()'s rules, offsets applied; about 115 ns to encode and 130 ns to decode, in C, so a struct without one compiles to exactly the codecs it did before), and decimal.Decimal (written as a string of its exact text, "19.90"; read from a string or a number by decimal.parse's rules, from the number's text and never through a float, also in C). rawptr, chan[T], and result[T,E] can't appear anywhere in a decode/encode target type. A missing JSON key defaults an opt[T] field to none; for any other field type it's a decode error. Unknown JSON keys are ignored. Every decode error names where it happened, composed through nesting — json.decode on {"addr":{"city":5}} against the Person shape above fails with field 'addr': field 'city': expected a string, got a number. Malformed input is a decode error, never a crash. Nesting is capped at 512 levels (deeper input is the error maximum nesting depth (512) exceeded), and depth costs heap, not stack: the parser keeps its own stack of open arrays and objects rather than recursing. Decoding into a type that contains itself (opt[Self], [Self], map[str]Self) does recurse once per level, so for those types alone json.decode first measures the input's depth and, only when the input is deep, grows the task's stack to fit before decoding.

Integers decode exactly, from the number as written: a 64-bit id such as 9007199254740993 arrives intact (it is not routed through a double, which is exact only up to 2^53), and every value an integer type can hold is accepted, its limits included. A whole number written with a fraction or exponent is still an integer (1e3 is 1000, 5.0 is 5); 1.5 or 1e-1 is expected an integer, and a value outside the target type's range is value … out of range for i8 (or int, u64, …). Floats decode through a double, as before.

proc#

Graceful shutdown and environment variables. proc.shutdown_requested() turns true once the process receives SIGTERM or SIGINT; a blocked net.accept()/net.recv()/net.dial() is interrupted the instant the signal arrives (an err result, not a hang), so a listener loop notices without needing select or a timeout.

Handling the signals is opt-in by use. A program that calls proc.shutdown_requested() anywhere — in its own code or in a package it imports — takes them over: the first SIGTERM or SIGINT only sets the flag, and a second one ends the process the way the signal would have, so a drain that never finishes cannot make it unkillable. A program that never asks keeps the default action: the signal ends it, whether or not it imports proc for anything else (proc.getenv, say).

proc.active_tasks() counts currently-running spawned tasks. proc.wait_idle() parks until that count is zero, so a shutting-down program can drain in-flight work without polling. Called from a spawned task, neither counts the caller (nor, for wait_idle, other tasks also waiting in it), so a task can drain the rest without waiting for itself; every test under slangc test is such a task.

import "net";
import "proc";
import "time";

fn accept_and_serve(lfd: i32) {
    let ar: result[i32, str] = net.accept(lfd);
    guard let cfd = ar else { return; } // interrupted, or a real error
    spawn serve(cfd);
}

let lr: result[i32, str] = net.listen(8080);
guard let lfd = lr else { exit(1); }

while !proc.shutdown_requested() {
    accept_and_serve(lfd);
}

proc.wait_idle();

proc.getenv(name) reads an environment variable, returning opt[str] (none if unset). proc.args() is the process argument list ([str]); args[0] is the executable path. To parse options out of it, see flags. proc.cwd() is the working directory as result[str, str].

fs#

POSIX file I/O on integer fds. open is read-only; create is write/trunc. read/write/close use the fd; pread(fd, off, n) reads at an offset. mkdir creates one directory. Every call returns result[_, str], except fstat(fd), which is [kind, size, mtime_sec, mtime_nsec] of an open file (kind 0 a regular file, 1 a directory, 2 anything else; [] for a closed fd). realpath(path) resolves ., .. and every symlink to an absolute path: what a file server compares with its root, so a link cannot lead out of it. These calls block the worker — use them for config and small files, not the accept loop. To read standard input use io instead: fs.read(0, n) works, but waits by blocking a whole worker thread, and a program waiting on a person at a keyboard would stall every other task with it.

import "fs";

let cr = fs.create("/tmp/note");
guard let fd = cr else { exit(1); }
fs.write(fd, b"hi");
fs.close(fd);

let or = fs.open("/tmp/note");
guard let in_fd = or else { exit(1); }
let rr = fs.read(in_fd, 16);
guard let data = rr else { exit(1); }
fs.close(in_fd);

os#

The operating system around a program: the environment, the process, and everything you can ask or do about a path without opening it. Pure libc, so importing os adds no link flag.

The fs/os boundary: fs owns open file handles and their contents; os owns paths you have not opened. fs.mkdir predates that split and stays where it is rather than breaking existing programs.

os.setenv(k, v) / os.unsetenv(k)result[bool, str]
os.environ()[str] of KEY=VALUE
os.pid() / os.tmpdir()int / str
os.hostname()result[str, str]
os.exists(p) / os.is_dir(p) / os.is_file(p)bool
os.size(p) / os.mtime(p)result[int, str]
os.read_dir(p)result[[str], str]
os.remove(p) / os.rename(a, b)result[bool, str]
import "os";

// serve a static file, the shape this package exists for
if !os.is_file(path) {
    return not_found();
}
let sr = os.size(path);
guard let n = sr else let e = err_of(sr) {
    log.error("stat " + path + ": " + e);      // "No such file or directory"
    return server_error();
}

The three predicates are bare bool on purpose. "Does this exist" has two useful answers: a missing path and an unreadable parent are both "no, you cannot use it", and code branching on the difference is racing anyway — the answer can change between the check and the use. The accessors return a value that has to come from somewhere, so those carry the errno text.

environ() is a list rather than a map because an environment may legally hold a repeated key, and a map would silently drop one. read_dir returns entry names without . and .., since forgetting to filter those is how a directory walk becomes an infinite loop. remove takes files and empty directories alike, so a caller need not know which it has.

proc.getenv, proc.args and proc.cwd stay in proc; os adds what proc has no answer for rather than duplicating it.

io#

The process's standard streams: what a command-line program needs that print/println and proc.args() don't already give it. Pure libc, so importing io adds no link flag.

io.read_line()result[opt[str], str] — one line of stdin, without its newline
io.read_all()result[bytes, str] — everything until the end of input
io.eprint(s) / io.eprintln(s)s (a str or a fault) to stderr, nothing added
io.flush()flush stdout
io.is_tty(fd)bool — whether fd (0, 1, 2) is a terminal
io.term_width() / io.term_height()opt[int] — columns / rows of the terminal; none without one
io.read_secret()result[opt[str], str] — like read_line, with echo off
io.raw_on() / io.raw_off()result[bool, str] — key-at-a-time input on / off
io.read_key()result[opt[str], str] — one key press, by name
import "io";
import "log";

print("name? ");                         // no io.flush() needed: reading flushes it
let r = io.read_line();
guard let maybe = r else let e = err_of(r) {
    io.eprintln("cannot read input: " + e);
    exit(1);
}
guard let name = maybe else {            // none: the input ended (Ctrl-D, end of pipe)
    exit(0);
}
println("hello, " + name);

A loop over every line is the same two guards inside while true:

let total = 0;
while true {
    let r = io.read_line();
    guard let maybe = r else let e = err_of(r) { io.eprintln(e); exit(1); }
    guard let line = maybe else { break; }         // end of input
    total = total + len(line);
}

opt inside result, on purpose. The end of input is not a failure — it is what a filter is waiting for — so it is none, the way a drained channel is; a failed read is an err. That is the README's own rule (absent data is opt, a bad world is result), at the cost of two guards.

Lines. "\n" or "\r\n" ends one and is not returned; an empty line is some(""), which is not none. A final line with no terminator is still a line. Ctrl-D on a terminal ends one read — the next read_line reads again, as in a shell — while on a pipe or file every later call is none too.

Waiting parks the task, not the thread. On a terminal, a pipe or a socket, read_line and read_all wait on the same reactor as net.recv, so other tasks keep running while a person thinks. (Reading fd 0 with fs.read blocks a whole worker thread; with one worker that stalls the entire program.) A regular file or /dev/null never blocks and is read directly. Before it waits, io flushes stdout, so a prompt written with print is on screen before the user types.

Ctrl-C. In a program that handles shutdown — one that calls proc.shutdown_requested() (see proc) — the signal is not fatal: a waiting read_line returns err("interrupted") and the program can clean up. Otherwise Ctrl-C ends the process as usual.

Limits. Input is buffered in memory, so one line — or read_all's whole input — is capped at 256 MiB; past that the call returns an err rather than allocating without bound. stdin is one shared stream: two tasks reading it at once are memory-safe but see interleaved chunks, so give it to one task.

eprint/eprintln write straight to stderr with no timestamp or level — for the messages a command prints to a person; log is for a service's diagnostics.

The terminal#
import "io";

// a password: nothing is echoed while it is typed
print("password: ");
let r = io.read_secret();
guard let maybe = r else let e = err_of(r) { io.eprintln(e); exit(1); }
let password = maybe ?? "";

// wrap to the window; a pipe or a file has no width
let width = io.term_width() ?? 80;

read_secret is read_line with the terminal's echo off for the length of the read. The Enter is not echoed either, so it writes the newline for you. When stdin is not a terminal (a pipe, a file, a test) it is exactly read_line: nothing to turn off. term_width and term_height ask the terminal every time, so they follow a resized window — poll them, there is no resize event. They look at stdout, then stderr, then stdin, and are none when none of the three is a terminal.

Key by key. raw_on switches stdin from line-at-a-time to key-at-a-time: no line editing, no echo, and each key press is available as it happens. read_key names it:

let r = io.raw_on();
guard let ok = r else let e = err_of(r) { io.eprintln(e); exit(1); }   // stdin is not a terminal
while true {
    let k = io.read_key();
    guard let maybe = k else let e = err_of(k) { break; }
    guard let key = maybe else { break; }                              // end of input
    if key == "q" || key == "esc" { break; }
    if key == "up" { println("up"); }
}
io.raw_off();
KeyName
a printable character (a whole UTF-8 character)itself: "a", "é", " "
Enter, Tab, Backspace"enter", "tab", "backspace"
Escape"esc"
Ctrl with a letter"ctrl-a" … "ctrl-z"
Alt with a key"alt-x"
cursor and editing keys"up" "down" "left" "right" "home" "end" "insert" "delete" "pageup" "pagedown"
function keys"f1" … "f12"
with modifiers"ctrl-right", "shift-up", "ctrl-shift-left", "shift-tab" — always in the order ctrl, alt, shift

Anything else is "unknown", and it is consumed whole, so a mouse report or an unfamiliar sequence cannot leak into the next key. read_key waits like read_line does, parking the task and not the thread, and also works on a pipe, where it decodes the same bytes.

A lone Escape and the first byte of an escape sequence are the same byte; what follows it within 50 ms is part of the sequence, and silence means the Escape key. Over a slow link a sequence split across that gap reads as an Escape followed by its tail.

The terminal is put back. A program that dies while the terminal is raw leaves the person's shell with no echo and no line editing, so restoring is not left to the caller. The terminal's original settings are saved once and restored when the last of raw_off and read_secret ends, and also:

  • when the program exits — exit(), a panic in the main task, falling off the end of main — so a forgotten raw_off costs nothing;
  • when SIGINT, SIGTERM, SIGHUP, SIGQUIT or SIGABRT ends the process (only where the program has not installed its own handling for them).

Raw mode leaves signals on, so Ctrl-C still works: in a program that calls proc.shutdown_requested() it makes the next read_key return err("interrupted"); otherwise it ends the process, and the terminal is restored first. It also leaves output processing on, so "\n" still starts a new line. Not covered: SIGKILL and crashes cannot be caught, and Ctrl-Z stops the process with the terminal still raw.

raw_on fails with "stdin is not a terminal" when it is not one, so a program can fall back to line input when it is run from a script.

log#

Stderr logging with a timestamp and level. Each function accepts a str or a fault (to_str/+ already convert faults the same way), so err_of bindings and fault values log without manual conversion.

import "log";

log.debug("cache miss for key foo");
log.info("listening on :8080");
log.warn("retrying dial after timeout");
log.error("could not load config: " + e);
log.warn(fault_timeout());

crypto#

Hashes, MACs, key derivation, authenticated encryption and signatures over OpenSSL: SHA-256/384/512 and their HMACs, HKDF, PBKDF2, Argon2id, AES-256-GCM and ChaCha20-Poly1305, Ed25519, ECDSA P-256, RSA, and a CSPRNG. Hashes and HMACs are infallible on valid inputs and return bytes directly; anything that can fail returns a result.

import "crypto";

let h: bytes = crypto.sha256(b"abc");              // 32 bytes
let m: bytes = crypto.hmac_sha256(key, msg);       // 32 bytes
let r = crypto.rand(32);
guard let b = r else let e = err_of(r) {
    log.error("rand failed: " + e);
}
let k = crypto.pbkdf2_sha256(password, salt, 600000, 32);   // RFC 8018
let s1: bytes = crypto.sha1(b"abc");               // 20 bytes
let d: bytes = crypto.md5(b"abc");                 // 16 bytes

pbkdf2_sha256(password, salt, iterations, length) accepts 1 to 10,000,000 iterations and a length of 1 to 1024 bytes; anything else is an err. The iteration count is capped because it is often chosen by the other side of a protocol (a SCRAM server sends it) and the whole derivation runs without yielding the worker thread.

sha1 and md5 are broken for collision resistance. They exist for the protocols that still specify them and cannot be changed -- the WebSocket handshake (RFC 6455) hashes the client's key with a fixed GUID, Git object ids, Postgres md5 authentication, Content-MD5, legacy ETags -- and must not protect anything new.

The low-level argon2id_raw and argon2id_verify primitives support the standalone argon package, which emits standard PHC password hashes. They accept byte passwords and salts and validated cost parameters; verification returns ok(false) for a mismatch and compares tags in constant time. Calls require OpenSSL 3.2 or newer with Argon2id support; on older OpenSSL builds they return an error while the other crypto functions remain available. Use the package's bounded PHC parser rather than parsing stored hashes in application code.

A derivation is tens of milliseconds of work that cannot be preempted. In a program that links the reactor (anything importing net or io, so every server), it runs on a small pool of dedicated kdf threads while the calling task parks, the way a DNS lookup does: a burst of logins neither pins the pool's workers nor holds up a collection. SLANG_KDF_THREADS sets the pool size (default: one per worker). Each in-flight derivation holds its memory_kib, so bound how many you start, as you would any other expensive request. Without the reactor it derives on the calling worker.

rsa_verify_sha256(n, e, msg, sig) -> result[bool, str] checks an RSASSA-PKCS1-v1_5 SHA-256 signature (JWS RS256) against a public key given as its raw big-endian modulus and exponent, which is what a JWKS entry's base64url n and e decode to. ok(false) is a signature that does not verify, including one of the wrong length; err is a key out of bounds (a 2048- to 8192-bit modulus, a 1- to 8-byte exponent) or one OpenSSL rejects.

import "crypto";

let r = crypto.rsa_verify_sha256(n, e, to_bytes(header_dot_payload), sig);
let valid = r ?? false;

Hashes, MACs, key derivation. sha384/sha512 and hmac_sha384/hmac_sha512 (JWS HS384/HS512) sit beside the SHA-256 ones. hkdf_sha256(ikm, salt, info, length) and hkdf_sha512 are RFC 5869 HKDF: use them to derive several keys from one secret, never a bare hash. The length is 1 to 255 hash lengths. constant_time_eq(a, b) compares in time that depends only on the length: use it for MACs, tokens and anything else an attacker could probe a byte at a time.

let enc = crypto.hkdf_sha256(master, salt, b"cookie-encryption", 32);
let mac_ok = crypto.constant_time_eq(crypto.hmac_sha256(k, body), sent_mac);

Authenticated encryption. seal(key, plaintext, aad) encrypts with AES-256-GCM under a fresh random nonce and returns nonce, ciphertext and tag in one bytes. open(key, box, aad) checks and decrypts it. The aad (associated data, which may be empty) is authenticated but not encrypted: bind a cookie to its name, a record to its id. Because the caller never chooses a nonce, it cannot reuse one, the mistake that breaks GCM outright. A key should seal fewer than 2^32 messages this way, so rotate it, for example via HKDF with a date in info. When a protocol fixes the nonce, aes256gcm_seal/aes256gcm_open and chacha20poly1305_seal/chacha20poly1305_open take (key, nonce, data, aad) with a 32-byte key and a 12-byte nonce, and carry ciphertext then a 16-byte tag. Every opener returns an error, never partial plaintext, when anything was altered.

let box = crypto.seal(key, to_bytes(session_json), b"session");
let back = crypto.open(key, box ?? b"", b"session");   // err if tampered

Signatures. Keys use the raw forms JWK and JWS use. Ed25519 has a 32-byte private seed, a 32-byte public key and a 64-byte signature. ECDSA P-256 with SHA-256 (JWS ES256) has a 32-byte private scalar, a 65-byte uncompressed public point (0x04 || x || y) and a 64-byte r || s signature. RSA keys are PEM text.

Ed25519ECDSA P-256RSA (SHA-256)
new keyed25519_generate()ecdsa_p256_generate()rsa_generate(bits) (PEM)
public keyed25519_public_key(priv)ecdsa_p256_public_key(priv)rsa_public_n(pem), rsa_public_e(pem)
signed25519_sign(priv, msg)ecdsa_p256_sign_sha256(priv, msg)rsa_sign_sha256(pem, msg) (RS256), rsa_sign_pss_sha256 (PS256)
verifyed25519_verify(pub, msg, sig)ecdsa_p256_verify_sha256(pub, msg, sig)rsa_verify_sha256(n, e, msg, sig), rsa_verify_pss_sha256
from PEMed25519_private_from_pem, ed25519_public_from_pemecdsa_p256_private_from_pem, ecdsa_p256_public_from_pem(sign takes PEM)

Verifiers return ok(false) for a signature that does not verify (including one of the wrong length) and err for a malformed key, such as a P-256 point that is not on the curve. ECDSA signatures are deterministic (RFC 6979) on OpenSSL 3.2 and newer, so a weak random number generator cannot leak the key; older 3.x uses random nonces. PSS fixes the salt at the digest length, as PS256 requires. RSA key generation takes tens of milliseconds at 2048 bits and seconds at 4096, without yielding, so do it at startup or in a tool. Prepared keys are cached (up to 16), so signing or verifying repeatedly with the same keys, as a server does with its JWT key or a JWKS, skips rebuilding them.

sql#

A SQLite driver (linked automatically, only when a program imports sql). Connections and prepared statements are opaque rawptr handles, exactly like net.tls_*; free them with sql.close / sql.finalize. Every fallible call returns result[_, str] whose error is SQLite's own message — no such table: users, near "SELCT": syntax error, UNIQUE constraint failed: users.id — so a bad query stays as visible as a bad socket read (guard let … else let e = err_of(r)), never a silent null. The column getters are infallible (SQLite coerces types; an out-of-range index is a programming error, returning 0 / ""), so they return bare values.

FunctionSignature
sql.open(path)result[rawptr, str] — ":memory:" for in-memory
sql.close(db)—
sql.exec(db, sql)result[int, str] — runs statement(s), returns rows changed
sql.last_insert_id(db)int
sql.prepare(db, sql)result[rawptr, str]
sql.finalize(st)—
sql.reset(st)result[bool, str] — clears bindings, re-run
sql.bind_int/bind_float/bind_text/bind_blob(st, idx, v)result[bool, str] — idx is 1-based
sql.bind_null(st, idx)result[bool, str]
sql.step(st)result[bool, str] — true = row ready, false = done
sql.col_count(st)int
sql.col_name(st, i) / col_text(st, i)str — i is 0-based
sql.col_int(st, i)int
sql.col_float(st, i)float
sql.col_blob(st, i)bytes
sql.col_is_null(st, i)bool
import "sql";
import "log";

let dr = sql.open("app.db");
guard let db = dr else let e = err_of(dr) {
    log.error("db open: " + e);
    exit(1);
}
sql.exec(db, "CREATE TABLE users (id INTEGER PRIMARY KEY, name TEXT)");

let pr = sql.prepare(db, "SELECT id, name FROM users WHERE id > ?");
guard let st = pr else let e = err_of(pr) {
    log.error("prepare: " + e);          // e.g. "no such table: users"
    exit(1);
}
sql.bind_int(st, 1, 0);
while true {
    let sr = sql.step(st);
    guard let more = sr else let e = err_of(sr) { log.error("step: " + e); break; }
    if !more { break; }
    println(to_str(sql.col_int(st, 0)) + " " + sql.col_text(st, 1));
}
sql.finalize(st);
sql.close(db);

SQLite calls block the worker — use them for real work off the accept loop (wrap in a spawned task), the same caveat as fs. One connection per rawptr; there is no pool and no async stepping. For Postgres, see pg, which runs on the scheduler instead.

Query complexity is capped per connection so SQLite's recursion stays inside the task stack: at most 50 terms in a compound SELECT (UNION/INTERSECT/EXCEPT) and an expression depth of 400 (roughly, terms in one AND/OR chain). SQLite's stock limits of 500 and 1000 allow a single legal query to want ~325KB of C stack, which would force a task stack far too fat to spawn per connection. Long IN lists, wide result sets, and recursive CTEs are not affected — they don't recurse. Exceeding a cap is a normal error through result[_, str] (too many terms in compound SELECT), not a crash.

pg#

A PostgreSQL client, written in slang over net. A query waiting on the server parks its task like any socket read, so unlike sql it does not block a worker thread: a server can hold many database connections on a handful of threads. Errors keep the server's own text and SQLSTATE, through the same result[_, str] story as every other package.

import "pg";
import "time";
import "log";

let dl = until_of(time.mono() + 5000000000);

let pr = pg.new_pool("postgres://app:secret@db.internal/shop", 10);
guard let pool = pr else let e = err_of(pr) {
    log.error("db url: " + e);
    exit(1);
}

let r = pg.pool_query(pool,
    "SELECT id, name, price FROM products WHERE price < $1 ORDER BY id",
    [pg.arg_float(20.0)], dl);
guard let rows = r else let e = err_of(r) {
    log.error("query: " + e);   // ERROR: relation "products" does not exist (SQLSTATE 42P01)
    exit(1);
}
let i = 0;
while i < rows.count {
    println(to_str(pg.get_int(rows, i, 0)) + " " + pg.get_text(rows, i, 1));
    i = i + 1;
}

Every call that talks to the server takes a deadline (until), like httpc; until_of(0) waits indefinitely. For connect it covers the whole connection: DNS lookup, TCP connect, TLS handshake and login.

FunctionSignature
pg.connect(url, deadline)result[Conn, str]
pg.parse_url(url) / pg.connect_config(cfg, deadline)result[Config, str] / result[Conn, str]
pg.query(c, sql, args, deadline)result[Rows, str] — one statement, $1, $2… parameters
pg.exec(c, sql, deadline)result[int, str] — several ;-separated statements, no parameters; rows affected by the last
pg.close(c)— sends Terminate and closes
pg.usable(c) / pg.in_transaction(c)bool
pg.server_param(c, name)opt[str] — server_version, TimeZone, …
pg.sqlstate(e)str — the SQLSTATE in an error, or "" if it did not come from the server

Parameters travel separately from the SQL text, so a value can never be parsed as SQL, whatever it contains. Build them with pg.arg_text(s), arg_int(n), arg_float(x) (sent exactly, not rounded), arg_bool(b), arg_bytes(b) (binary, for bytea), arg_time(t) (a timez.Time, for timestamptz; Postgres keeps microseconds) and arg_null(). A query with none takes [].

Results from query are buffered whole in a Rows (for one too big for that, see Streaming): rows.count, rows.columns (names), rows.affected (from the command tag, so an INSERT or UPDATE through query reports its row count) and rows.tag. Read cells by row and column index; pg.col(rows, "name") finds an index.

GetterReads
pg.get_text(rows, r, c)any column, in Postgres's text form (dates, numeric, uuid, json)
pg.get_int(rows, r, c)int2, int4, int8, oid, and a whole-number numeric (what sum() of an integer column returns)
pg.get_float(rows, r, c)float4, float8, numeric, the integer types
pg.get_bool(rows, r, c)bool
pg.get_bytes(rows, r, c)bytea
pg.get_time(rows, r, c)timestamptz as a timez.Time (result: err for infinity or a BC date); a timestamp without a zone is read as UTC
pg.get_decimal(rows, r, c)numeric and the integer types as an exact decimal.Decimal, at Postgres's scale (result: err for NaN, infinity or more than 38 digits)
pg.is_null(rows, r, c)whether the cell is NULL

The getters panic on NULL, on a column of a type they do not read, on an unknown column name and on an index out of range, with a message naming the column (column 'email' is NULL in row 3; check pg.is_null before pg.get_text). Those are disagreements between the query and the code reading it — bugs to see, not conditions to handle — and inventing a 0 or "" would hide them. A nullable column is checked with is_null first. Like sqlx's get, not Go's Scan.

pg.rows_as[T](rows) -> result[[T], str] binds columns by exact field name and decodes directly into value or gc structs, without rendering rows as JSON. Annotate the result, for example:

struct User { id: int, email: str, nickname: opt[str] }
let converted: result[[User], str] = pg.rows_as(rows);

Extra columns are ignored. Missing optional fields and SQL NULL become none; missing required fields and incompatible cells return an error naming the row and field. Binary integers and booleans retain their types, numeric values retain integer precision, and JSON/JSONB columns decode into nested types. bytea reads into bytes (or a base64 str); timestamps read into timez.Time or RFC 3339 strings, with a zone-free timestamp read as UTC. Infinity and BC timestamps remain strings and cannot fit a Time. String-keyed maps and optional row wrappers are also supported. Duplicate names use the first column for structs and the last for maps, as JSON decoding does. Scalar row targets are rejected; declare a row struct.

The hermetic bench/pg_rows_as/main.sl compares this with the previous Zokor JSON adapter on the same 1,000-row fixture. Intel macOS, ABBA x4: 3.792 ms versus 0.263 ms median per conversion; 23,040 versus 3,671 allocations and 1,864,778 versus 255,780 allocated bytes per conversion. These measure mapping alone, excluding database and request latency.

Pool#

pg.new_pool(url, max_open) parses the url and connects nothing until first use. pool_query and pool_exec take a connection, run, and give it back, so they cannot leak one. A transaction needs several statements on one connection, so it uses acquire and release:

let cr = pg.acquire(pool, dl);
guard let c = cr else let e = err_of(cr) { return; }
pg.exec(c, "BEGIN", dl);
let moved = pg.query(c, "UPDATE accounts SET balance = balance - $1 WHERE id = $2",
                     [pg.arg_int(100), pg.arg_int(from)], dl);
guard let m = moved else {
    pg.release(pool, c);    // still in the transaction: closed, never reused
    return;
}
pg.exec(c, "COMMIT", dl);
pg.release(pool, c);

At most max_open connections exist at once; a task that needs one while all are in use waits, up to its deadline. Waiters are served in arrival order: a release hands its connection straight to the oldest one, and pool_close fails every acquire still waiting. Idle connections are probed before reuse and closed after pool.idle_timeout (5 minutes). A connection released while broken, closed or still inside a transaction is closed rather than handed on — the next caller would otherwise run its statements inside someone else's uncommitted work. Releasing the same connection twice panics. pool.dials and pool.reuses count what the pool did.

URLs and TLS#

postgres://user:password@host:port/database?param=value, with % escapes in any part. The port defaults to 5432 and the database to the user name. Recognised parameters are sslmode, sslrootcert (a CA bundle to verify against, for providers that sign with their own CA), application_name and statement_cache_capacity (below), plus host and port, which override the ones in the authority, as in libpq. Anything else is an error, not ignored: a misspelt sslmdoe=require that was quietly dropped would connect in cleartext and nothing would ever say so.

sslmode
disablecleartext
require, verify-fullTLS, with the certificate chain and hostname verified
prefer, allowrefused: they fall back to cleartext when TLS fails, which is exactly what a man in the middle arranges
verify-carefused: it does not check the hostname

This differs from libpq on purpose: libpq's require encrypts without checking who answered. Without sslmode, the default is require — except for a loopback host (localhost, 127.x, ::1), where it is disable, because traffic that never leaves the machine gains nothing from TLS and local servers rarely have it set up. A server that does not offer TLS gets a message saying to add sslmode=disable.

Unix-domain sockets are named by their directory, as libpq does: postgres://app@%2Fvar%2Frun%2Fpostgresql/shop (the host percent-encoded) or postgres://app@/shop?host=/var/run/postgresql. The socket file is <dir>/.s.PGSQL.<port>. Postgres does not use TLS over a socket, so sslmode defaults to disable there and require is an error.

Authentication: SCRAM-SHA-256 (the default since Postgres 14), md5 and cleartext password. SCRAM checks that the server knows the password too: a server that skips its proof, or sends a wrong one, is refused.

Timeouts and errors#

A server-side error — a syntax error, a constraint violation — leaves the connection usable. Anything that leaves the stream at an unknown point breaks it: an I/O error, a protocol violation, a deadline. After a "timeout" the server may still be running the query, so the driver asks it to stop with a cancel request on a separate connection, from its own task, and returns immediately. A broken connection only returns connection is broken: <why>.

let r = pg.query(c, "INSERT INTO users (email) VALUES ($1)", [pg.arg_text(email)], dl);
guard let ok_ = r else let e = err_of(r) {
    if pg.sqlstate(e) == "23505" {     // unique_violation
        return respond(409, "email already registered");
    }
    return respond(500, "database error");
}

A Conn is safe to share between tasks (calls take turns), but a pool is the better tool for that.

Streaming#

pg.stream runs a query like query but hands the rows over one at a time, so memory holds one row however large the result, and the server is held back by TCP flow control rather than the client reading ahead:

let sr = pg.stream(c, "SELECT id, body FROM events ORDER BY id", [], dl);
guard let rows = sr else let e = err_of(sr) { return; }
while true {
    let nr = pg.next_row(c, rows, dl);
    guard let more = nr else let e = err_of(nr) { log.error(e); break; }
    if !more { break; }
    archive(pg.get_int(rows, 0, 0), pg.get_text(rows, 0, 1));   // always row 0
}

Each next_row replaces the row in rows, read at index 0; after the last, rows.affected and rows.tag are set. A server error part way through comes from next_row, after the rows before it. While a stream is open the connection serves nothing else — other calls fail with connection is busy — so read it to the end or call pg.stream_close(c, rows, dl). That asks the server to cancel the query, waits until the request has been delivered (so it cannot hit a later query on the same connection), then drops whatever was already sent. A connection released to a pool mid-stream is closed.

FunctionSignature
pg.stream(c, sql, args, deadline)result[Rows, str]
pg.next_row(c, rows, deadline)result[bool, str] — false after the last row
pg.stream_close(c, rows, deadline)result[bool, str]
COPY#

COPY … FROM STDIN bulk-loads data in COPY's text or CSV format, far faster than one INSERT per row; COPY … TO STDOUT dumps it.

let n = pg.copy_from(c, "COPY users (id, email) FROM STDIN (FORMAT csv)",
                     to_bytes("1,a@example.com\n2,b@example.com\n"), dl);
let dump = pg.copy_to(c, "COPY users TO STDOUT (FORMAT csv)", dl);
FunctionSignature
pg.copy_from(c, sql, data, deadline)result[int, str] — rows loaded
pg.copy_in_start(c, sql, deadline)result[bool, str]
pg.copy_in_send(c, data, deadline)result[bool, str] — any split, not only whole rows
pg.copy_in_end(c, deadline)result[int, str] — rows loaded
pg.copy_in_abort(c, reason, deadline)result[bool, str] — the server discards everything
pg.copy_to(c, sql, deadline)result[bytes, str] — buffered, up to the 256 MiB limit
pg.copy_out_start(c, sql, deadline)result[bool, str]
pg.copy_out_next(c, deadline)result[opt[bytes], str] — a row's worth, none at the end

The start/send/end and start/next forms stream, for data that should not be held in memory at once. A bad row fails the whole COPY — the error comes from copy_in_end (or copy_from) with its SQLSTATE, and nothing was loaded. A COPY of the wrong direction is refused with not a COPY … FROM STDIN statement, and a COPY through query or exec fails with a message pointing here, the connection intact either way. Only the text and CSV formats are meant: binary COPY data passes through untouched but this package does not build or parse it.

LISTEN / NOTIFY#
pg.listen(c, "jobs", dl);
while true {
    let wr = pg.wait_notification(c, until_of(time.mono() + 30000000000));
    guard let got = wr else let e = err_of(wr) { log.error(e); break; }
    guard let note = got else { continue; }        // 30s with nothing: none
    run_job(note.payload);
}
FunctionSignature
pg.listen(c, channel, deadline) / pg.unlisten(c, channel, deadline)result[bool, str] — unlisten(c, "*", dl) for all
pg.notify(c, channel, payload, deadline)result[bool, str]
pg.wait_notification(c, deadline)result[opt[Notification], str] — pid, channel, payload

Channel names are quoted as identifiers, so any name is safe to pass. Notifications that arrive during other calls — even in the middle of a query's result — are queued on the connection (up to 10,000; c.notes_dropped counts any beyond that) and handed out oldest first. Reaching the deadline in wait_notification returns none and leaves the connection usable, unlike every other call: waiting is the point, and a message cut off by the deadline is kept and completed by the next wait. A server that ends the session while one waits (an administrator's pg_terminate_backend, a restart) is an error with its SQLSTATE. Listen on a connection of its own, not a pooled one: a pooled connection with a notification waiting fails the pool's idle probe and is closed.

Limits#

A driver trusts the server with its memory, so what it will buffer is capped: one protocol message at 256 MiB, and one result at 256 MiB of cell data, which is an error rather than an allocation. A SCRAM server may ask for at most 1,000,000 PBKDF2 iterations (Postgres uses 4096).

Measured against Postgres 16 in Docker on the development Mac: 1M rows of two columns in about 2.1s and 150MB, a single 20MB value in about 0.3s.

Prepared statements#

query prepares each SQL text once per connection and reuses the statement after that, so Postgres parses and plans it once instead of on every call: on a point read that is about 60% less database CPU. A first use costs no extra round trip (the Parse travels with the query). Each connection keeps its 256 most recently used statements; statement_cache_capacity=N in the url changes that, and 0 turns it off, which a pooler that cannot carry named statements between server connections needs (PgBouncer before 1.21 in transaction mode). On cached runs, int2, int4, int8, bool, and bytea results use PostgreSQL's binary format; the pg.get_* functions preserve their existing values and text representation. Other result types stay in text format. If the server drops a statement or a schema change alters what it returns, the driver prepares it again and retries the query once, outside a transaction; inside one, the error is returned, because the transaction is already aborted. DEALLOCATE ALL and DISCARD ALL are noticed and empty the cache. exec and stream are not prepared.

Not supported: binary floats, building or parsing binary COPY data, Kerberos/GSSAPI, SCRAM channel binding (SCRAM-SHA-256-PLUS), multiple hosts in one url, and SASLprep normalisation of non-ASCII passwords (an ASCII password is unaffected).

redis#

A Redis client, written in slang over net like pg: commands waiting on the server park the task on the reactor instead of blocking a worker thread. Errors keep the server's own text through the same result[_, str] story as every other package.

The protocol core is usable now; pooling, cluster routing and pub/sub are all here too. connect dials with a deadline, runs AUTH and SELECT, and hands back a Conn that any task may share -- one round trip at a time, serialized by an internal lock. do runs one command; a reply that violates the protocol breaks the connection for good, while a server-side command error only fails that call. Heavier use goes through Pool (shared connections with idle probing), Cluster (slot routing with MOVED/ASK handling and same-slot checks), explicit MULTI transactions on direct Conns, and pull-model pub/sub where the caller drives sub_next (a 4-line pump feeds a chan when push suits better).

import "redis";
import "time";
import "log";

let dl = until_of(time.mono() + 5000000000);
let cr = redis.connect("redis://:secret@cache.internal:6380/2", dl);
guard let c = cr else let e = err_of(cr) {
    log.error("connect: " + e);
    exit(1);
}

// encode a command to wire bytes (binary-safe values pass through)
let wire: bytes = redis.encode([to_bytes("SET"), to_bytes("k"),
                                to_bytes("v")]);

// run it; at most one command is ever in flight per Conn
let r = redis.do(c, [to_bytes("PING")], dl);

// decode one reply; ok(none) means feed more bytes and retry,
// err means the bytes violate the protocol
let dr: result[opt[redis.Decoded], str] = redis.decode(wire);

// cluster hash slot of a key (0..16383), honouring {...} hash tags
let s: int = redis.slot("{user1000}.following");

// connection strings parse into Configs for pooling and clusters
let ur = redis.parse_url("redis://alice:secret@cache.internal:6380/2");

// pooled and clustered use underneath (see the table)
let pr = redis.new_pool("redis://cache.internal:6380", 8);

redis.close(c);
FunctionSignature
redis.encode(args)bytes — one command as a RESP2 array of bulk strings
redis.decode(buf) / redis.decode_at(buf, pos)result[opt[Decoded], str] — one reply plus bytes consumed
redis.slot(key)int — cluster hash slot with {...} tag support
redis.parse_url(url)result[Config, str] — redis:// / rediss://
redis.connect(url, deadline) / redis.connect_config(cfg, deadline)result[Conn, str] — dial, AUTH, SELECT
redis.do(c, args, deadline)result[Reply, str] — one command round trip
redis.close(c) / redis.usable(c)— shutdown / may commands still be attempted
stringsping, echo, get, set, set_ex, set_nx, del_keys, exists, expire, pexpire, ttl, pttl, persist, incr, decr, incr_by, decr_by, append, strlen, mget, mset
hasheshset, hget, hgetall, hdel, hexists, hkeys, hvals, hlen, hincr_by
listslpush, rpush, lpop, rpop, llen, lrange, ltrim, lindex, lrem
setssadd, smembers, srem, scard, sismember, spop
sorted setszadd, zrange, zrange_scores, zrank, zscore, zrem, zcard, zincr_by
keyskey_type, rename, rename_nx, scan (KEYS omitted on purpose)
poolnew_pool, new_pool_config, acquire, release, pool_do, pool_close
clusternew_cluster, cluster_do, cluster_refresh, cluster_close, c* typed wrappers, same-slot multi-key checks
transactionsmulti, queue, exec, discard, watch, unwatch (direct Conns; pool and cluster routing stay out)
scriptingeval, evalsha with NOSCRIPT fallback
pub/subsubscribe, sub_add, sub_next, sub_remove, sub_close (pull model; pump pattern in docs)
streamsxadd, xadd_maxlen, xrange, xrevrange, xlen, xtrim, xdel, xread, cluster cxadd/cxlen/cxread (groups deferred)

A reply is a redis.Reply: kind is one of REPLY_SIMPLE, REPLY_ERROR, REPLY_INT, REPLY_BULK or REPLY_ARRAY, with the payload in text, num, bulk (none for nil) or items (empty with is_nil for a nil array).

Replies default to a 256 MiB wire-byte limit and 1,000,000 aggregate array elements, including nested arrays. Config.max_reply_bytes and Config.max_reply_values can be raised for larger legitimate reads; each must remain positive, and the existing per-bulk, per-array, line, and nesting limits still apply. An over-limit reply fails and makes its connection unusable.

Not supported yet: consumer groups, RESP3, replica reads.

nats#

A client for NATS: publish, subscribe (plain and queue groups), headers, request/reply, flush and drain, keepalive, and automatic reconnection with resubscription. It covers core NATS, not JetStream.

import "nats";

let r = nats.connect("nats://127.0.0.1:4222", deadline);
guard let nc = r else let e = err_of(r) { panic(e); }

let sr = nats.subscribe(nc, "orders.*");                  // or queue_subscribe(nc, subj, "workers")
guard let sub = sr else let e = err_of(sr) { panic(e); }
nats.publish(nc, "orders.new", b"{\"id\":1}");
let m = nats.next_msg(sub, deadline);                     // result[opt[Msg], str]: none at the deadline

let h: map[str][str] = {};
h["Trace-Id"] = ["abc"];
nats.publish_headers(nc, "audit", h, body);

let reply = nats.request(nc, "pricing.quote", b"BTC", deadline);   // result[Msg, str]
nats.respond(nc, request_msg, b"42");
nats.drain(nc, deadline);                                 // unsubscribe, let in-flight arrive, close

URLs are nats://host:port or tls://host:port, optionally with user:pass@ or token@. options(url) gives an Options to adjust before connect_with:

  • Servers and identity: servers (tried in turn), name.
  • Authentication: user/pass, token, nkey_seed, or a user JWT and seed via with_creds(o, creds_file_text). NKeys sign the server's nonce with Ed25519.
  • TLS: tls, ca_path, and tls_first for servers with handshake_first. By default INFO arrives in clear and the client upgrades before sending CONNECT, so credentials travel only over TLS.
  • Delivery and keepalive: pending (see below), ping_interval, max_pings_out.
  • Reconnection: reconnect, max_reconnects, reconnect_wait, reconnect_buffer.

One connection, one reader. A reader task parses what the server sends and hands each message to its subscription's channel, so next_msg parks rather than polls. A subscriber that falls more than pending (default 1,024) messages behind loses the newest ones and counts them in sub.dropped, rather than stalling every other subscription. This is the NATS "slow consumer" rule.

Deadlines. Each connection has one timer task that is parked while nothing waits on a deadline; deadlines resolve to within 10 ms.

Writes coalesce. Publishes append to one buffer, and a flusher task writes it out after a short wait (20 µs, or 200 µs while a publisher is streaming). A publisher that fills 32 KB first writes it itself.

Reconnection. When the connection breaks, or the server stops answering pings, the reader redials the servers in turn with jittered backoff, sends CONNECT and every live SUB again, then replays what was published in the meantime, up to reconnect_buffer (8 MiB). Delivery is at most once, as in core NATS: a message written into a connection just as it broke is lost. reconnects(nc), is_connected(nc) and last_error(nc) report the state.

request uses one shared inbox subscription for every request on the connection. If no subscriber is listening, the server's "no responders" status makes it fail at once rather than at its deadline. A -ERR 'Permissions Violation' leaves the connection up; any other -ERR or a malformed frame drops and redials it. Subjects are validated (no whitespace, no empty tokens, no wildcards in a publish), and header names and values are refused if they would inject protocol lines.

s3#

S3-compatible object storage, signed with AWS Signature Version 4: AWS S3, Cloudflare R2, MinIO and the rest.

import "s3";
import "proc";

let r = s3.new_client("https://s3.eu-west-1.amazonaws.com", "eu-west-1", access_key, secret_key);
// or s3.from_env(proc.getenv): AWS_ACCESS_KEY_ID, AWS_SECRET_ACCESS_KEY,
// AWS_SESSION_TOKEN, AWS_REGION, S3_ENDPOINT
guard let c = r else let e = err_of(r) { panic(e); }

s3.put_object(c, "uploads", "avatars/42.png", png, "image/png", deadline);   // ok(etag)
s3.get_object(c, "uploads", "avatars/42.png", deadline);                     // ok(bytes)
s3.get_range(c, "uploads", "logs/today", 0, 1023, deadline);                 // first KiB
s3.head_object(c, "uploads", "avatars/42.png", deadline);                    // ok(some(Object)) or ok(none)
s3.delete_object(c, "uploads", "avatars/42.png", deadline);
s3.list_objects(c, "uploads", "avatars/", "/", "", 1000, deadline);          // ok(Page); page.next continues
s3.put_large(c, "backups", "db.dump", dump, "", 16 * 1048576, deadline);     // multipart
s3.presign_get(c, "uploads", "avatars/42.png", 900);                         // ok(url) for 15 minutes
s3.presign_put(c, "uploads", "incoming/x.bin", 300);

The payload is hashed into the signature (x-amz-content-sha256), so a body altered in transit is refused by the server. Signing keys are cached for the day they belong to. Errors carry S3's own code and message (s3: NoSuchKey: The specified key does not exist.).

Addressing and listing. Addressing is path-style by default (endpoint/bucket/key), which MinIO, R2 and most compatible stores want. Set c.path_style = false for virtual-host style (bucket.endpoint/key). list_objects returns up to 1000 keys a page in key order. Pass page.next to get the next page; with a delimiter, deeper keys fold into page.prefixes.

Multipart. create_multipart, upload_part, complete_multipart and abort_multipart are the raw steps (every part but the last at least 5 MiB). put_large drives them, uploading in one request when the data fits in a part, and aborting if a part fails so no orphaned parts are left to be billed.

Presigned URLs last 1 second to 7 days. presign_at signs at a given time, for tests and for URLs several servers must agree on.

Limits. Objects are read and written whole, in memory (or as one byte range). Redirects are not followed, since a redirect would change the host the signature covers.

regex#

Regular expressions on str or bytes, matched by slang's own Thompson NFA — no external library, so a program that matches text stays as dependency-free as a plain TCP one. compile returns an opaque rawptr handle (freed with regex.free, like net.tls_* and sql), and a bad pattern comes back as a descriptive result[rawptr, str].

Matching is linear time, always. There is no backtracking, so the classic catastrophic pattern (a+)+$ — which makes a backtracking engine take exponential time on a hostile input — runs in the same microseconds here as any other pattern. That is the point of choosing this engine for a server language: patterns and subjects both arrive from the network. The price is the RE2/Go one, and it is not negotiable: no backreferences and no lookaround. Both require backtracking; (?=...) and friends are a compile error, not a silent mis-parse.

FunctionSignature
regex.compile(pat)result[rawptr, str]
regex.free(re)—
regex.groups(re)int — number of capture groups
regex.is_match(re, s)bool — s is a str
regex.is_match_bytes(re, b)bool — b is bytes
regex.find(re, s) / find_bytes(re, b)[int]
regex.find_at(re, s, from) / find_bytes_at(re, b, from)[int]

find returns byte offsets as [start, end, g1start, g1end, ...], or an empty list when there is no match — so the result is GC-owned and there is no match handle to leak. find_at starts at an offset, which is how you walk every match.

import "regex";

let cr = regex.compile("(\\d{4})-(\\d{2})-(\\d{2})");
guard let re = cr else let e = err_of(cr) {
    log.error("bad pattern: " + e);   // e.g. "missing ) at offset 9"
    exit(1);
}

if regex.is_match(re, "due 2026-09-09") {
    let m = regex.find(re, "due 2026-09-09");
    println(to_str(m[0]) + ".." + to_str(m[1]));   // whole match: 4..14
    println(to_str(m[2]) + ".." + to_str(m[3]));   // year:        4..8
}
regex.free(re);

Supported: literals, ., classes [a-z] [^...] [[:digit:]], escapes \d \D \w \W \s \S \b \B \A \z \xHH, quantifiers * + ? {n} {n,} {n,m} and their lazy ? forms, groups (...) and (?:...), alternation |, anchors ^ $. Matching is leftmost-first (Perl-style priority), and . does not match \n.

Subjects are matched with an explicit length, so a bytes containing NUL matches correctly rather than stopping at the NUL — and \D matches a NUL byte like any other non-digit.

Bounds, so a hostile pattern can't exhaust memory or stack: 4096 compiled instructions, 100 nesting levels, 32 capture groups, and {n,m} counts up to 1000. Each is a descriptive compile error, never a crash.

A compiled regex is safe to share across tasks, and is meant to be: it carries a small pool of reusable match buffers, so concurrent matchers allocate nothing per match. Compiling is the expensive part (it is also the only part that grows the task stack) — compile once, match many times, ideally not once per request.

Where this lands on speed. Measured single-threaded on ^(GET|POST|PUT) (/[a-z0-9/_-]*) HTTP/1\.([01])$ against a 26-byte subject, 200k iterations:

enginematches/secon (a+)+$ vs a hostile input
slang regex~721k2µs, correct answer
POSIX regexec~372kfast here, but no limits
PCRE2 (interpreted)~2.4M0.2s, then gives up (MATCHLIMIT)
PCRE2 (JIT)~8.6Msame — JIT does not save it

So: ~1.9x faster than libc's POSIX engine, and several times slower than PCRE2 on benign input — PCRE2's interpreter and especially its JIT are very good, and this is an honest gap. The trade is deliberate: on adversarial input the ordering inverts completely, because linear time is a guarantee here and a hope there. is_match is markedly cheaper than find (it binds no capture slots at all), so prefer it when you only need a yes/no. Matching scales with tasks — ~3.2M/sec across 16.

Because matching never grows the task stack, regex is cheap to use per connection: 600 concurrently-live tasks each matching and then parking peak at 3.9MB RSS — about 17x lighter than the same shape holding sql connections (65.9MB), which does grow every task's stack.

http2#

HTTP/2 framing and HPACK header compression (RFC 9113, RFC 7541), written in slang — the frame codec is what the bitwise operators were added for.

import "http2";

let f = http2.decode(buf, 0, 16384);        // one frame, bounds-checked
guard let fr = f else let e = err_of(f) { return; }

let d = http2.decoder_new(4096);            // per-connection HPACK state
let hr = http2.decode_block(d, fr.payload, 64);
guard let hs = hr else let e = err_of(hr) { return; }
for i in 0..len(hs) {
    println(hs[i].name + ": " + hs[i].value);
}

Frame layer: decode / encode / header_bytes, the reserved bit masked off the stream id as the RFC requires, strip_padding, and the common control frames (settings_frame, settings_ack, ping_ack, rst_stream, goaway, window_update).

HPACK: prefix integers, string literals, the 61-entry static table, a dynamic table with the RFC's +32-per-entry accounting and eviction, and a canonical Huffman decoder. Header blocks decode through decode_block; encode_block builds one.

The encoder is deliberately stateless — every field goes out as a static-table index or a literal without indexing, and nothing is added to a dynamic table on the encode side. That is conformant and it removes a whole bug class: an encoder's dynamic table must stay in lockstep with the peer's decoder table, and any drift silently corrupts every later block on the connection.

Bounds against hostile peers: a frame longer than the advertised SETTINGS_MAX_FRAME_SIZE is refused before allocating, decode_block takes a max_headers cap (a small compressed block can otherwise expand without limit), a Dynamic Table Size Update above the agreed maximum is rejected, and NUL in a field name or value is the protocol error RFC 9113 §8.2.1 says it is. Huffman padding must be under 8 bits and all ones, and EOS inside a string is refused.

Validated against nghttp2 — the HPACK implementation curl and the browser stacks use — in both directions: blocks it produces decode here, and blocks produced here inflate there. Those fixtures are baked into tests/http2 as literals, so the suite needs no nghttp2 to run.

Connection layer, with concurrent streams. One task reads frames and dispatches each request to its own spawned handler; every byte leaving the connection goes through a single writer task fed by a chan[bytes]. No mutex is involved, and none is needed: each channel message is a complete frame sequence written with one net.send, so handlers cannot interleave inside a frame, and a HEADERS block plus its CONTINUATIONs stays contiguous by construction (RFC 9113 §6.2) rather than by careful ordering. Frames for different streams interleave at frame boundaries, which is what multiplexing means.

Measured: four 500ms requests multiplexed on one connection complete in 0.53s; served one at a time they would take about 2.0s.

The connection is addressed by a Transport, not a link. link is move-only, so spawn writer_task(c) consumes it and the reader can no longer use it — the two-task design is impossible with that type. A Transport is freely copyable, and one reader plus one writer in opposite directions on a socket is safe. net.recv also returns bytes directly, so no byte-at-a-time copy sits on the read path.

A Transport is either a plain fd or a TLS handle, and everything above it is identical either way:

http2.transport_fd(fd)     // h2c: cleartext, prior knowledge
http2.transport_tls(ssl)   // h2 over TLS, from net.tls_accept
fn handle(stream: i32, path: str, wch: chan[http2.WMsg]) {
    let hs: [http2.Header] = [];
    // The body goes over UNFRAMED: the writer owns the peer's windows,
    // so it decides how it is cut into DATA frames and when each may go.
    chan_send(wch, http2.response_msg(stream as int, "200", hs,
                                      to_bytes("hello")));
}

fn serve(fd: i32) {
    let cn = http2.conn_new();
    let rd = http2.reader_new();
    let wch: chan[http2.WMsg] = make_chan(32);
    let lim = http2.default_limits();
    spawn http2.writer_task(fd, wch, lim.write);

    guard let _p = http2.accept_preface(rd, fd, wch,
            until_of(time.mono() + lim.handshake)) else { return; }
    while true {
        let rr = http2.read_request(cn, rd, fd, wch, lim);
        guard let req = rr else let e = err_of(rr) {
            if http2.is_timeout(e) { /* slow peer; shed it */ }
            chan_close(wch);
            return;
        }
        spawn handle(req.stream as i32, req.path, wch);
    }
}

Verified against real curl --http2-prior-knowledge: GET, POST with a body, five requests multiplexed on one connection, and a 64KB upload that exercises DATA chunking and flow-control WINDOW_UPDATE.

Deadlines#

Every read and every write is bounded, so a peer that connects and then dribbles — or one that stops reading our responses — is disconnected rather than left holding a task forever. http2.Limits carries four separate budgets because they defend against four different peers:

BudgetCovers
handshakeconnect → valid client preface
idleno request in flight, waiting for the next frame
requestfirst HEADERS octet → END_STREAM
writeone writer_task send

idle is deliberately generous (2 minutes by default): an HTTP/2 connection sitting open with no streams is completely normal, and timing it out aggressively breaks correct clients. request is the strict one and applies to the request as a whole — it is never refreshed by incoming frames, so dribbling DATA one octet at a time cannot extend it. That distinction is the whole defence; a per-read timeout would never fire against a slowloris, because every individual read makes progress.

http2.is_timeout(e) distinguishes a slow peer from a broken one, so a server can answer the first with GOAWAY / E_ENHANCE_YOUR_CALM. tests/http2_deadline runs all three attacker shapes — silent, idle-after-handshake, and octet-at-a-time dribbling — against a server with sub-second budgets and requires all three to be shed.

Flow control#

DATA is flow-controlled at two levels, per-stream and per-connection (RFC 9113 §5.2), and the server may not exceed either. Both windows live in the writer task, because they are connection-wide state that the read side replenishes (WINDOW_UPDATE arrives there) and the write side spends — routing both into one task is what makes the accounting correct without a lock.

A handler therefore hands over its body unframed and moves on. If the peer's window is too small, the body waits in the writer's queue, not the handler's task — a peer advertising a tiny window costs a queue entry rather than a parked task.

SETTINGS_INITIAL_WINDOW_SIZE adjusts every open stream's window by the delta rather than resetting it, and does not touch the connection window (§6.9.2). A WINDOW_UPDATE that would push a window past 2³¹−1 is a FLOW_CONTROL_ERROR and ends the connection with a GOAWAY rather than being clamped.

tests/http2_flow drives both levels: a client advertising a 100-octet stream window against a 5000-octet body, and a client with a large stream window against a 100000-octet body where the default 65535 connection window is what binds. Each phase checks the exact octet the server stops at, that it resumes for exactly the credit granted, and that the resumed bytes carry the right content for their absolute offset in the body.

TLS and ALPN#

Browsers speak HTTP/2 only over TLS, and only when ALPN negotiates it — there is no in-band upgrade in a browser. So h2c alone, however conformant, cannot serve one.

The server advertises what it can speak, and then checks what was actually chosen:

net.tls_ctx_alpn(sctx, "h2,http/1.1");     // offer both, h2 preferred
// ... net.tls_accept(lfd, sctx) -> ssl
if !http2.alpn_is_h2(net.tls_alpn(ssl)) {
    // the peer picked http/1.1; serve it as HTTP/1.1 or hang up
}
let t = http2.transport_tls(ssl);

Checking is not optional politeness. A server that offers http/1.1 must expect to get it, and feeding an HTTP/1.1 client into the frame parser produces bad connection preface — true, but a poor explanation of what went wrong.

tests/http2_tls runs both halves over a real handshake: h2 frames across SSL_read/SSL_write, and an http/1.1-only client being declined rather than misparsed.

Interop#

Checked against Go's golang.org/x/net/http2, which shares no ancestry with nghttp2 (curl's stack, and where the HPACK fixtures came from) — agreement between two implementations that share code proves less than it appears to. It covers a GET, a 50KB POST, a 200KB response verified byte-for-byte against its absolute offset, and six concurrent streams on one connection. Run it with sh tests/http2_interop/run.sh; it skips cleanly without a Go toolchain.

Stream floods#

The connection layer cannot cap concurrency by itself: it does not spawn the handlers, you do. (slang has function values now, so handing it a callback would compile — but a callback would only move the same question inside, and the gate below is the answer either way.) So the bound is a gate — a token channel you hold. gate_enter takes a token and blocks when none are left, gate_leave returns one, and that blocking is the backpressure: the reader stops pulling frames while every slot is busy.

Without it, a peer that sends 1000 requests down one connection gets 1000 concurrent handler tasks — measured, against a SETTINGS_MAX_CONCURRENT_STREAMS of 100 that we were advertising and not keeping. Advertising a limit you do not enforce is worse than advertising none, because peers size their behaviour by it.

gate_drain also makes shutdown safe. Closing the writer channel while handlers are still in flight panics them with send on closed channel, and draining is what knows when none are left.

The one rule: gate_leave must run on every path out of a handler, error returns included. A lost token permanently shrinks that connection's capacity; losing all of them wedges that one connection — bounded and visible, not a crash, but not something to leave in.

Separately, RST_STREAM is counted. A peer that opens a stream and cancels it immediately (CVE-2023-44487, Rapid Reset) never looks concurrent, so a cap alone never trips; after a burst of 100 free cancellations, a peer whose resets outnumber half of what it opened ends the connection. Cancelling is legitimate — a browser navigating away resets its in-flight streams — so the burst and the ratio are both needed to tell a normal client from a flood.

tests/http2_flood drives both shapes, resetting and not, and fails if either exceeds the cap.

Client#

The same pieces, from the other end: a Transport, one writer_task, one reader. client_start performs the handshake and returns a Client whose own reader task routes each response to the task that asked for it, so client_call is safe from any number of tasks on one connection:

let started = http2.client_start(http2.transport_tls(ssl), http2.default_limits());
guard let c = started else let e = err_of(started) { return; }
let none_h: [http2.Header] = [];
let r = http2.client_call(c, "GET", "https", "api.example", "/v1/users", none_h,
                          b"", 2000000000, 1048576);   // timeout ns, max body
guard let resp = r else let e = err_of(r) { return; }
println(resp.status + " " + to_str(len(resp.body)));
http2.client_close(c);

client_call allocates stream ids (odd, increasing, in wire order), waits for a slot under the peer's SETTINGS_MAX_CONCURRENT_STREAMS (capped by Limits.max_concurrent), and times each call out on its own: an expired call is reset with RST_STREAM(CANCEL) and answers "timeout", and the late response's header block is still decoded so HPACK state stays in step for the rest of the connection. A Response carries status, headers, body and trailers. Calls are never retried; a stream refused by GOAWAY says so, since only those are safe to repeat elsewhere.

For one stream at a time without the reader task: client_preface, then client_request_msg sent to the writer and client_read_response for that stream. Requests are validated rather than cleaned up: header names must be lowercase tokens, values may not contain CR or LF, and connection-specific fields (connection, host, transfer-encoding, upgrade, te other than trailers) are refused. Responses that RFC 9113 calls malformed are refused too: no :status, a pseudo-header in trailers, a content-length the body does not match, PUSH_PROMISE (the client disables push).

On the server side, response_trailers_msg builds a response that ends in trailers (gRPC's status travels in them): the body goes out as DATA without END_STREAM, then the trailers as a final HEADERS frame.

The client's peer SETTINGS handling also fixed one server bug: SETTINGS_HEADER_TABLE_SIZE bounds the peer's decoder (RFC 7541 §4.2), and resizing our own decoder table to it made a peer that advertised a small table unable to use the dynamic table it was entitled to. And a writer that can no longer write now keeps taking messages until its channel closes, so a producer is never left blocked behind it.

Known gaps#

PRIORITY is validated but not acted on: it is deprecated in RFC 9113 §5.3.2, so ignoring the prioritisation is conformant, but a malformed frame is still rejected as the connection error it is (§6.3) rather than waved through to desync the stream. No browser has been run against the TLS path yet — the machinery is there and tested against slang's own client, but a real browser is different evidence.

strings#

Search, trim, case, split and join on str. The package cannot be named str because that token is the type — the same reason Go calls its own strings.

import "strings";

strings.find("hello world", "world");   // 6, or -1
strings.rfind("a/b/c", "/");            // 3
strings.contains("hello", "ell");
strings.has_prefix("hello", "he");
strings.has_suffix("hello", "lo");
strings.count("a,b,c", ",");            // 2

strings.trim("  hi \r\n");              // "hi" (space/tab/CR/LF)
strings.trim_start(s); strings.trim_end(s);
strings.to_upper("hi"); strings.to_lower("HI");   // ASCII only

strings.slice("hello", 1, 3);           // "el"
strings.slice("hello", -3, 5);          // "llo" — negative counts back
strings.repeat("ab", 3);                // "ababab"
strings.replace("a,b,c", ",", " | ");

strings.split("a,b,,c", ",");           // ["a", "b", "", "c"]
strings.join(parts, ",");               // the inverse of split
strings.join_bytes(parts, b",");        // the same for [bytes]: sized once, copied once

strings.from_float(0.1 + 0.2);          // "0.30000000000000004"

strings.escape_html("<a href='x'>");    // "&lt;a href=&#39;x&#39;&gt;"
strings.escape_js("it's </script>");    // "it\u0027s \u003c/script\u003e"

escape_html writes & < > " ' as character references, which is safe in text and in a quoted attribute of either quote. escape_js makes the inside of a JavaScript string literal of any quote (', " or a backquote), safe inside <script> and inside an attribute. Both return the input itself, with no allocation, when nothing needs escaping. template escapes every value through them.

from_float returns the shortest text that to_float reads back as exactly the same number, as to_str, println, inspect and json.encode now do too (they used six significant digits, so to_str(123456789.125) was "1.23457e+08"). The difference is the names: from_float writes NaN and the infinities as NaN, Infinity and -Infinity, where to_str writes nan, inf and -inf.

This is a compiler-provided native package, and it has to be: str supports len, + and == and nothing else — it cannot be indexed or sliced — so none of it could be written in slang without converting to bytes and back on every call. byteutil covers the bytes side.

Three behaviours worth knowing:

  • Indices are byte offsets and the case operations are ASCII-only. str is UTF-8 bytes, and these are byte operations, which is what protocol text (header names, methods, config keys) wants. For text people type, use unicode: characters, case folding and normalisation.
  • slice clamps rather than panics. Slicing is how you narrow a string you just searched, and a find that returned -1 on the line above should not turn the next line into a crash. A negative index counts from the end.
  • split and join are exact inverses. Adjacent separators produce empty elements, so the result always has count(s, sep) + 1 elements and join(split(s, sep), sep) == s for any non-empty separator. An empty separator splits into single bytes.

unicode#

Text as people write it: UTF-8 validation, characters rather than bytes, case-insensitive comparison and canonical normalisation, from the Unicode 18.0 tables. Use it for usernames, display names, length limits and search; strings stays the tool for protocol text.

import "unicode";

unicode.valid(s);                       // strict UTF-8: no overlongs or surrogates
unicode.valid_bytes(body);              // the same check on bytes, before to_str
unicode.count("café 😀");               // 6 characters (len is 10 bytes)
unicode.truncate(bio, 160);             // first 160 characters, none split
unicode.runes("aé");                    // [97, 233]: code points
unicode.encode(0x1F600) ?? "";          // "😀"; none for a surrogate or U+0000

unicode.fold("Straße");                 // "strasse": full case folding
unicode.equal_fold("STRASSE", "straße"); // true
unicode.nfc(name);                      // composed form: store and compare this
unicode.nfd(name);                      // decomposed form
unicode.is_nfc(name);                   // true when nfc(name) == name

The same text can arrive as different bytes: é is one code point (U+00E9) from most keyboards and two (e + U+0301) from some, so two strings that both display as café can compare unequal. Normalise with nfc at the boundary, before storing or comparing. For a case-insensitive key, such as a username that must be unique however it is capitalised, store unicode.nfc(unicode.fold(unicode.nfd(name))), or compare with equal_fold, which implements the Standard's canonical caseless match (D145) and so treats "STRASSE", "straße" and a decomposed spelling as equal.

Invalid UTF-8 is repaired, not guessed at. The checks report it (valid is false, is_nfc is false); every transform (fold, nfc, nfd, truncate, runes) replaces each malformed sequence with U+FFFD, as the Standard recommends, so its output is always valid UTF-8. Code that must reject bad input rather than repair it calls valid first.

Cost. Native, reading each str in place. valid, valid_bytes, count, is_nfc and equal_fold allocate nothing on the GC heap, and fold, nfc, nfd and truncate return their input itself, with no allocation, when it is already in the requested form, which covers ASCII and nearly all real text. Long runs of combining marks are reordered in linear time, so hostile input cannot make normalisation quadratic. The tables add about 125 KB to a program that imports the package, and nothing to one that does not.

Out of scope: compatibility normalisation (NFKC/NFKD, which maps fi to fi and full-width letters to ASCII), grapheme clusters (a flag emoji or an accented letter can be more than one code point, and truncate counts code points), and locale-specific casing (Turkish dotted and dotless i).

The tables come from the Unicode data files under the Unicode License v3. Its notice is compiled into every program that imports unicode, so the binary carries it wherever it goes; see THIRD_PARTY.md and LICENSES/Unicode-3.0.txt.

encoding#

Hex, base64, base64url, base32, percent-encoding and query strings — the ways arbitrary bytes travel through a channel that only carries text. The package also converts binary64 floats to and from little-endian bytes for binary protocols such as BSON.

import "encoding";

encoding.hex_encode(crypto.sha256(b"abc"));   // "ba7816bf8f01cfea..."
encoding.base64_encode(b"aladdin:opensesame");// HTTP Basic credentials
encoding.base64url_encode(sig);               // a JWT segment: -_ alphabet, no padding
encoding.url_encode("a b&c");                 // "a%20b%26c"
encoding.form_encode("a b&c");                // "a+b%26c"
encoding.float64_to_le(1.5);                   // exact IEEE-754 bytes, low byte first
encoding.json_tape("{\"a\":[1,2]}");        // ok([5,5,1,0,11, 7,2,0,2,3, 4,5,2,5,10, ...]): see below
encoding.xor_mask(head, payload, key);         // head ++ (payload XOR key, repeated): a WebSocket frame
encoding.base32_encode(secret);               // "JBSWY3DPEHPK3PXP" -- a TOTP secret
encoding.base32_decode("jbswy3dpehpk3pxp");   // either case, padded or not
encoding.crockford_encode(id_bytes);          // "7ZQ4M1X0" -- a code people read aloud
encoding.crockford_decode("7zq-4ml-xo");      // hyphens skipped, l read as 1, o as 0

let q = "/search?q=hello+world&page=2";
guard let term = encoding.query_get(q, "q") else { return; }   // "hello world"
encoding.query_keys(q);                                        // ["q", "page"]
FunctionSignature
encoding.hex_encode(b: bytes) -> str
encoding.hex_decode(s: str) -> result[bytes, str]
encoding.base64_encode(b: bytes) -> str
encoding.base64_decode(s: str) -> result[bytes, str]
encoding.base64url_encode(b: bytes) -> str
encoding.base64url_decode(s: str) -> result[bytes, str]
encoding.url_encode(s: str) -> str
encoding.url_decode(s: str) -> result[str, str]
encoding.form_encode(s: str) -> str
encoding.form_decode(s: str) -> result[str, str]
encoding.query_get(url: str, key: str) -> opt[str]
encoding.query_keys(url: str) -> [str]
encoding.float64_to_le(value: float) -> bytes
encoding.float64_from_le(bytes: bytes) -> result[float, str]
encoding.json_tape(text: str) -> result[[int], str]
encoding.xor_mask(head: bytes, data: bytes, key: bytes) -> bytes
encoding.base32_encode(b: bytes) -> str
encoding.base32_decode(s: str) -> result[bytes, str]
encoding.base32hex_encode(b: bytes) -> str
encoding.base32hex_decode(s: str) -> result[bytes, str]
encoding.crockford_encode(b: bytes) -> str
encoding.crockford_decode(s: str) -> result[bytes, str]
encoding.csv_record(data: bytes, from: int, sep: int, quote: int, flags: int) -> [int]

Like regex, strings and os — and unlike crypto and sql — this is pure computation, so importing it adds no link flag.

Five things worth knowing:

  • Encoders are infallible; decoders are not. Any byte string has a hex form, so hex_encode returns a bare str. Decoding takes input the program did not produce — a query string, a header, a token — so every decoder returns result[_, str], and the message names the byte offset it gave up at. "invalid base64" about a 400-character token is not a diagnosis.
  • url_* and form_* differ only in +, and that is exactly why they have separate names. In application/x-www-form-urlencoded a space is +; in a URI it is %20. Decoding a form body with url_decode leaves literal + where every space belongs, and nothing reports it — the failure surfaces later as a lookup that does not match. One function with a flag would make that the default mistake.
  • %00 is an error, not a truncation. url_decode and form_decode return str, which is NUL-terminated, so a decoded zero byte would silently cut the value short. They refuse it and say so. hex_decode and base64_decode return bytes, which carries an explicit length, so a zero byte there is ordinary data and round-trips exactly.
  • Float bytes are exact and little-endian. float64_to_le and float64_from_le preserve the IEEE-754 binary64 bit pattern, including signed zero and NaN payload bits. float64_from_le accepts exactly eight bytes and returns an error for any other length.
  • query_get is opt, and query_keys is a list. A missing parameter is absent data, not bad data, so it is opt[str] — the README rule above. Keys come back as a list rather than a map because a query may legally repeat a key and a map would have to drop one, the same reason os.environ is a list. query_get takes the first value; a bare ?debug is present with an empty value, not absent.
  • json_tape is where things are, not a copy of them. It validates a JSON document (RFC 8259, nesting up to 512) in one C pass and returns five ints per value, in document order: kind (0 null, 1 bool, 2 number, 3 string, 4 list, 5 object, 7 key; plus 8 for a string or key holding an escape), the entry after the value's subtree, its child count, and its byte span (a string's inside, between the quotes). A reader walks it, skips a subtree in one step, and materialises only the strings it reads (strings.from_bytes over the span). template reads its data this way; use json.decode when the shape is known.
  • base32 decodes leniently and strictly at once. base32_decode takes either case, with or without = padding, because TOTP secrets arrive in every combination. It refuses a tail of 1, 3 or 6 characters, which holds no whole byte, and a tail whose leftover bits are not zero. That input is a mistyped or truncated secret, and decoding it anyway would yield a different key that fails later and less clearly. Python's decoder accepts MZ======; this one does not. base32hex (RFC 4648 §7) sorts in the same order as the bytes it encodes. Crockford's alphabet leaves out I, L, O and U so a code read aloud cannot be misheard; its decoder reads I and L as 1 and O as 0, and skips hyphens. Strip spaces from a secret shown in groups before decoding it.

Percent-escapes are emitted uppercase (RFC 3986 §2.1) and hex digests lowercase (what sha256sum, git and every API that returns one use). Both decoders accept either case.

httpc#

An HTTP/1.1 client — the mirror of http, which serves. Speaks http:// and https://, follows redirects, and decodes chunked responses.

import "httpc";
import "time";

let dl = until_of(time.mono() + 5000000000);   // 5s for the whole request

let r = httpc.get("https://api.example.com/users?id=1", dl);
guard let resp = r else let e = err_of(r) {
    log.error("request failed: " + e);
    return;
}
println(to_str(resp.status) + " " + to_str(len(resp.body)) + " bytes");

// a POST, and a header the request owns
let req = httpc.new_request("POST", "https://api.example.com/users");
req.headers["Authorization"] = "Bearer " + token;
req.body = to_bytes(payload);
let r2 = httpc.send(req, dl);
FunctionSignature
httpc.get(url: str, deadline: until) -> result[Response, str]
httpc.head(url: str, deadline: until) -> result[Response, str]
httpc.post(url: str, content_type: str, body: bytes, deadline: until) -> result[Response, str]
httpc.send(req: Request, deadline: until) -> result[Response, str]
httpc.new_request(method: str, url: str) -> Request
httpc.header(r: Response, name: str) -> opt[str]
httpc.parse_url(url: str) -> result[Url, str]
httpc.new_client() -> Client
httpc.client_get / client_head(c: Client, url: str, deadline: until) -> result[Response, str]
httpc.client_post(c: Client, url: str, content_type: str, body: bytes, deadline: until) -> result[Response, str]
httpc.client_send(c: Client, req: Request, deadline: until) -> result[Response, str]
httpc.idle_count(c: Client) -> int
httpc.close_idle(c: Client)
httpc.enable_cookies / clear_cookies(c: Client)
httpc.set_cookie(c: Client, url: str, set_cookie_line: str)
httpc.cookies(c: Client, url: str) -> [Cookie]
httpc.parse_cookie_date(s: str) -> int (unix ns, or -1)

Request carries method, url, headers, body, max_redirects (default 5, 0 disables following) and ca_path ("" = the system trust store). Response carries status, status_text, headers, body and url — the last being the URL that actually answered, which after a redirect is not the one you asked for.

The deadline covers the whole request, connecting included: a host that never answers the SYN fails at the deadline, over plain TCP and TLS alike.

Separate from http rather than folded into it because http imports only byteutil, while a client necessarily imports net — and for a TLS request that drags -lssl/-lcrypto onto the link line of every program that merely wanted to serve HTTP.

Five things worth knowing:

  • A 404 is a Response, not an err. The result is about whether the exchange happened — DNS, connect, TLS, framing. A server that answers "no" answered. Collapsing the two would make a 404 and a connection refusal indistinguishable at the call site, and they need different handling.
  • Certificates are verified, and there is no flag to stop that. Verified against expired, self-signed and wrong.host on badssl.com: all three are refused, a valid one is accepted. For an internal service signed by a private CA, set ca_path to that bundle — the answer is a different trust anchor, never a disabled check.
  • Credentials do not survive a cross-origin redirect. Authorization, Cookie and Proxy-Authorization are dropped when the scheme, host or port changes, because the server that sent the Location chose where it points, and that is exactly how a token gets exfiltrated. Same-origin redirects keep them.
  • Redirect method rules follow browsers, not the RFC's original wording. 303 always becomes GET; 301 and 302 after a POST also become GET and drop the body, which is what every browser and curl do and therefore what servers expect. 307 and 308 exist to preserve the method, so they do. A redirect loop stops at max_redirects and hands back the last 3xx rather than spinning.
  • Everything a server can make you allocate has a ceiling — 64 KiB of headers, 32 MiB of body, and a bounded chunk-size line. A client talks to servers it does not control, so a buffer sized on their say-so is a denial of service arriving through an ordinary call.
Connection pooling#

httpc.get and friends are one-shot: a connection per request, closed afterwards, with Connection: close sent so the server does not hold it open. A Client keeps idle connections and reuses them — up to max_idle_per_host per origin (default 4), for idle_timeout nanoseconds (default 30s), and 64 across all origins.

let c = httpc.new_client();          // share one across tasks
let r = httpc.client_get(c, "https://api.example.com/v1/items", dl);

The one-shot functions ARE a client — one that keeps nothing — so framing, redirects, decompression and every security rule are one code path, and cannot drift between the two.

A Client is safe to share between tasks: the pool is behind a mutex. Its dials and reuses fields and httpc.idle_count(c) make its behaviour checkable rather than asserted.

  • Every pooled connection is probed before use. Servers close idle connections on their own timers (Node's default is 5 seconds), and a request written onto one fails indistinguishably from the server failing mid-request. The probe is net.idle_alive — one non-blocking MSG_PEEK, no latency, nothing consumed.
  • A dropped request is retried once, and only if it is idempotent. If a reused connection dies before a single response byte arrives, GET/HEAD/PUT/DELETE/OPTIONS/TRACE retry on a fresh connection. POST does not: it may already have been acted on, and sending it twice could charge a card twice. The probe is what protects a POST; the retry is the backstop for the race between probe and write.
  • The pool key includes ca_path. A connection verified against one trust anchor is never handed to a request that asked for another — tested over real TLS: the second request, demanding a different CA, fails verification instead of riding the verified connection.
  • Reuse requires a clean end: HTTP/1.1, no Connection: close, a framed body read exactly, and nothing left over. A body delimited by the connection closing is never reused.

Client operations come in two equivalent forms: handle-first package functions — httpc.client_get(c, url, dl), the same idiom as sql.exec(db, ...) — and methods, c.get(url, dl). The methods are get, head, post, send, idle_count, close_idle, enable_cookies, clear_cookies, set_cookie and cookies on Client, and header on Response. c.get and the one-shot httpc.get share a name; the method is the pooled client's, the function is not.

Decompression#

Requests carry Accept-Encoding: gzip, deflate and responses are decoded transparently — unless the caller set Accept-Encoding themselves, in which case the body comes back exactly as sent. A caller who asked for gzip wants the gzip (to proxy it, to store it), and decompressing behind their back would hand them something else.

  • The decoded size is held to the same 32 MiB ceiling as a plain body. Without that the wire limit would mean nothing: 32 MiB of gzip holds tens of gigabytes.
  • deflate is tried as zlib (what RFC 9110 says it means) and then as raw DEFLATE (what a real share of servers send under that name).
  • After decoding, Content-Encoding and Content-Length are removed: both describe the bytes that crossed the wire, not the body in hand.
  • An encoding the client did not ask for and cannot read is left alone, header included — visible, not silent.
Cookies#

RFC 6265, with RFC 6265bis's rules for Secure and the __Secure- / __Host- prefixes. Off by default — the opposite of a browser, on purpose:

let c = httpc.new_client();
httpc.enable_cookies(c);             // this Client now acts as ONE identity

A browser's jar belongs to one person. A server's Client is usually shared, one per process, used on behalf of every user it serves, and a jar there would store user A's session cookie and send it on user B's request. Go's http.Client makes the same call. Turn the jar on for a Client that represents one identity: a scraper, a test driver, an integration with a login-based API.

  • Cookies are applied per redirect hop. A cookie set by a 302 reaches the page it points to, which is how nearly every login works; a redirect to another host carries that host's cookies, not the first one's.
  • Refused: a Secure cookie from http:// (a network attacker could otherwise overwrite an https session); a Domain the host does not belong to; a bare single-label Domain such as com; a Domain that is part of an IP address; __Host- without Secure, or with a Domain, or with a path other than /; a line with no =; anything over 4096 bytes. Secure cookies are only ever sent over https.
  • Scoping: a cookie set without Domain goes back to exactly that host (127.0.0.1 and localhost are different scopes); Path=/p matches /p and /p/x but not /pathx. Longer paths are sent first.
  • Expiry: Max-Age wins over Expires whatever order they arrive in, and a past date or Max-Age=0 deletes. Dates are parsed with RFC 6265's lenient algorithm, since servers send RFC 1123, RFC 850 with two-digit years and asctime alike; httpc.parse_cookie_date exposes it.
  • Limits: 50 cookies per domain and 3000 in total, evicting the oldest, so a server flooding the jar pushes out its own cookies first.
  • Response.set_cookies holds each Set-Cookie line separately. Use it rather than headers["set-cookie"]: repeated headers are joined with ", ", and an Expires date contains a comma, so two joined lines cannot be split apart again.

No public-suffix list is applied, and the consequence is stated rather than left to be discovered: a response from a.example.co.uk may set a cookie for Domain=co.uk, and this jar will then send it to every *.co.uk host. Nothing in slang ships a suffix list, and an embedded copy goes stale. The bare-TLD rule stops Domain=com; it does not stop that case.

Not done, deliberately: no HTTP/2 client, no multipart bodies, no proxy support, no public-suffix list.

websocket#

A WebSocket client (RFC 6455) over ws:// and wss://, with permessage-deflate (RFC 7692) when asked for. In the Autobahn TestSuite's client run (tests/live/websocket, 517 cases), 440 cases are OK, 3 INFORMATIONAL and 2 NON-STRICT, with no failures. The NON-STRICT two are invalid UTF-8 inside one frame, caught when the frame is whole rather than byte by byte. The 72 UNIMPLEMENTED are window-size variants of permessage-deflate this client does not offer.

import "websocket";

let o = websocket.options();
o.protocols = ["v2.chat"];                       // offered; Conn.protocol is the server's pick
o.headers = ["Authorization: Bearer " + token];
o.ping_every = 20000000000;                      // keepalive: a ping every 20 s
o.compress = true;                               // permessage-deflate
let r = websocket.dial_with("wss://example.com/live", o, deadline);
guard let ws = r else let e = err_of(r) { panic(e); }

websocket.send_text(ws, "{\"subscribe\":\"prices\"}", deadline);
websocket.send_binary(ws, frame, deadline);
let mr = websocket.recv(ws, deadline);           // result[opt[Message], str]; none: the deadline passed
guard let got = mr else let e = err_of(mr) { panic(e); }
guard let m = got else { return; }
if m.kind == websocket.Kind.Text { handle(websocket.text(m)); }
websocket.close(ws, 1000, "bye", deadline);      // the close handshake

recv returns Text, Binary or Close messages, reassembled from their fragments. It answers pings and consumes pongs as they arrive, and answers the server's close before returning it. A deadline that passes loses nothing: the next recv carries on, even partway through a frame. One task reads at a time; any number may send, and a send is one whole frame written under a lock, so frames from different tasks never interleave.

What it refuses. Masked or reserved-bit frames, unknown opcodes, fragmented or oversized control frames, lengths not in their shortest form, continuations with nothing to continue, text that is not UTF-8 (checked per fragment as it arrives), close codes a peer may not send, and messages over max_message (32 MiB by default, counted after inflation, so a compression bomb is cut off). Each fails the connection with the matching close code (1002, 1007, 1009). In the handshake: a wrong Sec-WebSocket-Accept, a subprotocol or extension that was not offered, and redirects. A redirect would send the handshake, and any credentials in it, somewhere the caller did not name.

Keepalive. With ping_every set, a ping goes out each interval and a connection whose ping goes a whole interval unanswered is closed; recv then reports why. Pongs arrive through recv, so a connection nobody reads cannot keep itself alive.

Compression is offered without context takeover in either direction. The server must accept server_no_context_takeover, so each message it sends inflates on its own. Each message this client sends is compressed on its own too, as a sync-flushed stream with no final block (compress.deflate_raw_flush), which a server keeping one decompressor for the whole connection reads correctly. No 32 KiB window per direction stays alive between messages. A message is sent compressed only when that makes it smaller.

Text with U+0000. A str ends at its first zero byte, so websocket.text(m) stops there. m.data holds every byte, and send_text_bytes sends one.

Frames are masked with keys drawn from a 4 KiB pool of crypto.rand bytes, refilled as it empties. A frame is built in one allocation by encoding.xor_mask. Large frames are read in chunks and joined once.

compress#

gzip, zlib and raw DEFLATE, over zlib (-lz, added only when a program imports compress).

import "compress";

let gr = compress.gzip(body);
guard let gz = gr else let e = err_of(gr) { log.error(e); return; }

// decompression ALWAYS takes a ceiling -- see below
let ur = compress.gunzip(payload, 4194304);        // 4 MiB
guard let plain = ur else let e = err_of(ur) {
    log.warn("rejected: " + e);                     // names the limit
    return;
}
FunctionSignature
compress.gzip(b: bytes) -> result[bytes, str]
compress.gzip_level(b: bytes, level: int) -> result[bytes, str]
compress.gunzip(b: bytes, max_out: int) -> result[bytes, str]
compress.deflate(b: bytes) -> result[bytes, str]
compress.inflate(b: bytes, max_out: int) -> result[bytes, str]
compress.deflate_raw(b: bytes) -> result[bytes, str]
compress.deflate_raw_flush(b: bytes) -> result[bytes, str] (ends in a sync flush, no final block: RFC 7692)
compress.inflate_raw(b: bytes, max_out: int) -> result[bytes, str]

Decompression takes a mandatory output limit. max_out is a required argument, not an optional one with a generous default. The expansion ratio is unbounded — 65 KB of gzip holds 64 MB of output, and that ratio goes much further — so a program that decompresses anything it did not itself produce is one hostile input away from the OOM killer. The ceiling is enforced before the allocation that would cross it, not by inspecting the result afterwards: rejecting a 64 MB bomb was measured at 3.9 MB peak RSS against a 0.86 MB baseline. A default here would be a number nobody chose, applied at every call site that never thought about it.

Three containers, because HTTP needs all three. They are the same compressed bits under different headers: gzip (RFC 1952) is what servers send; zlib (RFC 1950) is what the deflate content-coding is supposed to mean; raw (RFC 1951, no header) is what the servers that get it wrong send instead — which is why inflate_raw exists rather than being a purist's omission. They are not interchangeable, and each decoder says so rather than producing garbage.

Backed by zlib rather than written here, unlike regex. That package was written in-house because a backtracking engine has a catastrophic input class and being immune to it by construction was the point. DEFLATE has no equivalent argument — what it has is thirty years of hostile input and a reference implementation on every platform slang targets. A hand-written inflate would be a memory-safety surface with no upside, since every bug in one is a buffer overrun driven by attacker-controlled input.

math#

Constants, float functions, rounding, and checked integer arithmetic, compiled to C's <math.h> and compiler builtins: math.sqrt(x) is the instruction, not a call, and no function here is a safepoint.

import "math";

let r = math.sqrt(2.0);                  // 1.4142135623730951
let a = math.atan2(1.0, 1.0) * 4.0;      // math.pi
let x = math.clamp(v, 0.0, 1.0);
let n = math.round_to(3.14159, 2);       // 3.14
let i = math.floor_int(f) ?? 0;          // none for NaN, inf, out of range

// One function for every numeric type: T is the arguments' type.
let b: u8 = 250;
let s = math.checked_add(b, 10);         // opt[u8]: none, 260 does not fit
let w = math.saturating_add(b, 10);      // u8 255
let m = math.min(b, 100);                // u8: a literal takes b's type
let total = math.checked_mul(price, qty) ?? 0;   // money: overflow is none

math.div_floor(-7, 2);                   // -4, where -7 / 2 is -3
math.rem_euclid(-7, 3);                  // 2, never negative
math.gcd(48, 18);                        // 6
math.isqrt(99);                          // 9
math.mul_div(a, b, c);                   // a * b / c with no overflow in between
math.popcount(b);                        // 6, on b's own width
math.format_fixed(math.pi, 4);           // "3.1416"

Every numeric argument of a call shares one type, T, so math.abs of an i32 is an i32, and math.checked_add of two u8s is an opt[u8]. T is what a + b of the arguments would be, except that a literal takes the other arguments' type (math.min(b, 100) is a u8) and must fit it. Float functions make an integer argument float; integer functions reject a float. One name covers all ten numeric types because slang has no overloading.

  • Constants: pi, tau, e, phi, sqrt2, sqrt3, ln2, ln10, log2e, log10e, inf, neg_inf, nan, max_float, min_normal, min_subnormal, epsilon, f32_max, f32_min_normal, f32_epsilon, max_int, min_int, and max_/min_ of each fixed-width integer.
  • Any number: abs, min, max, clamp, sign.
  • Floats: is_nan, is_inf, is_finite, is_normal, signbit, copysign, nextafter, nextup, nextdown, ulp, frexp_mantissa, frexp_exponent, ldexp, ilogb, float_bits/float_from_bits, f32_bits/f32_from_bits, total_cmp (a total order, NaN included), approx_eq(a, b, rel_tol, abs_tol).
  • Rounding: floor, ceil, trunc, round (half away from zero), round_even (half to even), round_to(x, decimals), round_to_multiple, fract; floor_int, ceil_int, round_int, trunc_int give an opt[int].
  • Powers and logs: sqrt, cbrt, hypot, pow, powi(x, n), fma, fmod, remainder, mod_euclid, exp, exp2, expm1, log, log2, log10, log1p, log_base.
  • Trigonometry: sin … atanh, atan2, to_radians, to_degrees, wrap_angle (into (-pi, pi]), sin_pi/cos_pi (exact at integers and halves).
  • Special: gamma, lgamma, erf, erfc, erf_inv, beta, factorial, binomial.
  • Interpolation: lerp, inverse_lerp, remap, smoothstep, smootherstep.
  • Integers: checked_add/sub/mul/div/rem/neg/abs/pow/shl (an opt), saturating_add/sub/mul, wrapping_add/sub/mul/neg/pow, div_floor, div_ceil, div_euclid, rem_euclid, abs_diff, midpoint, isqrt, ilog2, ilog10, is_pow2, next_pow2, align_up, align_down, gcd, lcm, mul_div, mul_hi.
  • Bits: popcount, leading_zeros, trailing_zeros, bit_len, rotate_left, rotate_right, swap_bytes, reverse_bits.
  • Formatting: format(x, digits) (significant digits), format_fixed(x, decimals), format_sci(x, digits). These are the only functions here that allocate; format_fixed rounds an exact tie to even, as C's printf does (format_fixed(2.5, 0) is "2").

Floats follow IEEE 754: NaN propagates (through min, max and clamp too), -0.0 is kept and orders below 0.0, and no float function panics. An integer operation that can fail returns an opt: overflow, a zero divisor, MIN / -1. div_floor, div_ceil, div_euclid and rem_euclid panic on a zero divisor as / does, isqrt on a negative argument, and align_up/align_down on an alignment that is not a power of two. Programs link libm (-lm) whether or not they import math; on macOS that is libSystem.

math.mul_mod(a, b, m) is (a * b) mod m with the product held at twice the width, so modular arithmetic near the type's limit cannot overflow; math/numtheory is built on it.

math/stats#

Statistics over lists of any numeric type, and streaming accumulators for numbers that never all exist at once. Written in slang over math.

import "math/stats";

let xs = [12.5, 9.0, 14.25, 11.0];
stats.mean(xs);                      // 11.6875
stats.median(xs);                    // 11.75
stats.stddev(xs);                    // sample (n - 1); pstddev is population
stats.quantile(xs, 0.9);             // R type 7 / NumPy's default
stats.correlation(xs, ys);           // Pearson's r; spearman for ranks
let fit = stats.linear_regression(xs, ys);   // fit.slope, fit.intercept, fit.r2, fit.at(x)
let h = stats.histogram(xs, 10);     // h.edges, h.counts

// Streaming, O(1) per value, mergeable across tasks:
let lat = stats.running();           // count, mean, variance, min, max
lat.add(elapsed_ms);
let p = stats.digest();              // quantiles of a stream (t-digest)
p.add(elapsed_ms);
p.quantile(0.99);

stats.normal(100.0, 15.0).cdf(130.0);        // 0.9772498680518208
stats.normal(0.0, 1.0).quantile(0.975);      // 1.9599639845400538
stats.poisson(3.0).pmf(5);
  • Center and spread: sum (compensated), product, mean, weighted_mean, geometric_mean, harmonic_mean, median, mode, min, max, spread, variance/pvariance, stddev/pstddev, mad, zscores, skewness, kurtosis (excess).
  • Order: quantile, percentile, quartiles, iqr, ranks; sorted_floats and quantile_sorted to sort once and ask many times.
  • Two lists: covariance, correlation, spearman, linear_regression.
  • Streaming: running() (Welford), ewma(alpha) and ewma_half_life(n), digest() (a merging t-digest: p99 and p999 stay accurate in about 100 centroids). Each has add, and running and digest merge.
  • Distributions: normal, log_normal, uniform, exponential (pdf, cdf, quantile), poisson and binomial (pmf, cdf).

A result with no value for its input -- the mean of no numbers, the variance of one, a correlation with a constant list -- is NaN, which propagates and tests true with math.is_nan; functions that return one of the elements (min, max, mode) return an opt of the element type. Sums are compensated (Neumaier) and variances are two-pass around the mean, so neither drifts on long or offset data: stats.sum([0.1, 0.2, 0.3]) is 0.6.

math/numtheory#

Modular arithmetic, primes and factorization on int, over math.mul_mod so a modulus near 2^62 is as safe as a small one.

import "math/numtheory";

numtheory.mod_pow(2, 100, 1000000007);       // 976371285
numtheory.mod_inv(3, 7) ?? 0;                // 5
numtheory.is_prime(9223372036854775783);     // true: exact for every int
numtheory.factorize(360);                    // [{p: 2, k: 3}, {p: 3, k: 2}, {p: 5, k: 1}]
numtheory.primes_below(30);                  // [2, 3, 5, 7, 11, 13, 17, 19, 23, 29]
numtheory.crt([2, 3, 2], [3, 5, 7]) ?? -1;   // 23

mod_pow, mod_mul, mod_inv, ext_gcd (a Bezout { g, x, y }), is_prime (deterministic Miller-Rabin with the seven bases proven for 64 bits), next_prime, prev_prime, primes_below (a sieve over the odd numbers), factorize (trial division, then Pollard's rho with Brent's cycle detection), divisors, totient, crt (moduli need not be coprime), binomial_mod (Lucas, for a prime modulus) and fibonacci (exact through F(92)). A modulus must be positive: a function given one <= 0 returns none, 0 or false rather than panicking, and no result is negative.

decimal#

Exact decimal arithmetic for money: up to 38 significant digits (SQL's DECIMAL(38)), an explicit scale, seven rounding modes, and overflow as none or an error, never a wrap.

import "decimal";

let r = decimal.parse("19.99");                // result[Decimal, str]
guard let price = r else let e = err_of(r) { panic(e); }
let total = decimal.mul(price, decimal.from_int(3)) ?? decimal.zero();   // 59.97
let rate = decimal.parse("0.075") ?? decimal.zero();
let vat = decimal.mul_round(total, rate, 2, decimal.Rounding.HalfEven)
          ?? decimal.zero();                    // 4.50
decimal.format(vat);                            // "4.50"
decimal.div(total, decimal.from_int(7), 2, decimal.Rounding.HalfEven);  // ok(8.57)
decimal.split(total, 2);                        // [29.99, 29.98]: no cent lost
decimal.allocate(total, [1, 2]);                // ok([19.99, 39.98])
decimal.to_units(total, 2, decimal.Rounding.HalfEven) ?? 0;   // 5997
decimal.eq(decimal.parse("1.5") ?? total, decimal.parse("1.50") ?? total);  // true

A Decimal is a coefficient and a scale (digits after the point, 0 to 38), kept as Postgres's numeric keeps it: "1.50" stays 1.50, add keeps the larger scale, mul adds the two. It is a value (five base-10^9 limbs), so arithmetic allocates nothing; only format, split and allocate build something on the heap.

  • Exact unless you ask. add, sub and mul are exact and return none when the result needs more than 38 digits (or mul, a scale past 38: use mul_round). mul_round, div and round round to the scale you give, by the Rounding you give: HalfEven (banker's, the usual choice for money), HalfUp, HalfDown, Down and Up (toward and away from zero), Floor, Ceiling. div is an error for a zero divisor.
  • Parsing is strict, like to_int: an optional sign, digits, an optional fraction and exponent ("1.5e3"). No spaces, separators, leading or trailing point, NaN or infinity. parse_bytes(b, lo, hi) reads a number inside a buffer without making a str. from_float goes through the float's shortest text, so 0.1 is 0.1, not 0.1000000000000000055511151231257827.
  • Comparing: cmp, eq, lt, le, gt, ge, min, max compare values, so 1.5 equals 1.50. Structs have no ==. Use normalize (strip trailing zeros) before a Decimal becomes a map key.
  • Splitting money: split(d, n) and allocate(d, weights) divide an amount at its own scale so the parts add up to it exactly. The leftover smallest units go to the parts with the largest remainders, earlier ones first.
  • JSON and Postgres. A decimal.Decimal field is written by json.encode as a string holding its exact text ("19.90"), which is safe for JavaScript clients, whose numbers lose digits past 2^53. json.decode reads one from a string or a number, exactly, without a float in between. pg.arg_decimal(d) binds one for a numeric parameter, and pg.get_decimal(rows, r, c) reads a numeric or integer column at the scale Postgres reports. NaN and infinity are errors.

rand#

Seeded, non-cryptographic random numbers for tests, simulations, sampling and retry jitter. One seed fixes the whole sequence on every platform, so a test that draws random inputs fails the same way on CI and on a laptop.

import "rand";

let r = rand.new(42);                  // same seed, same sequence
rand.below(r, 10);                     // int in [0, 10)
rand.between(r, 1, 6);                 // int in [1, 6], ends included
rand.unit(r);                          // float in [0, 1)
rand.chance(r, 0.25);                  // true about one time in four
rand.shuffle(r, xs);                   // reorders xs in place
rand.pick(r, xs) ?? fallback;          // none when xs is empty

// retry number `attempt` (0 first): uniform in [0, min(cap, base * 2^attempt)]
time.sleep(rand.backoff(r, attempt, 100000000, 30000000000));
rand.jitter(r, 60000000000, 0.1);      // 60s, give or take 10%

The generator is xoshiro256**, seeded through splitmix64. The tests pin its exact outputs against the authors' C code, so a seed means the same sequence in every slang release. Draws are unbiased: below and between redraw rather than take a plain modulo, and between covers the full int range. below(r, 0) and between(r, 5, 1) panic, like an index out of range, since there is no value to return. backoff uses full jitter, which spreads clients that failed together across the whole interval, and saturates at cap rather than overflowing. An Rng is not locked: give each spawned task its own with rand.fork(r), which stays reproducible from the parent's seed. rand.from_os() seeds from the OS when runs should differ. Neither is for secrets: tokens, keys and salts come from crypto.rand, because this generator's outputs reveal its state.

sort#

Sorting lists in place with Go's sort engine shape, plus binary search over what is already sorted. ints, floats and strings sort by native comparison; by sorts any list of anything by a key function returning int, float or str; by_stable keeps equal keys in their original order; the search functions binary-search a sorted list and answer opt[int].

import "sort";

let xs = [5, 3, 9, 1, 3];
sort.ints(xs);                       // in place, ascending
let i = sort.search_ints(xs, 3) ?? -1;

let users = load_users();
sort.by(users, user_age);            // key fn -> int, float or str
sort.by_stable(users, user_name);    // equal keys keep original order

let names: [str] = [];
for u in users { push(names, u.name); }
sort.strings(names);

let fs = [3.5, 1.0, 0.0 / 0.0, 0.5];
sort.floats(fs);                     // NaN values sort first (Go's rule)

The unstable sorts allocate nothing; each runs an entry pass that answers already-sorted and fully-reversed lists in one scan, and the quicksort settles each duplicate group in one partition pass, so four-valued million-element lists sort in tens of milliseconds. by_stable merges with one scratch list of about n/2 elements.

uuid#

UUIDs per RFC 9562: make version 4 (random) and version 7 (time-ordered), check, parse and convert. An id is a plain str in canonical form (36 characters, lowercase hex, hyphens at 8, 13, 18 and 23), the form JSON, logs and Postgres's uuid text already use, so it crosses those boundaries unconverted and has exactly one spelling to compare or key a map by.

import "uuid";

let r = uuid.v7();
guard let id = r else let e = err_of(r) {
    panic(e);                          // only if the OS has no randomness
}
uuid.is_valid(id);                     // true: canonical form only
uuid.version(id) ?? 0;                 // 7
uuid.time_ms(id) ?? 0;                 // unix ms it was made

let p = uuid.parse("{0190F4E2-7C1A-7B3E-9F21-6C0D5A4E8B17}");
// ok("0190f4e2-7c1a-7b3e-9f21-6c0d5a4e8b17"); also urn:uuid: and 32 hex
let bad = uuid.parse("0190f4e2-7c1a");
// err("uuid: want 36 characters (8-4-4-4-12 hex digits), got 13")

Use v7 for anything stored, primary keys above all: its first 48 bits are the creation time in milliseconds, so ids sort by time and new rows append to an index instead of landing on random pages. Within one millisecond the order is random, because v7 keeps no state and any task may call it without a lock. v4 is 122 random bits and reveals nothing, not even when it was made. Both draw from crypto.rand, and return an error rather than an id from a weaker source. v7_at(unix_ms) stamps a given time (tests, ids that carry an event's time); to_binary and from_binary convert to and from the 16-byte form; uuid.NIL and uuid.MAX are the RFC's two special values. is_valid accepts any version and variant, so ids from Postgres's gen_random_uuid() or other services pass.

csv#

CSV (RFC 4180): read a whole document or stream a large one, and write.

import "csv";

let r = csv.parse("name,qty\n\"Smith, J\",3\n");   // result[[[str]], str]
guard let rows = r else let e = err_of(r) { panic(e); }
csv.to_maps(rows);                                     // the header row as keys: [map[str]str]

let o = csv.options();
o.sep = 9;                                             // TSV
o.comment = 35;                                        // skip lines starting with #
csv.parse_with(text, o);

// streaming: feed what you read, take the records that are whole
let rd = csv.new_reader(csv.options());
csv.feed(rd, chunk);                                   // as often as needed
let nr = csv.next(rd);                                 // result[opt[[str]], str]; none: feed more
csv.end(rd);                                           // then next() returns what remains

csv.format(rows);                                      // CRLF, quoted only where needed
let w = csv.write_options();
w.spreadsheet_safe = true;                             // '=1+1 stays text in a spreadsheet
csv.format_with(rows, w);

Quoted fields may hold separators, doubled quotes and line breaks. Line endings are CRLF or LF, and empty lines are skipped. Errors name the line: csv: line 12: text after a closing quote.

Strictness. A quote inside an unquoted field, or text after a closing quote, is refused unless lazy_quotes is set, which reads them as data, as Go's LazyQuotes does. By default every record must be as wide as the first; set fields_per_record to a fixed width, or -1 to allow any.

Limits. The input decides how big things get, so max_field (1 MiB), max_record (16 MiB) and max_fields (4096) bound it. A record still incomplete past max_record is refused without buffering more.

Streaming keeps one record's worth of input. Fed chunks are joined only when a record needs them, and a record far larger than one chunk is rescanned O(log n) times, not once per chunk. There are no callbacks: slang has no closures, so the caller does the reading and the reader only parses.

Writing. A field is quoted when it holds the separator, the quote or a line break, or begins with a space or tab. A record of one empty field is written as "", so it does not read back as an empty line. spreadsheet_safe prefixes fields beginning with =, +, -, @, tab or CR with '. That keeps a spreadsheet from running them as formulas (CSV injection), but it changes the data, so use it only for files meant for spreadsheets.

Cost. The byte scan is native (encoding.csv_record); the package assembles fields. On an 8 MB, 120,000-row file, streaming took about 105 ms against about 33 ms for Go's encoding/csv record loop; the rest is one string per field. parse holds every field at once, which is slower again on large inputs (about 650 ms here), so stream big files.

byteutil#

Search, trim, and split on the bytes type — no new syntax. The package cannot be named bytes because that token is the type.

import "byteutil";

byteutil.find(b"hello", 0, 108);     // 2, or -1
byteutil.has_prefix(b"hello", b"he");
byteutil.has_suffix(b"hello", b"lo");
byteutil.trim(b"  hi\r\n");          // b"hi" (space/tab/CR/LF)
byteutil.split(b"a,b", 44);          // [b"a", b"b"]

flags#

Command-line options, parsed out of proc.args(). A slang-source stdlib package. Declare the flags on a Set, parse, read the values back:

import "flags";
import "proc";

let fs = flags.new("greet", "print a greeting");
fs.add_str("name", "n", "world", "who to greet");
fs.add_int("count", "c", 1, "how many times");
fs.add_bool("loud", "l", false, "shout");
fs.add_list("tag", "t", "label to attach (repeatable)");

let p = fs.parse_or_exit(proc.args());

let greeting = "hello " + p.get_str("name");
if p.get_bool("loud") { greeting = greeting + "!"; }
let i = 0;
while i < p.get_int("count") { println(greeting); i = i + 1; }
for file in p.args() { println("file: " + file); }
$ greet -n bob -c 2 -l a.txt b.txt
hello bob!
hello bob!
file: a.txt
file: b.txt
$ greet --help
Usage: greet [options] [args...]

print a greeting

Options:
  -n, --name <str>   who to greet (default: "world")
  -c, --count <int>  how many times (default: 1)
  -l, --loud         shout
  -t, --tag <str>    label to attach (repeatable)
  -h, --help         show this help
DeclareValue type
fs.add_str(name, short, default, usage)str
fs.add_int(name, short, default, usage)int
fs.add_float(name, short, default, usage)float
fs.add_bool(name, short, default, usage)bool
fs.add_list(name, short, usage)[str], one entry per occurrence
fs.require(name)the flag must appear
fs.set_takes("<file>...")positional synopsis on the usage line ("" for none)
fs.stop_at_first_arg()flags end at the first positional (subcommands)
Read
p.get_str(name) / get_int / get_float / get_bool / get_listthe value, or the default
p.is_set(name)did the command line set it?
p.args()positional arguments, in order
p.wants_help()was -h / --help given?
fs.parse(args)result[Parsed, str]
fs.parse_or_exit(args)Parsed; on a usage error prints to stderr and exits 2, on --help prints the usage and exits 0
fs.usage()the help text as a str

What a command line may contain:

  • Long flags: --name bob or --name=bob. Short flags: -n bob, -nbob or -n=bob. Give "" for a flag with no short form.
  • Bools take no value: --loud sets true, --loud=false or --no-loud sets false. Short bools combine, -lv is -l -v, and a value flag may end a cluster: -lc5.
  • Positional arguments may be mixed with flags. -- ends the flags, and a lone - is a positional argument (the usual "read stdin"). The last occurrence of a flag wins, except a list flag, which collects them.
  • A value flag takes the next token as its value even if it starts with -, so --offset -5 works. A positional argument that looks like a flag needs a -- before it.

parse takes the list exactly as proc.args() returns it and skips element 0, the program name. The positionals come back without it, so a subcommand parses what its parent left with the same call:

let global = flags.new("tool", "");
global.add_bool("verbose", "v", false, "chatty output");
global.stop_at_first_arg();                  // "push --force" is not ours
let g = global.parse_or_exit(proc.args());   // g.args() == ["push", "--force", "origin"]

let push = flags.new("push", "");
push.add_bool("force", "f", false, "overwrite");
let s = push.parse_or_exit(g.args());        // element 0, "push", is skipped

Two kinds of failure. What a user can type wrong (an unknown flag, a missing or malformed value, a required flag left out) comes back as an err from parse: bad data, so result[_, str]. What only a programmer can get wrong (a duplicate or empty flag name, a short name longer than one character, reading a flag that was never declared, get_int on a str flag) panics, because no input can cause it and it should fail the first time the program runs, not on the user's third flag.

Why declare, then read. Go's flag.String hands back a pointer that is filled in later. slang has no references to locals, so the values live on the Parsed result and are read by name. The declaring methods are add_str and so on because str, int, float and bool are type keywords.

Not supported: flags that take several values in one occurrence, --flag value1 value2; abbreviations of long names; environment-variable fallbacks. Set a default from proc.getenv yourself when you want one.

http#

HTTP/1.1 over link / wire / until / fault. Parse a request from bytes, or read from a connection into a caller-sized wire (the max request size). read takes the unconsumed prefix length and returns Incoming with leftover compacted to the front of the wire, so one connection can carry many requests. write serializes a Response through an arena; write_head answers a HEAD request with the head write would send -- Content-Length the length of the body GET would carry, as RFC 9110 has it -- and no body. A request keeps its header block as it arrived; header(req, name) looks it up case-insensitively. A body is framed by Content-Length or by Transfer-Encoding: chunked (chunk extensions ignored, trailers read and discarded). wants_close follows HTTP/1.1 keep-alive (and HTTP/1.0 close-by-default). A request with no header lines at all (GET / HTTP/1.0 and a blank line) is accepted.

read is the hot path, so it allocates only what the request it returns holds: the path, one copy of the header block, the body when there is one, and the Request/Incoming around them. The common methods (GET, HEAD, POST, PUT, PATCH, DELETE, OPTIONS) and both versions come back as literals, and wants_close decides from the header block in place. A plain GET costs 6 GC allocations; tests/run_tests.sh holds it to that.

Framing decides where a request ENDS, so it is a security boundary: if a proxy in front and this server frame the same bytes differently, the leftover is read as a second request the proxy never saw (request smuggling). read and parse therefore refuse, rather than guess at:

  • Transfer-Encoding together with Content-Length, or in an HTTP/1.0 request;
  • any coding but exactly chunked (no lists such as gzip, chunked);
  • a repeated Content-Length or Transfer-Encoding header;
  • a Content-Length longer than 18 digits, or not all digits;
  • chunk-size lines over 1KB, sizes over 15 hex digits, bare LFs, chunk data not followed by CRLF, or trailers over 8KB.
  • a method that is not an RFC 9110 token, so a quote, a backslash or a control byte such as ESC never reaches a handler or a log line inside the method.

After read returns an error, close the connection (the examples all return). The error means this server could not tell where the request ended, so it cannot tell where the next one begins either.

The writing side holds the same line. A CR or LF inside a header field would end the line early, so what follows would become a header of its own or the start of the body: response splitting, when a handler copies request data into a header. write and serialize therefore write every field (a with_header value, a raw with_headers line, location, the content type, the status text) with CR and LF turned into spaces, the rule Go's net/http applies. A clean field is written as it is, at no extra cost. httpc does the same for the method and the request headers it sends, and percent-encodes CR, LF, space and tab in the request target. http2's header encoder does the same for HTTP/2 fields.

Every response carries Date (RFC 9110 6.6.1): the current second, kept by the runtime for all threads and copied into the response with no clock read and no allocation; a handler's own Date is the one sent. A 1xx, 204 or 304 response is written with no Content-Length and no body, whatever the Response holds (8.6 forbids the header on 1xx and 204, and on a 304 unless it equals the 200's; body bytes there would be read as the start of the next response). http2 follows the same rules. A prebuilt static rendering (static_render) ends its header block with static_date_line(), whose date write_static refreshes on every send; a rendering built elsewhere gets the same by ending its headers with that line.

import "http";

fn serve(c: link) {
    let ra = arena_new(16384);
    let sa = arena_new(16384);
    let buf = ra.wire(8192);
    let filled = 0;
    while true {
        let rr = http.read(&mut c, buf, filled, until_never());
        guard let got = rr else { return; }
        let wr = http.write(&mut c, http.ok_text(got.req.path), &mut sa,
                            until_never());
        guard let _n = wr else { return; }
        sa.reset();
        if http.wants_close(got.req) { return; }
        filled = got.filled;
    }
}

See examples/httpd/ for a listener loop on this package.

In a program that asks proc.shutdown_requested(), every thread starts with SIGTERM/SIGINT blocked, and one dedicated thread receives them with sigwait — so no signal lands on a worker thread mid-request, and whichever thread a blocked accept() is parked on, the runtime wakes it. There's a narrow startup race inherent to this: a signal that arrives in the brief window before main() blocks the signals gets the OS's default disposition (immediate termination) instead of graceful handling, same as any signal-handling program.

template#

HTML templates that escape each value for where it lands, and plain-text templates for email bodies and other text. The syntax is Go's ({{ .field }}, if, range, with, define, pipelines), and so is the safety model: escaping is decided by the HTML around each action when the template is parsed, not by the caller remembering to escape.

import "template";

gc struct Item { name: str, url: str, price: int }
gc struct Page { title: str, items: [Item], user: str }

let r = template.parse("page",
    "<h1>{{ .title }}</h1>" +
    "<ul>{{ range $i, $it := .items }}" +
    "<li><a href=\"{{ $it.url }}\" title=\"{{ $it.name }}\">{{ add $i 1 }}. {{ $it.name }}</a></li>" +
    "{{ else }}<li>nothing yet</li>{{ end }}</ul>" +
    "<script>const user = {{ .user }};</script>");
guard let t = r else let e = err_of(r) { panic(e); }      // syntax and context errors, with line numbers
let html = template.render(t, page);                       // result[str, str]

template.add(t, "layout", "<html><body>{{ template \"page\" . }}</body></html>");
template.render_named(t, "layout", page);

let mail = template.parse_text("welcome", "Hi {{ .name }},\n{{ range .items }}- {{ .name }}\n{{ end }}");

Each action is escaped for its context:

WhereWhat the value becomes
text, quoted attribute& < > " ' as character references
URL attribute (href, src, action, …), at the startonly http, https, mailto, tel or a relative URL; anything else, javascript: above all, becomes about:invalid#blocked
later in a URLpercent-encoded (/search?q={{ .q }})
<script>, on* attributesa JavaScript literal (JSON), < > & ' as \u escapes
inside a JS stringescaped as string content, whatever the quotes
<style>, style attributesletters, digits and # % . , - only, else ZgotmplZ

Refused when the template is parsed: an action in a tag or attribute name, in an unquoted attribute value, in an HTML or JavaScript comment, and branches of an if or range that leave the HTML in different states ({{ if .x }}<a href="{{ end }}). {{ .html | raw }} writes trusted HTML unescaped in text, and escapes it anywhere else.

Data is any value json.encode takes: a struct's fields are .name, a list ranges, a map's keys are fields (ranged in sorted key order). A missing field is an error, not an empty string, so a typo does not render as silence; use if, with or default for data that may be absent.

Language. {{ if }}/{{ else if }}/{{ else }}, {{ with }}, {{ range }} (with $i, $x := and {{ else }} for empty), {{ $x := … }} and {{ $x = … }} with block scope, $ for the root, pipelines ({{ .name | upper }}), parentheses ({{ len (index .items 0).tags }}), {{/* comments */}} and {{-/-}} to trim whitespace, {{ define }} and {{ template "name" . }} (nested up to 64 deep). Functions: eq ne lt le gt ge, and or not (short-circuit, as Go's), len index default join upper lower trim add sub, print printf (%s %v %d %f %.2f %q %x %t), json urlquery raw. An unknown function or variable is a parse error.

Cost. Parsing and escape analysis happen once; a Template is safe to render from many tasks at once. Rendering encodes the data with json.encode and indexes it with encoding.json_tape (one C pass, no allocation per value), resolves variables to slots at parse time, and escapes with strings.escape_html/escape_js, which return their input unchanged when nothing needs escaping. A 100-row table page (15 KB, six actions a row) rendered in 0.6–0.8 ms, against 1.0–1.2 ms for Go's html/template on the same page, run alternately on one machine.

All packages#