Repository navigation
Releases
v0.7.3
This release changes how a delayed response is sent. It also reorganizes the documents around value orientation. The rules of the language have not changed.
std
std/http/server: a response fromhttp.server.Delayed(ms, r)is now sent directly by the runtime when its delay expires. Before, the expiry woke the task and the task sent the response. On the local benchmark (async, 24,000 connections), user-side cycles per request dropped by 46% and p99 went from 141 ms to 73 ms. The lower bound of the delay (never sent earlier thanms) is unchanged- With the change above, the order of sends under saturation changes. Before, the most recently expired response was sent first. Now responses are sent in the order they expire. The median is slightly later and the slow tail is shorter
std/http/server: the method, path and query of a request are cut at the positions of their ASCII delimiters. Before, each cut ran a UTF-8 checkstd/convert: formatting an integer as decimal text is faster.ParseIntgained a fast path for 18 digits or fewer
Compiler
- A large immutable value is passed to a parameter as a one-word address. This applies to immutable structs, unions and
T?over 8 words that do not contain a map. Before, the value was copied. The meaning of a program does not change - The wording of the const parameter diagnostic was fixed ("is a const seat" → "is a const parameter")
Documents
- Added a document on value orientation (docs/value-oriented.md; Japanese: docs/value-oriented.ja.md). It explains the idea at the center of Sober in one piece
- The opening of the README now explains value orientation. The pillars section was reorganized into "Properties derived from value orientation" and "Four pillars"
- The wording of language rule F-16 was fixed. The lambdas that cannot capture a
mutbinding are the ones that are not inlined (a lambda in a value position, and a lambda passed as a borrowed argument of a value call). A lambda inlined at its call site can capture one. The implementation already worked this way; the text had not kept up - Fixed descriptions that had gone stale: the paths a recursive type declaration may go through (including the contents of
T?and function types), SQL injection (withstd/pgthe SQL text must be a const, so it cannot be written), the shape ofstd/pool(it returns no reference and copies values), and the list of environment variables the runtime reads - The data race section of the security guide now explains domains
- In the Japanese standard library reference and CLI document, two headings were renamed to the settled terms
Measurements
Requests per second against v0.7.2, on a setup close to the official HttpArena one (EC2 c6a.metal, 64 server workers; median of 6 rounds):
| Test | vs v0.7.2 |
|---|---|
| async (32,000 connections) | +10.7% |
| baseline (4,096 connections) | +1.8% |
| limited-conn | +1.4% |
| json-comp | −1.2% |
For the fixed-rate latency tests (latency-10k, latency-1m, 8gbit), p99 and mean are unchanged within the round-to-round spread. The cause of the small drop in json-comp is under investigation.
v0.7.2
The release that cuts the fixed cost of one HTTP request. The fixed cost that grew from v0.6.6 to v0.7.1 is back below v0.6.6 (on the local pipelined benchmark, instructions per request are −27% and cycles −33% against v0.7.1). Domains no longer take more work to wake the next task when many tasks are waiting, so throughput no longer drops when there are more connections than PostgreSQL pool slots. std/pg receives a row in one pass and makes the statement cache and the pool's cleanup cheaper. The default CPU pinning of server workers goes back to the form used up to v0.7.0.
Language
- Newly accepted forms (check, build or both used to reject them; both now accept all of them):
- Reading a field or an element's field of the result of calling a function value (a field of function type, or a lambda bound to a local variable):
s := r.f(x); s.ok,xs := r.f(x); xs[0].ok,t := r.f(x)?; t.ok— check used to reject them - An if expression whose arms are untyped constants (
if v { 1 } else { 0 }) in a position that expects a byte or a named scalar (a return value, an argument, append, an element of a slice / map literal) — build used to reject them - The same if expression in a struct field (
T{b: if v { 1 } else { 0 }},U{p: if v { 80 } else { 443 }}) — check and build used to reject them - An untyped numeric const or a character literal in a byte field (
T{b: K, c: 'a'}) — check used to reject them delete(m, k)on amut mapparameter (in the body of a function that also has other parameters) — check used to reject it- A constant expression (
1 + 2) still cannot be placed in a byte context (check and build both reject it)
- Reading a field or an element's field of the result of calling a function value (a field of function type, or a lambda bound to a local variable):
std
std/compress/gzip/std/compress/flate(encoding with a record — the gzip responses of std/http/server): the fixed cost of estimating the recorded trees and of rebuilding their canonical codes is smaller (the output bytes are unchanged). On the local bench/httparena json-comp, cycles per request −1.7% (median over 8 code layouts, lower on all 8)std/pg:pg.ColJsonText(r, i) (string, error?)— reads the text of a json / jsonb column as a string.json.Put(mut o, pg.ColJsonText(row, i)?)puts it into a response as is (no need to writestring(pg.ColJson(row, i)?))std/pg: json / jsonb columns are also checked for UTF-8 when the row is received (the same treatment as text columns). A row with an invalid byte sequence is aProtocolerror when it is received (before, the error appeared only when the user converted the bytes to a string). Reading text / json columns as strings does not check them againstd/task:task.Ledger()gainslate(the running count of tasks that ran after waiting longer than the wait bound D) andmaxLate(the largest overrun, wait − D, in ns; reading it resets it to 0)std/task:task.Ledger()gainsparkShallow/parkDeep/parkDeepWin/parkSwap(running counts of connection tasks that parked shallow, parked holding the worker's stack area, parked deep at a shallow point because the area's window held data, and swapped stack areas with the worker just before parking shallow — the receive wait after a deep wait).parkDeepWincounts, for example, a connection that waits with a partial request in its receive regionstd/net: the accept loops ofnet.Servenow inheritmaxWait:too. Before, only the loops kept the default 100 ms, so under loads that reconnect often amaxWait:shorter than 100 ms had no effect (a loop stayed at the bottom of the run stack and the overdue tasks above it could not be rescued)std/time:time.Now/time.Mono/time.Sinceread the clock through the vDSO (__vdso_clock_gettime). Before, every call was a clock_gettime syscall (locally on WSL2, ≈300 ns → ≈25 ns per call). Without a vDSO they fall back to the syscall as before. Every std entry point that reads the time —std/pgdeadlines,std/http/clientelapsed time,std/uuidV7 and the rest — goes through this path. Startup is unchanged for programs that never read the clock (the vDSO address is looked up on the first read)std/pg: the pool scans for connections whose borrower died only when some borrower has died. Before, everypg.Withlooked at every slot of the pool (336 instructions per slot — proportional to the pool size)
Runtime
- The machine stack of a std/net Serve connection task is split in two: the frame of the connection's body stays at the top of the task's own block, and the calls below it run on a per-worker stack area. When the task parks it keeps the area it was running on and the worker takes the task's area in exchange, so the stack pages touched while running stay on the worker's warm area. A body whose frame does not fit the room at the top of the block runs in the previous contiguous form. Memory per connection is unchanged (async with 24,000 connections: RSS and page tables at the level of the previous form). At the point that waits to receive the next request, the task drops the frames below the body and parks without holding an area (a shallow wait) — a sleeping keep-alive connection keeps 8 KB resident instead of 12 KB. A connection that slept inside the handler (Sleep and the like) swaps the area it holds with the worker's before parking for the next receive. On the local bench/httparena (12 workers, median over 8 code layouts): baseline with 4,096 connections −7.6% cycles per request and −29% reads from DRAM, limited-conn (frequent reconnects) −18%, async with 24,000 connections on par (+0.2%). The per-request region window of a connection that parks for the next request now lives on the worker's stack area as well (the area window — a connection that sleeps deep keeps it with the area), so a sleeping keep-alive connection keeps 4 KB resident instead of 8 KB. Median over 8 code layouts: cycles baseline ±0%, pipelined +0.7%, async −0.3%, json +0.3%, limited-conn −6.4%; RSS baseline 71 → 55 MB, json 126 → 112 MB
- For server-shaped programs (std/net's Serve, no
SOBER_WORKERS), the default CPU pinning goes back from v0.7.1's "the CPUs sharing an L3 cache" to one CPU per worker (the form up to v0.7.0). On HttpArena's Zen 2 box the p99 of latency-1m was about 20 times that of v0.7.0 (it did not reproduce on other boxes, and the cause is not established). The L3 groups remain available withSOBER_PIN=1(2= one CPU,0= off) - The wording of the promise about the wait bound D under saturation changed: "a task that has waited longer than D runs, in deadline order, before tasks whose deadline has not come yet. How many tasks ran past D and by how much can be read from
late/maxLateoftask.Ledger()". The previous wording (a runnable task runs within D) could not always be kept, because a task runs until it yields and runnable tasks do not move to another worker. The scheduling order itself is unchanged - The wait queue of a domain's
Awaitis split into "the queue of sleeping waiters" and "a mark for waiters already woken". When a task that finished a body woke the next waiter, it checked the waiters that had been woken but had not run yet, one by one (while holding the lock — about 1,150 checks per request with 1,024 connections and a pool of 250). Now it wakes the one waiter at the head of the queue. Arrival order, the wait bound and poisoning mean the same as before - Sending a response on a plain keep-alive HTTP connection no longer sleeps until the send completes (the fused send and receive). The response is copied into the worker's send area and the task goes on to wait for the next request without sleeping on the send. Wake-ups stay at one per request, the stack at the point that waits for the next request is 304 B shallower, and the response's work area is not held while waiting. When the peer reads slowly, the kernel finishes the rest of the send (neither that connection nor others stall). A send error is returned by the next receive on the same connection (
http.server.ServeConnreturns the same labels). The publicnet.Sendstays synchronous. On the local HttpArena profiles throughput and percentiles are unchanged (rps ratio 0.99–1.03).SOBER_NET_FUSE=0turns it off
Compiler
- Wider region elision: a function no longer needs to open its own region when its allocations all go to the caller's region, or when its callees allocate only on the failure return path. A relocate that copies nothing (its destination is the depth at the function's entry) is removed. About 40% of the instruction cut per HTTP request comes from here (local callgrind, pipelined, one worker: 5,996 → 5,343). It also helps the compiler itself: building the compiler takes 0.37 G (0.3%) fewer instructions, and the output is 125 KB smaller
- In
return g(…), when g's result type is the same as the function's own, the function passes its own result location straight to g and skips copying the result. On/pipelineof bench/httparena this saves 123 instructions per request, and 228 on a request with 8 header lines - The address of a global is rebuilt where it is used instead of being kept in the frame (an address defined outside a loop and used inside it stays in a register and is not rebuilt). An address computation whose index is the constant 0 folds into its base address
- Slot sharing (reusing locations inside a frame) now also applies to functions whose locations total less than 128 words. The frames of all functions in bench/httparena shrink by 12% in total (together with the result forwarding above). Compile work grows by 0.6%
- The region elision check now repeats until no more functions can be elided. Before, the caller of a function that failed the check once stayed failed even after that function became elidable later. On bench/asyncdb this saves 1,456 instructions per request
- The tail of a byte scan (loops of the form
while i < n && b[i] != c, and UTF-8 validation) is now read once as the last 16 bytes, overlapping what was already scanned. Bef...
v0.7.1
The release of domains — long-lived shared state — and a PostgreSQL client. Many tasks updating one state (a connection pool, a counter, and so on) can now be written without writing locks (task.NewDomain / Update / Read / Await — bodies run one at a time, and because a body does not wait and does no io, a lock-ordering deadlock cannot be written). std/pg is a PostgreSQL client over wire protocol 3.0 with TLS 1.3 + SCRAM-SHA-256-PLUS by default, transactions and a connection pool. The HTTP server builds JSON responses with a two-pass encoder, and the fixed cost per request is lower. The limited-conn regression of v0.7.0 (−11.5% on the 64-worker box) is fixed.
Language
- Domains — serialized shared state (spec section 10.8, section 5 of the concurrency guide):
task.NewDomain(s, init, wait:)makes a domain that holds one state inside scopes, andtask.Update(write),task.Read(read) andtask.Await(wait in arrival order untilwhen:holds; past the boundwait:it returnsio.Expired) apply their bodies one at a time- What a body promises: it does not wait, does no io, does not call another domain and does not panic explicitly. There is no form that waits while holding a lock, so lock-ordering deadlocks cannot be written
- Which task enters first is a "recorded input", like input from a file or the network, in the table of determinism promises (with the order fixed, the result does not depend on the schedule — proved by extending the Lean Kahn theorem with an oracle)
- A domain whose body died is poisoned; from then on a call kills its caller with
task.Poisoned{}(it does not go on with a half-written state) - A domain can be captured and passed as a function argument (also inside a struct field). It cannot be a scope's value, a task's result, a channel payload or a function's result (its state disappears at the exit of the scope that made it)
- The state can hold mutable containers (mut slices / mut maps — nested ones too). The initial value is copied into the domain and a body's value is copied out to the caller, so the state's containers cannot be written from outside the domain
- The checker rejects by name, with a rewrite to use, the writes a body may not do: adding region-holding values to the state (append and friends — through an alias of the state too), storing the domain where it would outlive the scope that made it, and passing the state as a mutable argument (allowed when the callee's body shows that it writes it by the same rule as the body)
- Const slots,
@const(p, …): an argument for a marked parameter must be a string whose text is fixed at build time (a literal without interpolation, a const, or+of those) or the caller's own@constparameter. std/pg's SQL text sits in such a slot, so concatenating user values into SQL text (SQL injection) cannot be written (values go in as arguments) - Newly accepted forms (check used to reject them without a message; build accepted them): a union variant built inside a function (including region-holding payloads), another package's typed const (
http.OK) passed to aT?parameter or read back bycheck --split, and reading a slice inside the expansion of a function with mut parameters
std
std/pg(new): a PostgreSQL client (wire protocol 3.0, extended query)- Connecting:
pg.Open(url, k, policy:)(k receives the connectionConn; it is closed when k returns). TLS 1.3 + SCRAM-SHA-256-PLUS (with channel binding) is the default, and the certificate chain is always verified. Only the threedangerously…fields relax this. The password is@secret - Statements:
pg.Exec/pg.Script; rows withfor r <- pg.Query(c, sql, args: …)? { … }(leaving the body with ? / return / break reads and drops the remaining rows and keeps the connection). SQL text sits in a const slot; arguments are the 12 variants ofpg.Value, encoded to match the server's types; statements are prepared once per connection and text - Reading rows:
Col/ColInt/ColString/ColTime/ColUuid/ColJson/ColNumeric(Scaled) and more,IsNull,Width - Failure labels:
Server(with SQLSTATE and hint),Auth,Tls,Dns,Closed,Protocol,BadUrland more - Transactions:
pg.Tx(c, k, isolation:, readOnly:)— COMMIT when k ends normally, ROLLBACK when k leaves with ?. Nesting is SAVEPOINT throughout. If k swallows a statement error and ends normally, the transaction is rolled back andAbortedis returned. There are no automatic retries - Connection pool:
pg.OpenPool(url, k, size:, wait:)andpg.With(p, k). The typepg.Poolpairs a domain with a holding table and can be passed as an argument to a named handler. A connection goes back to the pool only when k returns normally, outside a transaction and with nothing left unread (otherwise it is closed). The pool reclaims connections from borrowers whose task died. The name isOpenPoolbecause a functionPoolwould collide with the typePool(the same pairing asOpen→Conn) - Round trips with real PostgreSQL 17 / 18 (TLS / plaintext) are checked by
scripts/pgoracle.sh
- Connecting:
std/task: the four domain calls andDomain[S];task.Ledger()gainsdomains(one row summing all domains,DomainView{waiting, maxWait, expired, poisoned}) andpin/pinGroups(how workers are pinned to CPUs)std/http/server:Delayed(ms, r)— a response sent after a wait. While waiting, the connection task holds neither the handler's stack nor the request's region (the send time is the same as sleeping withos.Sleepin the handler before returning, and the footprint is that of a keep-alive connection). Pipelined order does not changestd/http/server:Unstated{}forCache— writes no Cache-Control line (it leaves storability to the recipient, so it is on the may-be-shared side,@shared— only allowed on a route's row). The defaultprivateis unchanged- TLS client (http/client and pg): secp256r1 (P-256) key exchange. X25519 stays preferred; when a server asks for P-256 with a HelloRetryRequest, the client follows (PostgreSQL 17 and earlier accept only P-256 by default)
- analyzer: a construction that gives a std
dangerously…field anything but a literal false is a warning every time (acceptance unchanged, no way to silence it — it goes away when the setting is safe again)
Runtime
- For server-shaped programs (std/net's Serve, no
SOBER_WORKERS), workers are pinned to "the CPUs sharing an L3 cache ∩ the allowed set" instead of one CPU. The groups are read from sysfs; when they cannot be read, pinning falls back to one CPU.SOBER_PIN=0turns pinning off,1= groups,2= one CPU (for comparison) - The chain of deadline waits is doubly linked (O(1) removal), and the deadline scan keeps a lower bound. The O(n²) cliff with many connections that have deadlines is gone (16,000 connections, W12, Serve with a deadline: 45–49K → 453–475K requests/s — local. The instruction count of the production shape without deadlines is unchanged)
- The wait queue of a domain's
Await: a waiter whosewhen:(a literal lambda with no captures) is already known to be false is not woken again until the state changes. The chain of wakeups with many waiters (wakeups proportional to the number of waiters at each exit) is gone (1,024 waiters on one domain, W1: 596K → 6.3K instructions per acquisition — local). Arrival order, the wait bound and poisoning mean the same as before - An error value is now a fixed 4 words: a tag and a list of labels. The label with the largest fields in a program no longer widens the error slot of every function (importing std/pg keeps the deepest frame of an HTTP connection task at 2,112 B — 2,688 B with the previous representation). Labels with fields allocate only on the failure path
- The headroom at the top of a task's stack goes from 2 to 3 KiB. The deepest stack of one keep-alive HTTP request had only 16 B left in the old budget (going over adds 4 KB of resident memory per connection). The new stackcheck gate watches the depth
Tools
- Documentation: a concurrency guide (docs/guide/concurrency — tasks, channels, domains) and a PostgreSQL guide (docs/guide/postgres — connecting, statements, rows, failures, transactions, the pool)
bench/httparena:perf.sh json-tls(perf attribution for json-tls),WRK_THREADSforlocal.sh(wrk threads for json-tls — defaults to the load side's CPU count; upstream uses 64) and a busy column for the load side's CPUsbench/httparena:SOBER_UPSTREAM_FORM=1measures with the same launch shape as the upstream entry (noSOBER_WORKERS= CPU pinning on)
Fixes
- The limited-conn regression of v0.7.0 (−11.5% on HttpArena's 64-worker box): every time a connection was re-established, the madvise that returns the window's pages sent TLB shootdowns to every worker's CPU. Pages are now returned only the first time per task block (local W8 page faults 36,004–45,919 → 91–114)
- The TLS client regenerated the random in the second ClientHello after a HelloRetryRequest (a violation of RFC 8446 4.1.2 — Go's crypto/tls server rejects it)
- check rejected, without a message, a union variant built inside a function, another package's typed const, and slice reads inside an expansion (build accepted them — see "Newly accepted forms" under Language)
Performance
-
HTTP — bench/httparena (our own yardstick: it uses HttpArena's load generator and request files but is not an official HttpArena result). On a 16 vCPU GitHub Actions box (AMD EPYC 9V74), with 8 CPUs given to the server, alternating with v0.7.0 round by round in the same job (median of 5 rounds, 4,096 connections). This time it is a same-box A/B on one box generation:
Profile Requests / s (vs v0.7.0) CPU / request (vs v0.7.0) json-comp +12.2% −10.8% json-comp-16k +10.2% −8.9% async +3.9% −3.6% baseline +2.3% −2.2%
...
v0.7.0
The release that reworks imports. A qualifier is now every segment of the import path (std/http/server → http.server.Serve), and a sober.mod at the build root declares the module's name and its local dependencies. std fills in the pieces web applications were missing: password hashing, cryptographic randomness and UUIDs, time and time zones, cookies, versioned sessions, a stub DNS resolver, and an HTTP client that always verifies the certificate chain. std/http is split into the server (std/http/server), the client (std/http/client) and the shared vocabulary (std/http). deflate keeps a record of recent Huffman trees so a connection with similar bodies does not rebuild them, which makes the HTTP server's gzip responses faster. At startup, the runtime now locks in W^X (Linux 6.6 and later).
This release has many breaking changes; when upgrading from 0.6.7, start with the "Migration" section below.
Migration
Steps to move 0.6.7 code to 0.7.0:
- Use every segment of the path as the qualifier. With
import "std/compress/gzip",gzip.Encodebecomescompress.gzip.Encode; with your ownimport "util/strings",strings.Xbecomesutil.strings.X.std/is dropped (import "std/json"is stilljson.X). Single-segment paths do not change. A file-local alias,import gz "std/compress/gzip", shortens a long qualifier - Move HTTP server code to
import "std/http/server"and changehttp.Xtohttp.server.X(http.server.ServeConn,http.server.Request,http.server.Text, …). Only the shared vocabulary stays instd/http(Field,BadHeader,StatusCodeand consts such ashttp.OK) - The named argument
compress:ofServeConn/ServeWithis nowcompression: - Follow the renames (listed under "Breaking changes"). Renamed labels also affect
catchandswitcharms - If a response wrote Set-Cookie as an arbitrary header, move it to
Response.cookies([]http.server.SetCookie); an arbitrary Set-Cookie header now panics withBadHeader - If a gzip response body must depend only on the response body (for a content-addressed cache, say), use
compression: http.server.Gzip{fresh: true}(the default now uses a per-connection record — see below)
Breaking changes
- The qualifier is every segment of the import path (only
std/is dropped). The old spelling that referred by the last segment alone is no longer accepted. Every path segment must be a lowercase identifier that is not a keyword. A local binding with the same name as a qualifier's root segment (thehttpofhttp.server) is an error std/httpis split in three: the server API moved tostd/http/server(http.Serve…→http.server.Serve…,http.Stream→http.server.Stream, and the rest).std/httpkeeps only the shared vocabularycompress:ofhttp.server.ServeConn/ServeWithis nowcompression:(it collided with the root segment ofstd/compress)- Labels for malformed input are now
Bad…:convert.InvalidInt/InvalidFloat/InvalidBool→BadInt/BadFloat/BadBool,json.InvalidJson→BadJson,strings.InvalidUtf8/env.InvalidUtf8→BadUtf8 - Out-of-range labels are now
…OutOfRange:json.IntRange→json.IntOutOfRange compress.flate.Prepare→compress.flate.NewWork- The position parameter of
crypto.chacha20.XorKeyStreamAtis nowoffinstead ofat(positional callers are unaffected) std/tls/recordis no longer public (std/internal/record)crypto.rsa.MontSelfCheck/crypto.rsa.SelSelfCheck/crypto.sha256.ShaSelfCheck/crypto.x25519.FeSelfCheckare no longer public (they moved into each package's tests)- Set-Cookie can only be written through the typed field
Response.cookies. An arbitrary Set-Cookie header panics withBadHeader{name: "Set-Cookie"} - A field of another package's struct that holds mutable contents (a mut slice / map) cannot be given a value even in a composite literal (reading and writing it were already rejected). A literal from outside leaves the field out (zero value: empty contents). This closes a hole through which std's immutability promises (the results of
table.Freezeandstrings.Clone) could be broken from outside - Calling an optional function value (
F?— a field, a map read, an element of[]F?) without narrowing it is now rejected by name in both check and build (build used to accept it and crash on nil) - A func with results whose body ends in a call expression statement other than panic is rejected (check used to take the last value as the result)
Language
- Imports and packages (spec chapter 8): the qualifier is every segment of the import path. An aliased import replaces the whole qualifier. The root segment of an import path lives in one namespace of "names in the tree" (std roots, dep names, root directories of the starting tree); collisions are rejected rather than resolved by priority
sober.mod: an optional single module declaration at the build root (cwd).module "example.com/app"(the last segment is the name),dep "../kv"/dep kv2 "../kv"(a dependency by local path — import paths are written from the name in the tree, and the origin appears only in sober.mod),alias stdjson "std/json"(renames a std root to resolve a collision). Directories above the root are never searched. In this version a dep must be a local path; versions, other origins and deps of deps are rejected by name- How this differs from Go (the qualifier, where the origin lives, how the root is found, how collisions are resolved) and the naming conventions (a package name is one lowercase word; parameters and fields are nouns) are tabulated in language.md section 20 (in Japanese)
- Conversion to a named function type,
W(e): puts a lambda, a named function or a W value in the value position of W. A named function can be passed as an argument when calling a function value - A call with named arguments can be a for generator:
for x <- g(a, n: v)? { … } - Newly accepted forms (previously rejected by either check or build): storing into a struct's mut map field
s.m[k] = v, compound assignment to a list elementxs[i] op= v, renaming field bindingscase A{e: inner}, unions with a single variant (recursive and generic ones too), recursive values in a cell, passing a named function to a generic named function type - Rejections that had no message now name the problem: comparing
string?with string, a region-carrying type argument to a generic called as a Def, a locally bound named function in a value position, omitting a field of a type with no zero value in a partial literal - Diagnostics print other packages' names with their qualifier
std
std/http/client(new): an HTTP/1.1 client.http.client.Request(url, method:, headers:, body:, cookies:, policy:),Streamfor receiving the body in slices, andOpen/Session/Sendfor reusing a connection. TLS 1.3 is the default (1.2 only with an explicitdangerouslyAllowTls12), and the certificate chain is always verified (there is no field that turns verification off). Trust anchors are the built-in Mozilla NSS store plusPolicy'sextraRoots(tls.ParseRoots). Chains ending at a built-in anchor have the CA/B Forum Baseline Requirements leaf rules applied. Redirects (Authorization / Cookie are dropped when the origin changes), proxies (absolute URI for http, CONNECT for https —ParseProxy), and cookies as data that you read withCookieValueand put on the next request (there is no jar). Configuration is a single statelessPolicytypestd/http/server:cookies []SetCookieon responses. Omitted fields default to the safe side (Secure, HttpOnly, SameSite=Lax, Path=/); only fields spelleddangerouslyAllowHttp/dangerouslyScriptReadablerelax them. Breaking the attribute rules (__Host-/__Secure-and others) panicsstd/http/server: gzip responses keep, per connection, a record of the most recent dynamic Huffman trees and reuse them without building a new tree when similar bodies follow (4 KB per connection; 0 B for connections that never compress). The compressed body is determined by the connection's sequence of requests (the same sequence gives the same bytes), and blocks that use the record are at most 1.03 times the size without it.Gzip{fresh: true}keeps no record (the path up to 0.6.7, determined by the body alone)std/http/server: for a route without a policy (cache:),Dispatchreturns the handler's response as is (one copy fewer)std/session(new): versioned session tokens (sealed with HMAC-SHA256; the server keeps no session table). Verification has two stages,Open→Subject→Check; bumping the generation (on a password change, say) makes older tokensStale. Read claims withWho/Of(a zero-valuedClaimscannot be read as verified). The doc lists the threat model (what it defends against and what it does not)std/crypto/password(new): Argon2id, bcrypt and PBKDF2-HMAC-SHA256, withVerify/NeedsRehashover PHC strings. Costs are optional named arguments; when omitted, they are the minimums recommended by the OWASP Password Storage Cheat Sheet (Argon2id 19456 KiB, 2, 1 / bcrypt cost 10 / PBKDF2 600000 iterations). Defaults are revised only in std releases and recorded in this changelog.Policy'sUseDefaultleaves both the scheme and the costs to the std versionstd/crypto:RandIntn(a uniform integer from cryptographic randomness)std/uuid(new):NewV4,NewV7,Format/Parse,Equalstd/time(new): the wall-clockTimeand the monotonicInstantare separate types (so measuring a deadline with a wall-clock difference is a type error). RFC 3339 / HTTP-date formatting and parsing, calendar fields (Civil), and a built-in IANA tzdata 2026f (LoadZone,ToCivilIn/FromCivilIn)std/net: name resolution (a stub resolver that does not use libc).net.Dialaccepts host names (/etc/hosts, then DNS from /etc/resolv.conf)- `std...
v0.6.7
v0.6.6
v0.6.6 — 2026-11-03
A performance release. The HTTP server's receive buffer ring is now consumed incrementally, which cuts CPU per request. TLS gets faster handshakes (RSA signing, x25519, SHA-256) and faster AES-GCM records, and deflate's lv6 gives up a few percent of compression ratio for speed. The language gains @replaces / @doubles, which swap a package for a double in a test build (build-time DI), and std/testing/cell, a place for fakes to keep state. assert now reports the user's line on failure and takes an optional label. Many disagreements between check and build were fixed.
Language
- Swapping packages in test builds (build-time DI): a double is a directory of
_double.soberfiles only, each with@replaces("<target path>")before its package clause.@doubles("<double path>", …)before the package clause of a test package's_test.sobermakes every file in that test build that imports the target (other packages included) read the double instead. Production build and check never read_double.sober. The public names the double shares with the target are checked as a contract (function signatures, the types of shared fields, consts,@errors⊆, the target's@effectsdeclarations); a double with effects the real package doesn't have is rejected.sober check <double directory>checks only the contract. - A failing
assertnow panics at the line where the user wrote the assert (it used to point inside std/testing). An optional labelassert(e, label)is evaluated only on failure, and the heading readsassert failed (<label>): <expr>. - An if / switch expression with no expected type whose arms are T and T? (
r := if c { 4 } else { q }, a narrowed arm next to an unnarrowed one), or whose arm ends in nil, has type T?. An arm of nil merged with an error-label arm gives error?. - Narrowing by nil checks on the right side of
&&/||now works wherever the expression appears (the condition of an if with else, return, a binding, an assert), and in the bodies ofif A && x != nil { … } else { … }. - Assigning to a narrowed mut binding (
if x != nil { x = 5 }and the like) is still rejected; the diagnostic now names the target, the position of the narrowing if, and the workaround (bind another name). returnin the body of a yield-form for is officially accepted (the prohibition is removed from the documents — the implementation already accepted it).- Values passed with
give:must be channel endpoints (std/task Sender / Receiver); anything else is rejected by name (build used to accept it and the program failed at run time). Pass other values by capture. - Fixed disagreements between check and build (in each case one of them used to reject):
- adding or copying a value from an outer region into a local slice / map (an
appendelement, a literal element), and the initial value of a struct with mut parts andappendto its fields - the expected type now reaches the tail of a block with statements, the body of a lambda's block and switch arms (
{ g(); 44 }in abyte?arm,return string(b) catch nil, …) - reading fields of generic structs and of a generic function's result
- a pipe that drops multiple values into holes in order,
divmod(17, 5) |> add(_, _) - a scope body that ends in panic (
task.Run((s) => { panic(Boom{}) })) - a string used directly as a for source,
for c <- s, is now rejected by name in check too (suggestingfor c <- bytes(s)) - a form rejected only by an internal stage of build gets the same diagnostic as check, when check rejects it too
- adding or copying a value from an outer region into a local slice / map (an
Testing
- std/testing/cell (new): a place for fakes to record calls and written values. A
Cell[T]is created only by a const declaration and read and written withGet/Set/Update(the effect is io). Only_test.soberand_double.sobermay import it. std/quick empties it for each trial. sober test --wiring: reads the test build (without running it) and prints<test package>: <target> <= <double>for each double.- Doubles are outside the instruments:
--coverdoesn't instrument double files and--mutatedoesn't mutate them. A panic inside a double is printed with the double's real path. - Power-assert value lines now appear for expressions with function-typed arguments,
mutarguments andcatch.
Tools
sober watchalso watches the files the warmed tests read (_test.sober, doubles, and the targets read for contracts).- Check cache fix: a function returning a plain
errorcould miss invalidation when the type of an error payload in another package changed.
Standard library
- std/crypto/aesgcm:
Open/OpenAtnow fold GHASH while decrypting and compare the tag at the end (they used to compare first, then decrypt). If the tag doesn't match, the plaintext written so far is zeroed withcrypto.WipeandAuthis returned (no plaintext reaches the caller). - std/compress (deflate): lv6 (the default of
compress:) is tuned toward speed (chain 128 → 64, nice 128 → 96, lazy threshold 12 → 10). The output bytes change. On JSON bodies the output is 1.6–2.6% larger than zlib's lv6 (it was within 1%); use lv7–9 when ratio matters more. lv4 / 5 / 7–9 are unchanged. - std/crypto/sha256: on CPUs with SHA-NI, compression uses those instructions (Sum256 of 1 MiB: 34.9 → 2.04 cycles / B, local Zen 5). Other CPUs use the previous code.
Runtime
- The receive buffer ring is consumed incrementally (IOU_PBUF_RING_INC — Linux 6.12 or later): 16 KiB buffers are shared in pieces by all of a worker's connections. The path where, with many connections, the ring ran empty and receives kept falling back to read(2) is gone. The rings total at most about 16 MiB across workers. Kernels before 6.12 use the previous fixed-slot ring.
- The io_uring ring fd is registered (Linux 5.18 or later), and connection sends go through a fixed file table (4,096 slots per ring), saving a file reference get / put per SQE. Connections beyond the slots use their plain fd.
- The TCB and stack of the tasks likely to run next are prefetched (this helps when the run queue is deep under saturation — locally with 12 workers, async at 16,000 connections: −18 to −20% cycles per request). Only candidates that have waited at least 2^22 TSC ticks (≈1.4 ms) in the run queue are prefetched; the check uses the push time kept in the run-queue slot, so it does not read the candidate's TCB. The TCB of the task about to run is always prefetched. The TCB of a task woken by a sleep's expiry is prefetched too.
- Run order, the wait bound D and every observable value are unchanged.
Performance
-
HTTP — measured on bench/httparena, our own harness rather than an official HttpArena result. It uses the HttpArena load generators and request files on a 16-vCPU GitHub Actions runner (AMD EPYC 9V74), with 8 CPUs for the server; v0.6.6, v0.6.5 and Go's net/http ran alternately round by round in the same job (median of 5 rounds):
Test Requests/s (vs v0.6.5) CPU/request (vs v0.6.5) Requests/s vs Go, v0.6.5 Requests/s vs Go, v0.6.6 baseline +9.2% −8.7% 2.76× 3.02× pipelined −1.9% +2.2% 12.6× 12.4× limited-conn * −4.8% +4.6% 2.47× — json +9.0% −8.5% 3.03× 3.31× json-comp +19.7% −16.4% 1.66× 1.98× json-comp-16k +17.0% −14.6% 1.58× 1.85× json-tls +29.8% −23.2% 2.19× 2.85× 8gbit +0.8% −8.5% 0.99× 1.00× async +3.7% −5.0% 1.82× 1.89× * The limited-conn row (a new connection for every few requests) is from a separate run after the prefetch fix (see Runtime above — candidates that are still warm are not prefetched), on the same runner type and alternating with v0.6.5 in the same job. Same-runner comparisons of this test move by ±2.5–3% from run to run; depending on the CPU and the run it lands within −5% to +3% of v0.6.5 (EPYC 9V74 −4.8%, 9V45 −2.5% to even, Xeon even). In the same run, baseline was +9.6% and async +4.2%. The ratio to Go was not measured in that run.
- 8gbit is bandwidth-bound, so read it as CPU per request. The latency tests (fixed rate) held their rate in both versions, with CPU per request −0.1 to −2.2%.
- No non-2xx responses in any test.
-
TLS handshakes: RSA signing's window table lookup and Montgomery squaring, x25519 field multiplication and SHA-256 are now instruction blocks. Server user cycles per handshake 4.61 → 3.20 M (−31% — local Zen 5, one worker).
-
AES-GCM: GHASH folds 8 blocks per reduction, and CTR and GHASH run in one stitched pass (GHASH uses Karatsuba). The key table and the per-record state are separate, removing a 384-byte copy per record. 16 KiB records: Seal 1.08 → 0.63 and Open 1.09 → 0.64 cycles / B; 64-byte records: instructions −51% for Seal and −47% for Open (local Zen 5).
-
Compiler: keeps the hot path of bounds checks on the fall-through side of the branch (copies on edges to cold blocks move to the end of the function), extends inlining of tiny leaf functions into inlined bodies, folds comparisons of a value with itself, and more. Locally: json-comp server cycles per request −6.6% (8 layouts × 3 rounds); sequential routing −7.7%, jsonapi −1.7%.
-
Sequential benchmarks against the Go twins (CPU time — task-clock — as the median of per-round paired ratios over 5 rounds; the range is the min–max of the per-round ratios; 16-vCPU GitHub Actions runner (AMD EPYC 9V74), pinned to one core):
Benchmark Sober / Go Range binary-trees (18) 0.37× 0.36–0.38 k-nucleotide 0.37× 0.36–0.38 1BRC (10M rows) 0.55× 0.54–0.56 reverse-complement 0.63× 0.62–0.63 fasta (2.5M) 0.64× 0.63–0.64 jsonapi (100k) 0.74× 0.74–0.76 n-body (5M) 0.81× 0.81–0.82 logagg (500k) 0.96× 0.94–0.98 fannkuch-redux (11) 0.97× 0.97–0.98 mandelbrot (4000) 0.98× 0.98–0.98 spectral-norm (2000) 1.00× 0.99–1.02
...
v0.6.5
A testing release. It adds four verification tools — property-based testing with std/quick, sober fuzz, sober test --cover (coverage) and sober test --mutate (mutation testing) — with a guide on writing tests. sober check / sober build / sober test now remember the answer to a query and return it when the same query comes again, and sober watch warms build and tests in the background after each save. The CLI is now declared as a table (the new std/cli), and usage errors exit with code 2 (breaking change — -run → --run and others).
Breaking changes
- Long CLI flags start with
--:sober test -run <prefix>becomessober test --run <prefix>(a long name with a single dash is an unknown-flag error that suggests the close spelling). Command-line usage errors (unknown flag, missing value, combinations that don't go together) exit with code 2 (was 1). - Assigning multiple values to existing names (
a, b = f(),_, b = f(),s.x, b = f()) is rejected (build used to accept it, and check failed with an internal error). Bind new names (a, b := f()) or discard all of them (_, _ = f()). Parallel assignment with several right-hand values (a, b = b, a) is unchanged. - The build cache is on by default (see Tools). Set
SOBER_BUILD_CACHE=0where you don't want a cache directory created.
Language
- Composite consts: a top-level
constcan hold a string, a float (including negative), true / false, a struct (partial literal), an imm slice, and nestings of these (int consts still fold; the others are built at each point of reference). Leaves are int const expressions, closed lambdas, references to named functions, references to other consts, and nil in a field's value position. - A named function type from another package can now appear in a function's results and parameters, in struct fields and as a generic type argument (check used to fail with UnknownName and build with UndefinedType).
- Type rejections from
sober checkcarry a position:sober: type error at <path>:<line>:<col>(the verdict JSON is unchanged).
Testing (the four verification tools)
- std/quick — property-based testing: draw values from a choice source (
quick.Int/Bool/Pick/Elem/Bytes/String/Runes/Slice), andquick.Checkcalls the property once per trial. When it turns false, the sequence of choices is shrunk to a minimal counterexample, which is printed. The seed comes from the test's name, so the same code gives the same trials and the same counterexample on every run. In*_test.sober, a counterexample where the property's last expression is false is printed in the same form asassert(with the values of subexpressions). sober fuzz <package> --run TestXxx: keeps running a test'squick.Checkuntil a deadline (--time). A false property, a panic or a hang (--hang) is shrunk, reported and written as a choice sequence under<package>/testdata/fuzz/— whichsober testreplays first. It is coverage-guided by default (choice sequences of trials that opened a new branch are mutated and reused — on a local example, a nested 4-byte magic value was found 0/10 times in 14 million trials without guidance and 10/10 times in 90 thousand trials with it).--schedalso varies the std/task schedule per trial.sober test --cover: counts the branches of the tested package (function bodies, both sides of an if, each switch case, loop bodies, the right side of&&/||, catch arms — "arm" in the output) by whether any test passed through them, and lists unreached branches in position order.--cover-outwrites text or LCOV.sober test --mutate: builds one test binary with mutants woven into the tested package (comparison boundaries and negation, swapped arithmetic and bit operators,&&↔||, removal of!, fixed conditions, deleted statements), then for each mutant runs only the tests that reach it, in parallel, and counts killed / survived / unreached.--mutate-outwrites one line per mutant;<package>/testdata/mutate.ignoreexcludes equivalent mutants.- Replaying test results:
sober testremembers the output of a package whose tests all passed, and returns it without building or running when none of the sources, testdata or environment variables the tests read have changed (4–15 ms locally).--freshalways runs.
Tools
- Replaying answers:
sober check/sober buildremember the answer to a query and return it when the same query comes again (the contents of the files read, the list of .sober files in the directory, @embed inputs and the relevant environment variables all equal).sober checkof the compiler itself: 4.4 s cold → 0.05 s. - Per-function check cache (on by default —
SOBER_CHECK_CACHE=0turns it off) and build cache (on by default —SOBER_BUILD_CACHE=0): after an edit only the changed functions and packages are checked and rebuilt. The store lives outside the worktree and is shared by several worktrees;sober cache info/sober cache gc. sober watch: about 0.05 s from save to the check answer. After an accepted round it warms build and tests in the background (nice 19, a quarter of the workers), so a plainsober build/sober testtyped next hits from the first time (build 0.27 s locally). It waits until edits have been quiet for 200 ms before a round (SOBER_WATCH_QUIET).- The build splits lower into function groups and runs them in parallel (on by default with 2 or more workers — byte-identical output).
sober doc --cli: the CLI reference as Markdown, from the executable itself (docs/cli/sober.md is its output).sober outline <file>: the declarations of a source file and their line ranges (text / json).sober --help/<command> --help/--versioncome from std/cli (80 columns).
Standard library
- std/cli (new): write a CLI as a const table of flags, positional arguments, environment variables and subcommands;
cli.Mainparses, prints usage, diagnoses misuse (exit 2) and handles--help. - std/env (new): declare environment variables in a const table;
env.Loadreads and checks all of them once at startup (Missing, BadValue, InvalidUtf8). - std/quick (new): see Testing above.
- std/os:
RunTo(a child's stdout / stderr into files),AppendFile,Remove/RemoveDir. - std/table:
Read. - std/task:
task.Run(…, backlog: K)(see Runtime);task.Ledger()gainsbacklog/backlogYield. - std/net:
net.Serve(…, backlog: K).
Runtime
- A bound on unstarted children: of the children a task spawns, at most 255 per worker wait without having started. Beyond that the spawning task yields and the child runs right away (values and output are unchanged). In a loop that keeps spawning (the accept shape), peak RSS no longer grows when the spawner outpaces the workers because of CPU contention (locally, 2 million spawns on 4 cores shared with busy loops: 47–77 MB → 1.7 MB).
task.Run(…, backlog: K)/net.Serve(…, backlog: K)lower it to 1–255, and child tasks inherit it. Observe it withtask.Ledger().backlog(unstarted now) andbacklogYield(times the spawner yielded). - Fixed three bugs when a child task looked up a frozen map: a write to address 0,
task.Mapresults silently becoming 0, and rebuilding the index on every by-value pass until the region ran out. - Fixed a rare ETXTBSY (Text file busy) when running an executable right after writing it (programs using std/task now write files with a synchronous write).
Documentation
- A guide to writing tests (docs/guide/testing.md / testing.ja.md): on a small example, from assert to std/quick,
--cover/--mutateandsober fuzz. All output is real. - A guide to abstraction (docs/guide/abstraction.md / abstraction.ja.md): instead of interfaces — named function types and closures, structs of function fields (dictionaries), unions with type-pattern switch, and a table for readers coming from other languages.
- A guide to environment variables (docs/guide/env.md / env.ja.md): the variables the compiler, the runtime and std read.
- Terms: the Japanese documents now use the common names for testing terms (プロパティ, シュリンク, ミュータント, 分岐, シード, ジェネレータ) and エフェクト for effect.
v0.6.4
A release that moves accept onto every worker. net.Serve now holds one listener per worker on the same address, and each worker accepts on its own listener and runs the connection itself. For that, net.Listen now sets SO_REUSEPORT (a change in behavior — see Standard library and the security guide). The language gains optional anonymous function-typed parameters, and http.ServeFlushed is folded into http.ServeWith(…, put:) (breaking change). The runtime also gains a flight recorder (off by default) that keeps what it was doing when something fails.
Breaking changes
http.ServeFlushedis removed. Writehttp.ServeWith(recv, send, route, …, put: (b) => tls.Put(c, b))instead (see the optional parameters under Language). Calls toServeWith/ServeConnthat don't pass put compile to the same code as before.net.Listensets SO_REUSEPORT. Another process running as the same uid can bind the same address, and the kernel splits incoming connections between them. A second start on the same address no longer fails with EADDRINUSE; it silently receives part of the connections. Processes running as a different uid cannot bind. If you detected a double start by the bind failing, switch to another mechanism (a pid file, systemd's single-instance units, etc.).
Language
- Optional anonymous function-typed parameters
p (func(…) R)?, allowed only on the parameters of a func declaration. Callers may pass nil, omit it, or pass a lambda, a function name, or the same kind of parameter of the enclosing function. In the body, p may be called only inside anif p != nil { … }branch (storing, returning or binding it is rejected). Whether it is present is decided per call site from the source, and a call site that doesn't pass it compiles to the same code as a function without the parameter. A barefunc(…) R?is rejected because it reads as a function with an optional result; the diagnostic points to the parenthesized form. To store an optional function, use an option of a named function type,F?.
Performance
-
HTTP — measured on bench/httparena, our own harness rather than an official HttpArena result. It uses the HttpArena load generators and request files on a 64-vCPU GitHub Actions runner, with 32 server cores, and compares against Go's net/http. Sober / Go ratios, against the v0.6.3 run:
Test v0.6.3 v0.6.4 baseline 3.2× 3.5× pipelined 15.9× 15.7× limited-conn 9.2× 11.1× json 3.4× 3.6× json-comp 1.8× 1.8× json-comp-16k 1.7× 1.6× json-tls 2.8× 2.8× async 2.4× 2.3× The v0.6.3 run used an AMD EPYC 9V45 (Zen 5) and the v0.6.4 run an EPYC 9V74 (Zen 4); the CPU model alone moves the ratios by a few percent (limited-conn in particular comes out higher on the 9V74).
- latency-1m holds its target rate, and p99 went from 0.21 ms to 0.14 ms.
- No non-2xx responses in any test.
-
Accept on every worker: a single dedicated worker used to accept and hand connections to the workers, which capped loads that open and close connections at a high rate. Now
net.Serveadds one listener per worker on the same address, and worker k puts each connection it accepts straight onto its own run queue (no hand-off wake-up). The kernel's 4-tuple hash spreads the connections. Locally with 12 workers and 4096 connections (15 s, alternating runs):- reconnecting on every request: +0.6% requests per second, p99 104–109 → 52–76 ms, p99.9 207–239 → 143–156 ms
- keep-alive: +1.6–2.2% requests per second
- in a build with measurement instrumentation (a heavy runtime cost per connection — not a number for user builds), reconnecting on every request gets about 3× the requests per second (114–138K → 383–425K)
There is no dedicated accept worker any more, so a Serve program runs exactly as many threads as workers. Under saturation, the first request of connections that arrive all at once may wait close to the wait bound D (100 ms by default); the wait stays bounded by D.
-
The run queue is now a per-worker array ring (4096 slots; overflow goes to the previous linked list). Pushing and popping no longer write to other tasks' TCBs, and the wait bound D under saturation is kept more precisely. Locally with keep-alive at 4096 connections, the longest wait from becoming runnable to running went from 113.7 to 103.0 ms, and tasks that waited longer than 100 ms went from 1,969 to 14. CPU per request without saturation is unchanged.
-
Worker CPU pinning: in a Serve program, when the number of workers is at most the number of allowed CPUs (always true when the worker count is derived from the CPU affinity and cgroup, which is the default), each worker is pinned to one allowed CPU. Runnable workers no longer pile up on one CPU waiting for a time slice. Programs that don't use Serve (the compiler included) are not pinned.
SOBER_PIN=0turns pinning off. -
False sharing fix: before sleeping, a worker reads every worker's run-queue state, and that cache line also held words the owner writes every round. They now live on a line only the owner touches. Locally with 12 workers: 0.4–2.4% less CPU per request on a scaled-down latency-1m, and +2.4% requests per second on keep-alive at 4096 connections.
-
Writing JSON strings:
json.quotescans for the stretch that needs no escaping 16 bytes at a time (SSE2; short strings use two overlapping loads), and a string that needs no escaping at all is appended together with its quotes in one step. Locally with 12 workers and 4096 connections: 6.9% fewer cycles per request on json and 2.8% on json-comp.
Standard library
builder.PushBetween(b, open, xs, close): appends xs between open and close (the same result asPush(b, open),PushAll(b, xs),Push(b, close), with one capacity check).http.ServeWithgains an optionalput:. When given, the responses for one received chunk are queued with put, and only streamed responses are sent with send (meant for TLS connections — replaces v0.6.3'shttp.ServeFlushed).net.Listen/net.Serve: see Breaking changes and Performance above (SO_REUSEPORT, accept on every worker).
Runtime
- Flight recorder (
SOBER_FLIGHT=<path>, off by default): the runtime writes events — accept, spawn, run, fd waits and wake-ups, the requested and actual lengths of sends and receives, close, sleep, task end, panic — as fixed-size records into a ring per worker. The rings live in a shared mapping of , so the file survives a panic, an external kill or a hang, and is removed on normal exit. Read it withscripts/flight.ts(--conn <port>for one connection's lifetime,--aroundfor the events near a time,--anomalyto find anomalies). Off, it costs +0.17% instructions; on, +3.3% cycles.
Testing
- Development mutant
--rt-mutant=30(chaos): injects spurious EAGAIN and short writes / reads into sends, receives and accepts on the epoll path, and shrinks the event buffer and adds sched_yield around sleeping (seedSOBER_CHAOS_SEED). Normal builds compile to the same code. scripts/httpprobe.shand the httpprobe workflow: a net for httpcheck failures that show up only rarely. It alternates normal and chaos runs of the single-worker configuration for as long as time allows, and keeps the flight recording and the client's failure reason (cause, local port, time) of any failed run.
Documentation
- The security guide (
docs/guide/security.md, Japanese originalsecurity.ja.md) adds port sharing with processes of the same uid (a property of SO_REUSEPORT) to what it does not protect against.
v0.6.3
v0.6.3 — 2026-10-25
A performance release. Servers with many connections now take io_uring completions in small steps, which removes the many-connection cliff, and a task that becomes runnable under saturated load now waits at most a bounded time. Memory per request and per connection is lower, and TLS sends and compression encoding are faster. Public std functions now carry @effects declarations, and the runtime's depth invariant is always checked.
Performance
-
HTTP — measured on bench/httparena, our own harness rather than an official HttpArena result. It uses the HttpArena load generators and request files on a 64-vCPU GitHub Actions runner, with 32 server cores, and compares against Go's net/http. Sober / Go ratios, against the v0.6.1 run:
Test v0.6.1 v0.6.3 baseline 3.0× 3.2× pipelined 16× 15.9× limited-conn 7.2× 9.2× json 3.1× 3.4× json-tls 2.3× 2.8× json-comp 1.3× 1.8× async 1.9× 2.4× - latency-500k-8cpu and latency-1m hold their target rates (p99 0.37 ms / 0.21 ms).
- No non-2xx responses in any test.
-
Taking completions (the net ring): the runtime used to enter io_uring only when it ran out of runnable tasks, and then took all completions at once. With many connections each batch grew deep and the working set spilled out of the cache. Now, once 8 SQEs are waiting to be submitted, a worker submits, takes completions and fires expired timers without sleeping, and runs the woken tasks first while they are hot. On its own that would let old wake-ups sink under saturated load, leaving some connections unserved for seconds, so a task that becomes runnable now runs within a wait bound D even under saturation (default 100 ms —
maxWait:below), picked one at a time from the bottom. Locally with 12 workers, with every connection served within D:- keep-alive at 4096 connections: 2.3× fewer cycles per request (the 2.5× cliff against 256 connections is gone), and 17% more requests per second than the fair variant without hot-first completions
- async at 24k connections: 7–9% less CPU per request
- Under saturation, p99 ≈ D (100 ms by default) is by design: every connection runs within D (recently active connections run while hot, and connections whose wait reaches D are picked up). Without saturation, the bookkeeping for the bound costs about 3% CPU per request.
-
Memory:
- A function whose region allocations all flow into its return value no longer enters a region of its own; it allocates straight into the caller's. This removes region depths per request, including std/http's Dispatch. async at 24k connections: VmHWM −21 MB (−5%).
- The per-worker cap on spare small slots and the slot count of the receive buffer ring (PBUF_RING) are now divided by the number of workers, so spares no longer grow with the worker count. async at 24k connections: VmHWM about −20 MB with 12 workers, about −44 MB with 24. The smaller ring also stays hot: 64 KiB POST echo uses 11% fewer cycles.
- A task that parks now releases its unused deeper region slots and takes them back when it wakes, so a connection waiting on a send no longer holds empty slots. async at 24k connections: 11% fewer cycles per request and 19% less RSS; json-tls at 4096 connections: 4% fewer cycles per request.
-
TLS:
- Sending now has two steps:
tls.Putencrypts into a record batch andtls.Flushsends it (tls.Sendis still Put + Flush). The receive side oftls.Recvflushes after handing out each received chunk. A connection waiting on a send no longer holds its response's working memory: json-tls at 4096 connections uses 23% less memory per active connection, 9% fewer cycles per request, and serves 7% more requests per second. - The sending key schedule is expanded once per connection instead of being copied per record, and AES-GCM keeps one working area per record (the tag is computed inside the AES block). About 4% fewer cycles per request on json-tls.
- Sending now has two steps:
-
Compression (flate / gzip):
- At levels 4–6 (including level 6, the default of
http.Gzip{}), positions inside long matches are no longer added to the hash chains. Level 6 encodes JSON bodies 24–29% faster, and a json-comp-style server answers 23% more requests locally. In exchange the output is slightly larger (about +1% on JSON bodies, about +10% on inputs made mostly of short matches). Pick level 7 or higher to keep the previous search and ratio. - Match lengths are compared 16 bytes at a time (SSE2). Encoding JSON bodies is 6–9% faster (levels 3–9), and the json-comp server uses 5.9% fewer cycles per request.
- Fixed costs per encode and per block are lower: the code tables are no longer rebuilt each time, and the sort that assigns code lengths counts frequencies instead of inserting. 8–13% fewer instructions per encode and 7.5–8.4% fewer cycles per request on the json-comp server. The output bytes are unchanged.
- At levels 4–6 (including level 6, the default of
-
x86 optimizing backend:
/and%by a non-power-of-two constant become a multiplication, and short memory copies (32 bytes or less) use two overlapping loads and stores. Writing JSON numbers and building responses get faster: 4–5% fewer cycles per request on json / json-tls at 256 connections. -
net.Listensets TCP_NODELAY on the listener, and accepted connections inherit it, so small responses are not held back by Nagle's algorithm.
Standard library
- Every public std function with a body now has an
@effectsdeclaration (also shown indocs/std). The declarations are checked against the derivation when stage2 is built. net.Serve(…, maxWait:)/tls.Serve(…, maxWait:)declare the wait bound D under saturation (default 100 ms). A connection's task and its children run within D of becoming runnable. When tasks with different D share a worker, the bound is the largest among them. It has no effect without saturation.task.Ledger()gainsmaxWait(the longest wait from becoming runnable to running, in ns; reading resets it to 0) andaged(how many times the bound kicked in — a sign of saturation). In operation: a growingagedmeans saturation; amaxWaitfar above D means something is wrong.http.ServeFlushed: ServeWith with two-step sending — the responses for one received chunk are queued with put and sent together (meant for TLS connections). Seetls.Put/tls.Flushabove.binary.MatchLen(b, i, j, n): the length of the common prefix of b[i:i+n] and b[j:j+n]. The analyzer rejects secret values as arguments (the comparison takes variable time).builder.PushWord(b, w, k): appends the low k bytes of w, lowest first (the same result asPushandw >>= 8repeated k times).
Tools
sober fix --effects --std <dir>reads the embedded std and writes the derived@effectsonto the public functions that need one (for std maintenance).
Runtime
- The runtime's depth invariant (a task never skips a depth when it enters a region) is now always checked. A hole of the kind fixed in v0.6.2 becomes a defined death —
panic: RegionDepthSkipped{} (runtime invariant broken in rt.slot_new — please report)with exit code 81 — instead of silently wrong values. The cost is within measurement noise.
Documentation
- Security guide (
docs/guide/security.md, Japanese originalsecurity.ja.md): what the language and std prevent, what they don't, and what is left to you, in 12 sections. It opens with a quick table of three outcomes (cannot happen / becomes a defined death / remains) and a real-world vulnerability (an out-of-bounds write in Node.js's Brotli decompression) worked through in Sober. Code examples are the real files underexamples/security_*. - The benchmark numbers and charts in the public README were re-measured on a 16-vCPU GitHub Actions runner (AMD EPYC 9V45). The measure changed from local cycles to CPU time (task-clock — the median of per-round paired ratios). Across two runs of the same tree, 21 of the 26 ratios stayed within ±3% and every win / tie / loss call matched. 1BRC, binary-trees and logagg move by up to ±10% from day to day, and external sort and k-nucleotide against Rust by ±3–4%;
bench/README.mdnotes this.
v0.6.2
A hotfix for v0.6.1, together with the first part of function effects in the analyzer.
Language
- Function effects
@effects(checked bysober check— build acceptance is unchanged):- The analyzer derives each function's effects from what it calls:
block(may wait — task waits, channel operations),io(talks to the outside of the process),panic(an explicitpanic(…)) andnondet(values that vary even with fixed input — time, timing). - A declaration is a claim about the derivation: prefix a function with
@effects(io)/@effects(!block), or suffix a named function type withtype F func(…) R @effects(…). A list is either all positive or all negated;@effects()means no effects. - A derived effect outside the declaration is rejected at the declaration, naming the first call that brings it in. Values of a named function type are checked against its contract.
- Declarations are optional for now.
- The analyzer derives each function's effects from what it calls:
sober check <entry> --effects-dumpprints the derived effects.
Tools
sober fix [--effects] [--errors] <entry>writes the derived@effects/@errorsdeclarations onto public functions that have none, and narrows word-only lists that are wider than the derivation. Negated lists, lists naming function types, internal functions and declarations that the derivation exceeds are left alone.
Analyzer
- The lease-window performance lint now uses the derived effects (
block) instead of its own check. It also sees waits through function values, so the set of warnings can change.
Fixes
- Fixed memory corruption in programs that use std/task with two or more workers and io_uring available (the default on a multi-core host, unless seccomp blocks io_uring as Docker's default profile does): when a task other than
maincalledio.ReadFileand the read had to wait, the runtime could release regions that were still in use, and two tasks could end up sharing the same memory. The symptom was silently wrong values or unexpected panics. The bug came in with v0.6.1.
Testing
- New gate
scripts/nextgen.sh: builds the compiler with the current runtime (with a runtime invariant check) and runs its own parallel build and check with 1, 2 and 8 workers, so runtime changes are exercised by the compiler itself before they reach a bootstrap step.