# Xo 0.39 release notes


**Downloads:** [Xo 0.39.0 on GitHub](https://github.com/xo-lang/xo/releases/tag/v0.39.0),
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](/docs/getting-started/#install) 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 `Token`s 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`.

