Xo is experimental. The source is now public on GitHub, or try Xo in your browser.

Xo 0.39 release notes

released 2026-11-05 · tag v0.39.0 · all releases

Downloads: Xo 0.39.0 on GitHub, with archives for macOS (Apple silicon and Intel, signed and notarized), Linux (arm64 and x86-64), and Windows (x86-64, Authenticode signed), and a SHA256SUMS file. Getting started shows how to install one. These notes cover everything since 0.38.1.

Xo 0.39.0 adds three language features for code that talks to real services: an enum that keeps values it does not list, for over anything with a next() method, and scope as an ordinary name outside a scope expression. JSON gains a token reader and writer in Go’s Decoder.Token style, use go binds embedded struct fields, and most of the speed Intel Macs and x86 Linux lost after 0.35.0 (map1M, json10kx20, task spawns) is back.

Six changes can affect existing programs: a use go binding names a Go scope as scope (it was scope_), std/jwt refuses an aud list holding a non string, std/archive returns no data for a directory entry and refuses a 65,536th zip entry on every backend, the HTTP client sends a request path as written, and a nested module must be required.

Breaking changes

  • use go: a Go name scope is bound as scope, no longer scope_ (decision 0497: scope is a contextual word now); a program that called x.scope_() on a Go binding calls x.scope(). Bindings are made again on the first build (binding format 36).
  • std/jwt: an aud list holding a non string is refused when the audience is checked, as golang-jwt does; a token that passed with such a list fails with JwtErr.Claim.
  • std/archive: a zip or tar entry whose mode says directory returns no data, and a NUL byte in an entry name is refused as contains a NUL byte (was leaves the archive).
  • std/archive: zip and zip_writer refuse a 65,536th entry, or an archive past 4 GB, on every backend. On the Go backend they wrote a zip64 archive there and succeeded; the native zip wrote a wrong entry count. Xo writes no zip64 yet (decision 0499 proposes it).
  • Modules: a module that imports a package of a module nested in its own directory must require it, as core 4.2 says: xo check now reports XO1306 for such an import (it resolved silently from the subdirectory before), and xo mod tidy keeps the requirement.
  • std/http: the client sends a request path as written (Go’s RawPath): http://h/b/a%20b%2Bc goes out as a%20b%2Bc, no longer a%20b+c, and %2F no longer becomes /. A server that relied on the old normalization sees the path the program wrote.

Language

  • An enum that keeps values it does not list (decision 0493): a valued enum may end with Unknown(value: Str) (or Int); E.from(v) returns the catch-all for an unlisted value, E.known(v) is the strict E? lookup, and derive Json decodes an unlisted value into it and writes it back unchanged. For open enums such as S3’s StorageClass.
  • for over a next() method (decision 0494): for x in v iterates any value whose type has next() -> T?, and for x? in v one whose next() returns T? ! E, propagating each step’s error as ? does. sql Rows, multipart’s PartReader and xml’s Reader iterate this way unchanged.
  • scope is a contextual word (decision 0497): scope NAME { is the scope expression as before, and anywhere else scope is a name (a field, parameter, local or function, such as SigV4’s credential scope). Xo has 27 keywords.
  • A method that implements a prelude interface is callable across modules without pub (decision 0496): display of Display or Error and debug of Debug, as they already were through the interface; other non pub methods are still XO0605, which now carries a fix that adds pub.

Standard library and use go

  • JSON token by token (decision 0495): json.tokens(data) and json.token_reader() give Tokens with the state machine, errors, more and offset of Go’s Decoder.Token, plus skip and value; json.json_writer() writes one a call at a time in encoding/json’s forms. to_value and from_value no longer go through JSON text: 0.144 and 0.757 of their time before.
  • use go binds embedded struct fields (decision 0491): zap’s observer.LoggedEntry gets le.entry() and the promoted le.message() and le.set_message(s), by Go’s selector rules. xo check warns (XO1107) when a List, Map or Set given to Go as any is a copy Go may change unseen. A use go program built natively reports one error per use go line instead of one per Go type (the owner’s jwt program: 5 errors to 1).
  • crypto.ecdsa_private_key(alg, d), an ECDSA key from its raw scalar with Go’s checks, and CRC-64/NVME (Poly.Nvme, S3’s default checksum).
  • xo mod graph [--dir=<dir>] [--json] prints the requirement graph as Go’s go mod graph does; --json adds the version selected for each module.
  • xo doc std lists the std modules the running xo ships, each with its first sentence.

Speed and memory

  • Compiling a function with a very long list literal (over about 1,000 elements) is no longer slower than 0.38.1: the cold json std tests run 1.007 times 0.38.1’s instructions.
  • Intel Mac and x86 Linux speed recovered (the owner’s v0.36.0 and v0.38.1 evaluations): json10kx20 84 to 47 ms on linux/amd64 and 154 to 124 on darwin/amd64 (decision 0492), then 122 to 105 ms on darwin/amd64 with the reused pages kept; map1M on linux/amd64 0.905 of the wall time and 0.902 of the peak memory; darwin/amd64 map1M 145 to 138 ms and sort1M 90 to 83 with darwin’s 64 KiB size classes; spawn100k on darwin/amd64 39 to 30 ms (Go 26).
  • bcrypt at Go’s speed on linux/arm64: hash 0.998 and compare 0.999 of Go’s wall time (were 1.19 and 1.20), from bounds checks proved away by a length guard.
  • nbody on arm64: a loop carried pair of Floats stays in one vector register, 0.98 to 1.01 of Go (was 1.02 to 1.03) on linux/arm64.
  • std/multipart parse 0.222 of Go’s mime/multipart (was 1.399), a text log record 0.62 of its time on the Mac (one write per line), template render 0.923 of before, url escape 0.70, mime format 0.625, rate allow 0.50, RSA sign4096 0.868 of before.
  • std/metrics: a labelled counter increment 4.38 to 3.67 and a histogram observation 3.51 to 2.68 of client_golang’s time (bench/results/std-twin-metrics-2026-11-04.md).
  • Sorting on x86: a Bool compare under a negation no longer keeps a set-and-test sequence; sort1M on linux/amd64 0.945 of its time, 83 to 78 ms on the darwin/amd64 laptop (Go 74).
  • WebAssembly: a wasip3 http component serves several requests at once per instance; a wasip3 proxy’s peak memory at 256 connections fell from 2.36 GB toward Rust’s 146 MB (322 MB measured with a 1 MB stack); an HTTPS client component is smaller by the TLS, SHA and frame changes (9 KB, 52 KB, 61 KB, 6,014 bytes); on wasm a certificate’s expiry error shows the time in UTC.

Security

  • A native HTTP server streaming a request body to another task no longer races the connection (present since 0.36.0): with keep-alive and two or more threads, a read still running on the other task (a reverse proxy’s full duplex upload, decision 0433) when the handler returned could take the next request’s bytes on that connection as body bytes: a reverse proxy forwarded them upstream as part of another client’s upload (reproduced on linux/arm64 in plain builds). The server now finishes a streamed body under the body’s lock, as net/http’s body.Close does, and a later read gets “http: invalid Read on closed Body”. Found by ThreadSanitizer.
  • An HTTP/2 server’s header list limit follows with_max_header_bytes, as Go’s does (that limit plus 320 bytes): the native server advertised and accepted 1,048,896 bytes of headers per request on HTTP/2 whatever the limit, so a server held to 8 KB on HTTP/1.1 took 1 MB on HTTP/2. Fields that add up past the limit now get 431, and one field longer than it ends the connection.

Fixes

  • Checker: a node past 1 MiB of a source file could take another file’s checker records, so a call could resolve to the wrong function with no error (or fail with XO1201); present in every earlier release and needing a .xo file over 1 MiB (the AWS SDK’s generated S3 client hit it). Nodes are now keyed by their offset in the run’s sources.
  • Security: xo check’s module cache no longer keeps a passing result after a vouch line is removed or changed in xo.mod (builds were never affected).
  • std/rate: cancelling a reservation that waits forever (a limit of 0, or a tiny one) no longer stops the program with an integer overflow; the limiter’s times saturate as Go’s do, and its tokens match golang.org/x/time/rate to the bit (200 random scenarios on the native and Go backends).
  • From the owner’s v0.38.1 evaluation: HTTP/2 full duplex over TLS sends each piece of a piped body as it is written (b24); jwt exp, nbf and iat past 2262 no longer overflow (b25); a failed std store build no longer turns separate compilation off for every later build of an intact install (b26); a surrogate from decoded input no longer faults in idna (xn--ib9b) or regexp (\x{D800}) (b27); the Postgres driver’s copy_from stops and returns the server’s error when the server refuses the COPY.
  • WebSocket: a server closing a connection with 1009 lingers so the client gets the 1009 instead of a reset (Node’s client: 1006 on 47 of 600 oversized messages before, 0 after).
  • Compiler: a Map or Set literal where an optional collection is expected, another module’s function used as a value, and a value converted to an interface as a match arm’s value now build instead of failing with XO1201 (all found by the AWS SDK agent).
  • Native runtime: an invalid XO_HEAP_MID_MAX now says so at start instead of being ignored silently. A CPU profile of a --race build no longer stalls the program (debug_routes: 4 hangs in 1600 runs before, 0 in 3200 after, linux/arm64).
  • Tools: xo def, refs, type, doc and symbols read the std of the tree they look at; diagnostics name a module’s own error type (AwsSigErr) and explain T ! E in a struct field; the build cache no longer drops and rebuilds entries in a program that loads two modules declaring a type of the same name, and a front end cache entry is keyed by how the program names its modules, so an entry recorded where a module name was not shared is never replayed where it is (it could call the wrong function); xo mod tidy no longer removes the requirement of a nested module.
  • Go backend: a parenthesized value converted to an optional ((Int.parse(s) ?? return Err(...)) into an Int? field) builds instead of failing go build.
  • The full list is in CHANGELOG.md.

Also in 0.39.0 (listed 2026-11-05)

These five changes shipped in 0.39.0 but were filed under 0.38.0 in the changelog, so the notes above missed them. The changelog check now fails on an entry filed under a release it did not ship in.

  • A write to a shared List, Map or struct stays in place (decision 0488): a container held by one holder through Mutex.with no longer gets walked whole at each unlock. A 100,000 element Mutex[List[Str]] written 20,000 times went from 2.55 s to 0.01 s on the Mac; httpsession store 0.919 and store100k 0.876 of their time (max RSS 0.541) on linux/arm64.
  • crypto.hmac with SHA-256 uses the one shot HMAC: bench/perf/otp sha256 0.82 of its time before, on linux/arm64.
  • html.escape_string copies runs and replaces in one store: 0.85 of its time before, on linux/arm64.
  • deflate assigns Huffman codes more cheaply, with unchanged output bytes: bench/perf/archive zip 0.92 of its time before, on linux/arm64.
  • ECDSA verification uses sliding windows over odd multiples: jwt es256_verify 0.96 of its time before, on linux/arm64.

Xo against Go

Measured before the cut on main 9d70f9b6c against Go 1.26.1; the full report is bench/results/release-vs-go-v0.39.0-precut.md.

  • Run benchmarks (darwin/arm64, Xo’s time over Go’s): 21 of 24 rows are faster than Go, from 0.025 (coll branch) to 0.971 (llvm collatz); nbody is 1.054 and rwmutex 1.001. fib read 1.351 in this run, taken at load 5, but a direct 9-round comparison of 0.38.1 against this build reads 0.990 on darwin/arm64 and 1.002 on linux/arm64, so fib is unchanged since 0.38.1.
  • micro13 on four platforms (main’s time over Go’s): json10kx20 0.29 to 0.38, strbuild1M 0.25 to 0.36, map1M 0.68 to 0.89. Xo is still behind Go on spawn100k everywhere (1.14 to 1.27) and on sort1M on darwin/amd64 (87 against 74 ms).
  • HTTP server (linux/arm64): 1.28 times Go’s requests per second on /hello with 32 connections (355,324 against 277,209) and 6.0 times on /alloc (168,663 against 28,095), and main is 0.995 to 1.000 of 0.38.1 on every row.
  • Tests: xo test of the json std module cold takes 0.495 of go test’s time, the edit 0.303.

Known issues

  • Shutdown hang (native backends, 0.36.0 to 0.40.0; found 2026-11-05): a native HTTP server with idle keep-alive connections can hang on shutdown (SIGTERM, or a stop request) when a connection answered a request in the 20 ms before the stop; serve then waits until the grace period ends, or for ever without one. Fixed in 0.41.0.
  • Security (0.36.0 through 0.39.0, native and LLVM backends): an HTTP server whose handler sets response trailers (BodyWriter.set_trailer) frees an internal block at the wrong address on every such response, which can corrupt the runtime’s heap; any client that requests such an endpoint triggers it. A server configured with Server.with_h2_config makes the same mistake once, when it closes. Workaround: do not set response trailers from a handler. Fixed after 0.39.0.
  • xo run --watch restarts the program on a comment-only edit (the build info records source hashes).
  • A program run with XO_PROF=cpu can die with SIGSEGV in the profiler’s signal handler (cabi.on_prof) on linux/arm64. Seen with spawn-heavy programs at 1,000 Hz: 1 in 30 runs with 0.39.0. The program’s own behavior is unaffected without XO_PROF. Fixed after 0.39.0.
  • A program that declares its own type named Select (a struct, enum, newtype or valued enum) and uses select, its own or one inside std such as http.PipeWriter.write, fails to build on the native and LLVM backends with “the runtime call rt.select_new returns a Select”: the type takes the place of select’s runtime handle. Present since select came to the native backends. Fixed after 0.39.0 (5dd0b3f4e).
  • Under the test network (xo test without --real), http.Client.open of a request with a streamed body (http.pipe) is not full duplex: the handler does not read the body as it is written, and a streamed response’s writes do not reach the client until the end, so a test whose handler reads and writes at once deadlocks (on the Go backend the test scheduler reports a deadlock). Fixed after 0.39.0 (0e1d7b177).
  • A native build can fail to link with a duplicate _xo_f_zero__val_... symbol on the first build after std’s separately compiled variants are cached (the shared zero value of a fault exit was a strong symbol in both the program and std’s object). Workaround: XO_BACKEND_CACHE=off, or build again. Fixed after 0.39.0.
  • On native arm64 only (LLVM and amd64 are unaffected), a program holding a very large value can fail to build: a box holding a value of about 520 words or more (assembler error sub sp, sp, #N), a value of about 2,050 words (“reference counting of T (too many words)”), == on a struct of 254 words or more (“equality of T (too many words)”), or a function holding a value of about 3,600 words (“frame of F too large”). Workaround: --backend=llvm, or keep the value behind a List or Map. Fixed after 0.39.0.
  • Two types with the same name from different packages (two structs or two enums named Provider, say), each boxed into an interface anywhere in the program, collide: the native backend fails to build with XO1510 (“a function disagrees with this build’s definitions”), and the LLVM backend builds a program that reads one type’s value as the other’s (wrong results or a crash). Errors that reach main’s ! through ? are not affected. Workaround: rename one of the types. With the AWS SDK this includes an app type named Cache, Chain, StaticProvider, FixedResult or AssumeRoleProvider.