All notable changes to NURL — Neural Unified Representation Language — are documented in this file.
The format is based on Keep a Changelog, and this project adheres to Semantic Versioning.
The two holes 0.79.1 listed under Known issues are closed (h378–h383), and so is one found on the way (h384–h388); no hole is known open.
The TLS handshake's elliptic-curve work got faster. X25519 and P-256 now run their field multiply and square as compiler-emitted mulx/adcx/adox blocks on the ADX tier. They invert by constant-time divsteps instead of Fermat chains. On a Zen 3 runner against aws-lc:
(was 15.1, aws-lc 8.74).
77.4 µs (was 114, aws-lc 51.4).
to 96 %, and with X25519 from 84 % to 103 %.
statement** (Agora #138). ( string_data ( mk ) ), a helper handing back a view of its parameter, or a closure over it: the temporary is released once a call further out is done with the view, as before. Kept past that call — bound (: s v ( string_data ( mk ) )), returned, assigned, stored, taken by a join, or handed to a callee that keeps it — the view left the temporary owned by nothing, and it leaked. That is now a compile error naming the call; bind the argument to a name first (: String t ( mk ) : s v ( string_data t )). Only a temporary the caller owned is refused: a value the inner call lent (vec_at, arc_get) is not. A view-typed result that, by the callee's summary, holds nothing of the argument (→ s over a literal) has its temporary released right after the call.
UdpSocket closes itself (Agora #139). It is a shared handle with
a drop, as TcpConn is: its last owner closes the socket, and udp_close only stops it early — every copy then sees it closed. Calling udp_close is optional, and code that called it exactly once is unchanged. quic_server_new takes a share of the socket it is handed, so the socket stays the caller's, as its contract says.
compiler emits nurl_p256_mulm / nurl_p256_sqrm: on x86-64 one inline-asm block each, on the ADX tier (mulx, with adcx/adox carrying the low and high halves of a round on two flags) or with mulq, using P-256's sparse Montgomery reduction; the square takes its six cross products once. Constant time, branch-free. Other targets keep the portable NURL form, which a new test holds the kernels to (p256_kernel_agrees: 120,162 cases, 0 disagreements). On Skylake (ADX): ECDH shared 134 → 97 µs, keygen 30 → 22 µs, ECDSA sign 49 → 38 µs.
nurl_x25519_mulm / nurl_x25519_sqrm: the 4×4 product of GF(2^255 − 19) and its 2^256 ≡ 38 fold as one block each, mulx with adcx/adox on two carry chains, a dedicated square. The ladder and the fixed-base keygen walk run on them where the CPU has ADX (a buffer of elements, the ladder kept as the point it added to and the one it doubled, so a step swaps nothing and doubles the one the bit asks for by mask), and everywhere else keep the register form. Constant time. x25519_kernel_agrees holds the kernels, the ladder and the walk to the register form (120,576 field cases, 144 scalar multiplies, 0 disagreements), and crypto_tier_ir.sh runs it with the tier on and off. On Skylake: X25519 shared 45.4 → 35.1 µs, keygen 16.2 → 15.0 µs.
P-256's field and P-256's group order invert with a new constant-time Bernstein–Yang inverse (std/modinv, the formulation of libsecp256k1's modinv64: ten rounds of 59 divsteps, a fixed walk) instead of Fermat's a^(m−2), a serial chain of ~265 multiplies. The signed products it sums come from a new nurl_smulhi (one imul). modinv_agrees holds it to std/bigint's extended Euclid for the three moduli (1,253 inputs, the edge set and 0 included). On Skylake: ECDSA P-256 sign 38.3 → 29.3 µs, X25519 keygen 14.9 → 12.9 µs, P-256 keygen ~22 → ~19 µs; without ADX (Haswell) X25519 shared 55 → 48 µs and P-256 keygen 28.6 → 22.8 µs.
sub; sbb chain on x86-64.
nurl_subb_lo / _hi lower through llvm.x86.subborrow.64: the generic borrow test was rewritten by LLVM into a shape it no longer chains, and a four-word subtract took 42 instructions where 23 do. Haswell: RSA verify −20 %, RSA-PSS sign −4 %, P-256 ECDH −5 %. X25519's square takes its cross products in an order that needs one accumulating pass, not three (latency 14.7 → 12.9 ns). Skylake: X25519 shared 33.3 → 31.7 µs, P-256 ECDH 87.5 → 82.0 µs.
walk scans all eight entries of a row for each of its 64 digits; the scan now takes six 16-byte vectors per entry (nurl_v128_and / _or under the entry's mask) instead of twelve words — still every entry read, the digit only reaching masks. Skylake: keygen 15.3 → 13.4 µs.
doubling and the mixed addition fetch each coordinate's and each temporary's data pointer at the top and run the field operations on limb pointers (__p256_mul_p / _sqr_p / _add_p / _sub_p), where every operation re-derived three Vecs' data pointers, each with its null test. Skylake: P-256 ECDH 80.8 → 77.4 µs, keygen 16.8 → 15.9 µs, ECDSA sign 27.3 → 26.5 µs.
#142). spawn / spawn_owned / spawn_joinable / thread_spawn* ran on a copy of the closure's environment, and the caller dropped its own right after the call. A closure literal (or a closure a call returned) written as the argument is now handed over and adopted in place — no second environment, and no share and release of each captured handle. 200k fiber spawns: 437.4M → 416.8M instructions (−4.7 %).
another.** nurl_println writes its text and its newline as two stdio calls, and on native targets nothing held them together: two workers printing a line each could write text, text, newline, newline (16 000 lines from eight fibers on four workers: ~800 torn). It, and every print, now holds stdout's stream lock across the whole write — skipped while glibc reports one thread, as its own stdio does — and the output-capture buffer only grows under it. The test corpus's fiber_owns_option_payload failed on this once in CI.
UdpSocket read after udp_close no longer reads freed memory,and one never closed no longer leaks its record and fd** (Agora #139, h382, h383).
panics at the crossing** (Agora #140). A closure handed to spawn_owned (or a thread) that captured a match's payload c together with an owned local was built owning a copy of c while the option kept the original, and the crossing check saw two owners. The option's owner now moves with the payload, also for a binding read out of one (: TcpConn c ?? cr { T x → x … }); an option matched again after its payload went to the fiber still panics at the crossing, as it should. A spawned closure's env takes a share of its own only of a handle the crossing check follows; a Channel, Mutex or Atomic it captures is shared once, by the spawn's copy, which also drops a share and a release per spawn that a closure moving a capture used to pay (Agora #142).
longer leaks** (Agora #138, h378–h381): the program is refused, above.
caller's argument in every spelling** (Agora #141, h384–h388). Bound to a local before the ^, joined (^ ? c f g), given a new value, or over a local view of the parameter, it was not recorded as one: the caller could release the argument and then run the closure, which read freed memory. This predates 0.79.0.
The safety patch. 0.79.0 said no hole was known open while two were.
? statement whose arm ended in anassignment from a join.
Both are closed (h371–h377), and so is a binding given its own value back through a join.
The I/O layer no longer pays for, or starves on, unusual waits:
used to grow a table to gigabytes.
255.
udp_bind host 0 no longer hands out a port another socket holds.
Two holes found on the way stay open and are listed under Known issues: a UdpSocket used after udp_close or never closed (Agora #139), and a binding that views a call's temporary argument, a leak (Agora #138).
UdpSocket read after udp_close reads freed memory, and one
never closed leaks its record and fd** (Agora #139; tools/fuzz/holes/open/). The handle is the runtime record's address. It has no drop, and its "close exactly once" contract is not checked. This predates 0.79.0, and it was found by the new UDP tests' LSan run. The fix gives it the handle-with-a-drop shape every other resource has. That is written, but it waits on #140: a way to hand a fiber the only owner of a handle taken out of an option binding. Close each UdpSocket exactly once and do not use it after.
(Agora #138; tools/fuzz/holes/open/). The pattern is : s v ( first ( string_from … ) ), where first hands back a view of its argument, and it includes a closure whose env views such an argument. The temporary must outlive the binding, and the borrow checker has no name for it with which to keep the view from escaping, so the compiler never drops it. It is a leak, never a use after free. Bind the argument first. This was found while testing #131, and it predates 0.79.0.
archive.ubuntu.com is tried before the Azure mirror, which stalled on them four times in one day.
? / ?? arm no longer has its value
dropped by the statement** (Agora #132, h371, h372). nurldb's sql_test segfaulted on SELECT … WHERE id > 100, and its __pick_access, safe code, handed back an Access whose key it had freed. An assignment publishes the stored value's type, so an arm ending in one is value-typed, and the statement joins the arms' values. The arm's ownership bit was the right-hand side's. With a ? or ?? on the right (= lo ? c ( f ) ( g )) it said "owned", so the statement dropped its discarded value on that path: the value lo had just received. Locals, fields, Vecs, Strings, owning structs and options were all affected. An assignment's value is now lent to whatever reads it: the target owns it.
= x ? c x ( f ) no longer leaves x holding freed memory (h373,
h374). On the path that chose x, the join lends x's own value. The assignment dropped x's old value by its flag and then stored the join's result, which was the buffer it had just freed. The same held for = x ?? o { T v → v F → x }. Where a join that may select the target lends, the value is now copied in, so the target owns what it holds on every path. A value that cannot be copied is refused there instead. compiler/tests/arm_assign_join_tail_lent and assign_self_through_join cover each shape. All of nurldb's tests (151) now run ASan- and LSan-clean.
The panic journal had no entry for a closure, so a panic that unwound past one skipped its drop. That leaked the env and everything it had captured or adopted, for every kind of closure: - a literal handed to a call; - a bound closure; - a sink closure parameter; - a closure a call returned; - the closure field of a struct literal argument.
#121's crossing check, which panics in thread_spawn, leaked a whole PNCounter that way. Each function's closure bindings are now registered once, as a frame table of value and owner-slot pairs (nurl_jframe_clo_drop). A panic drops the value's env where the slot says the binding owns one, as the binding's scope exit would. An argument's closure temporaries and a struct literal argument's owned fields (its Strings, Vecs and closures, which leaked the same way) are journaled while the call runs. A binding's slot is now cleared before its env is dropped, so a capture's destructor that panics mid-drop leaves nothing to drop twice. compiler/tests/panic_reclaims_closure_envs covers each shape, and send_check_crossings now runs LSan-clean.
costs nothing** (Agora #133). The Linux netpoller's table is indexed by fd, and it grew to fd + 64 entries before epoll was asked whether the fd existed. wait_readable on 300000000 (a socket handle passed where its fd belonged) took 56 s and 19 GB, and a pointer-sized value aborted. Now: - A closed fd never grows the table. - A negative fd is no longer a pure timer that waits out its whole timeout. - An fd past INT_MAX is no longer narrowed onto some other fd.
Each is answered "ready", as poll(2) answers an fd it cannot poll, and the caller's read says what the fd is. The table now grows by doubling. On Windows a number that names no socket is answered the same way without reaching WSAPoll. WSAPoll could hold the whole call to its timeout before saying so, and every other waiter with it. compiler/tests/wait_unpollable_fd holds it: 0.1 s and 2 MB.
macOS, Windows; Agora #133). Its poll set was a fixed 256 entries, so every waiter past the 255th was left out. New waits join the head of its list, so the oldest were the ones that starved, for as long as 255 newer ones waited. The set now grows with the waits, and a ready fd finds its waiter in constant time. compiler/tests/reactor_many_waiters (300 waiters) woke 0 of the 45 oldest before and all 45 after. A poll that fails as a whole used to end the reactor thread, the only thread that wakes a fiber waiting on an fd, and every such fiber then hung. A number that is no socket can make WSAPoll fail that way. The reactor now asks each fd on its own and reports one it cannot poll as POLLNVAL, as poll(2) does.
udp_bind host 0 no longer hands out a port another socket holds.
UDP sockets were bound with SO_REUSEADDR, the option a TCP listener takes for TIME_WAIT. On a UDP socket it lets sockets share a port, so the ephemeral allocator returned held ports: several in every 900 binds on 127.0.0.1. A datagram sent to one socket then reached the other, which looked like a lost wakeup. It also let any program bind a running server's UDP port beside it. compiler/tests/udp_bind_ephemeral_distinct checks that 900 binds get 900 ports.
The threads-and-scheduling release. Every soundness hole found so far is closed. The last open one was a handle's copies changed on two threads, and it is now checked where the handle crosses to a thread. The probes run to h370 with none open. A trait object crosses a thread only when its trait promises Send, and an ( Arc %Trait ) is readable in safe code, because arc_get lends every payload. Windows x64 gets fibers: a Win64 context switch, a WSAPoll reactor and the resolver pool. The M:N scheduler serves fibers in the order they became runnable, and runs an accept the moment its listener is ready. Measured locally, that brings the TLS handshake latency spread under load into rustls's range. RSA-2048 signing runs on AVX-512 IFMA (~0.27 ms) and on ADX multiply rows, P-256 fixed-base multiplication is a complete Booth comb, and the unikernel's TCP stack bounds its receive window and resets a connection closed with unread data.
API changes: ext/mcp_tasks.nu hands out an McpTask (mcp_task_find / mcp_task_nth return ?McpTask), and quic_server_pump is removed. Some programs the checker used to accept were unsound, and they are now refused:
inout argument handed to a callee that replaces it(h367–h370);
Send.
A share of a handle left behind when the handle crosses to a thread is now a run-time panic that names the crossing.
Packages published with this release: anomaly 0.36.6, f5tts 0.5.1, nurldb 0.1.1, psql 0.5.1 and swarm-mcp 0.32.0 (requires 0.79.0).
nurl_mac_row: a multi-precision multiply row with two carry
chains (the ADX tier).** ( nurl_mac_row rp ap n b ) computes rp[0..n) += ap[0..n)·b and returns the word above. Where the runtime's CPUID says BMI2 + ADX (nurl_cpu_has_adx, Broadwell and later Intel, every Zen; NURL_CPU_ADX=0 takes it away), the row is inline asm on mulx, adcx and adox: the products' low halves carry through CF and their high halves through OF at the same time. LLVM cannot produce that from any IR, because it never selects ADOX. Elsewhere the row is a loop of nurl_mac steps, including on AArch64, which has no second carry flag. One asm body serves every width and every x86-64 OS. The decision is recorded in docs/adr/0001-two-carry-chains.md. compiler/tests/mac_row_agree checks every width from 0 to 40 against the nurl_mac chain on both paths.
nurl_v256_madd52lo / _madd52hi. AVX-512 IFMA
(VPMADD52LUQ / VPMADD52HUQ) on a ymm register: each 64-bit lane of the accumulator gains the low or high 52 bits of the product of two 52-bit limbs, so a radix-2^52 multiply accumulates four limb products an instruction with no carry chain. Gated like the AES tiers, on x86-64-v4 + AVX512_IFMA (nurl_cpu_has_ifma; Ice Lake, Zen 4 and later; NURL_CPU_IFMA=0 takes it away); stubs on AArch64 and under --no-cpu-dispatch. Tests that need it say requires: cpu-ifma.
nurl_v256_shrlane64 lo hi: lo's lanes 1–3 and hi's lane 0, thepair moved down one 64-bit lane — a number held as limbs across v256 registers divided by one limb.
spawn, channels, sleep_ms on a fiber
and the async network calls used to be stubs there: spawn returned a null handle and its closure never ran, so a program printed zeros and exited 0. stdlib/runtime_ctx.c now has a Win64 switch for the Microsoft x64 ABI. It also saves rdi, rsi and xmm6–xmm15, and carries the TIB's stack bounds per fiber, because the CRT's longjmp (every panic) unwinds against them. Without that, a panic recovered on a fiber ended the process with STATUS_BAD_STACK. Fiber stacks are VirtualAlloced with a no-access guard page. The I/O reactor runs on WSAPoll, and the resolver pool runs on Windows too, so a lookup on a fiber no longer holds its worker. Both MinGW and MSVC-target clang builds. docs/ASYNC.md has the platform table.
cpu-vaes, cpu-vaes512 and cpu-ifma tests used to be skipped on every Windows host. run_tests.ps1 now links a probe against runtime.o and asks nurl_cpu_x86_*, NURL_CPU_* overrides included. Its fibers default is on, as in run_tests.sh. A test that writes POSIX shell syntax into process_run_shell now says so (requires: posix-shell) instead of hiding behind fibers. On Windows, process_run_shell is cmd.exe by design.
arc_get lends the payload of every Arc, a trait object's too
(Agora #128). An Arc whose payload can hold a handle back to it (mem_ts_cyclic: a tree of Arcs, a payload holding a closure or a trait object) used to hand out a mem_dup copy, from before every Arc's payload was frozen (0.72.0). A trait object has no clone, so ( arc_get [%Job] a ) did not compile and an ( Arc %Job ) (the Rust Arc<dyn Job + Send + Sync>) could not be read in safe code at all. A copy is not what keeps such an Arc acyclic: no call may change what arc_get lends, so nothing stored through it can close a cycle. Every read is a lend now, and a cyclic payload's lend is checked like any other. A program that changed its copy of a cyclic payload now copies it itself: ( mem_dup ( arc_get [T] a ) ). compiler/tests/thread_arc_dyn_shared runs one %Job on two threads and reads a payload holding a trait object and a tree of Arcs.
aws-lc (~0.23 ms on the same CPU, an i7-11850H).** Where the CPU has the IFMA tier, std/rsa_mont.nu runs the two 1024-bit exponentiations of a CRT signature at once (_rm_pow2_ct): each operand 20 limbs of 52 bits in five ymm registers, an almost Montgomery multiply whose scalar step for one half hides in the other's vector latency. The p and q halves are computed in the 52-bit domain from entry to exit; the moduli's 52-bit forms are derived once per key. Other widths and CPUs keep the ADX rows and FIPS kernels. compiler/tests/rsa_pow2_agree holds both paths to bigint_modpow. The decision is recorded in docs/adr/0002-ifma-rsa.md.
simd @ function reaches a tier through inline helpers of inlinehelpers.** Before, only a callee that used a tier primitive itself counted, so a kernel written as helpers of helpers got baseline clones that called each helper's gated original out of line, its vectors crossing by pointer — the RSA kernel above ran at 4.1 ms that way. An inline function now counts as the body it becomes; an ordinary call still ends the reach.
ADX.** On an i7-11850H: - 16 limbs: square 245 → 203 ns, multiply 322 → 211 ns. - 32 limbs: 1.65× faster. - RSA-2048 signature: 0.79 → ~0.62 ms.
Without ADX, std/rsa_mont.nu keeps its FIPS kernels, which are the faster form on a single carry chain. crypto_tier_ir.sh holds both forms to the rsa_mont_kernels golden.
complete for every scalar.** p256ct_scalarmult_base (ECDSA's nonce point, ECDH keygen) reduces the scalar mod n, recodes it into 52 digits in [−16, 16] and adds one entry of a per-window affine table per digit, negated in constant time for a negative digit. That is 52 mixed additions in place of 32 doublings and 64 mixed additions, about 40 % fewer field operations. The table (832 points, 53 KB) is built once per process from G, in place of the 30-point hex constant of the old comb. On this machine (Xeon, 2.1 GHz): - P-256 keygen: 34.3 → 23.4 µs. - ECDSA P-256 sign: 43.6 → 31.4 µs.
The old comb accepted a ~2⁻²²⁴ chance per addition of the one case its mixed addition cannot compute (acc = entry). The new one cannot meet it, for any scalar; the proof is at the comb in std/p256_field.nu, and compiler/tests/p256_scalarmult_base_kat.nu (now 30 vectors, including the two scalars the proof names) and the new p256_comb_agree.nu (comb against the variable-base ladder) hold it.
p256n_inv_be
(k⁻¹ in every ECDSA signature) builds x32 = a^(2^32 − 1) for the all-ones top half of n − 2 and slides an odd 4-bit window over the bottom half: 253 squarings and 39 multiplies, against 255 and 70. compiler/tests/p256_scalar_inv.nu checks it against Python and over 2000 random a·a⁻¹ ≡ 1.
CHANGELOG.md alone no longer runs the compiler jobs.
arrival** (Agora #113). The fiber parked in an accept used to join the tail of the run queue when its listener became ready, like any woken fiber. It waited a whole pass of the queue before accepting, and the connections it then spawned were stamped behind everything that had become runnable meanwhile. On a TLS server under handshake load, a new connection waited one pass in the kernel's accept queue and another in the run queue. Joining the client's and the server's per-connection timestamps on the source port showed the handshake latency spread over 4–11 ms where rustls held 5–8 ms. Now an accept's wait (nurl_reactor_wait_accept, which tcp_accept_async uses) runs the fiber as soon as the listener is polled ready: the Linux netpoller hands it back to run now, the poll(2) reactor puts it at the head of the global queue, and the unikernel's socket layer wakes it to the head of the bare runtime's run queue (nurl_bare_unpark_admit). Locally (server on one core, 64 connections), handshake p99/p50 went from 2.25 to 1.57 (full X25519; rustls 1.62) and from 1.85 to 1.57 (resumed; rustls 1.46), medians of three runs. Handshakes/s, the keep-alive record layer and HTTP keep-alive and connection-setup rates are unchanged. compiler/tests/sched_accept_admission fails on the old scheduler (worst delay 80 dispatches of a 64-fiber queue hosted, 64 under nolibc) and passes on the new one, hosted and nolibc. It runs on Linux only: on FreeBSD, macOS and Windows a reactor thread polls, and the wake waits for that thread's time slice first (36–80 ms on the loaded CI hosts; agora #137).
( nurl_cpu_x86_v3 ) call is lowered i32, as the
preamble declares it.** The compiler had no return type for it, so std/chacha20poly1305.nu's # i ( nurl_cpu_x86_v3 ) (the four-way Poly1305 gate) became call i64. x86-64 read eax and got the right answer. A wasm module trapped on the signature mismatch instead, and wasmbuilder's fold of call i32 @nurl_cpu_x86_v3() to 0 never matched the call, so poly1305_mac on wasm32 printed nothing (wasm-bench's correctness gate). simd_dispatch_ir now checks the call's type.
inout argument that does not own its value is refused where a
callee replaces it** (Agora #135, h367–h370). A cursor over another binding (: ~ ( Vec i ) cur root), a field read (: ~ H h . o h) or a value arc_get lends, handed to a callee that gives the parameter or a field of it a new value, made the callee release what the owner still held. The owner then read freed memory and dropped it again; through an Arc's payload, under another thread's reader. The rule for this shape (h295) covered only a : ~ copy of a parameter, by a marker set where the binding was made. The borrow walk now refuses any argument in a borrow state, on any path, handed to a parameter its callee may replace (bck_inout_borrow_check), and checks the calls in a returned expression too. h295 is one case of it. A copy of a value with a % Drop, taken from a local that owns it outright, already held that local's drop. Such a call now makes it the owner and moves the local, so reading the local afterwards is an error (h370), where it used to read freed memory. The rule found the shape in three packages: gRPC's test fixtures, anomaly's file importer (0.36.6) and f5tts's model thread (0.5.1). There, a payload or a sink parameter was copied into a mutable binding and handed to a replacing call. diag_inout_borrow_replaced and inout_borrow_takeover show both sides.
(h364–h366, found with Agora #128). A dyn call's virtual callee had the union of its impls' summaries, with three gaps. A receiver declared inout is handed the object's box, which every copy of the object reaches, yet a write to it counted as no change: an inout method changed what a read guard or an Arc lends. The replacement summary was not in the union: a view read out of the object outlived a method that gave the field a new value. And the union came after the mutation summaries had closed, so a helper handing its parameter to a dyn call changed nothing as far as its callers knew. Each parameter of the virtual callee is now an edge to the same parameter of every impl, in both fixed points, and an inout receiver changes the object. diag_arc_dyn_payload_frozen and diag_dyn_inout_replace_ends_views show the diagnostics.
( mem_dup ( f … ) ) over a call that lends is a value of its own.
The borrow walk took the lend of the call inside for the binding's own. A copy of an Arc's payload was refused as a change to the payload, and a copy of a vec_at element ended with its vector. The copy now binds no lending call (mem_dup_of_a_lend_is_owned).
runnable** (Agora #113). The local run queue used to come first. A fiber that the netpoller spilled, or that a channel or mutex unparked, onto the global queue therefore waited until the local queue emptied, or for the 128th-dispatch starvation guard. Meanwhile everything pushed locally after it ran ahead: spawns, yields, the netpoller's local share. A busy worker's poll also ran the first fiber it woke at once, ahead of everything queued. On one worker under TLS handshake load this was service out of arrival order: tls-bench showed NURL's handshake p50 below rustls's and its p99 at 1.4–4.5× its p50, against ~1.3× for rustls. Now every push onto a run queue stamps the fiber from one clock, and a worker takes the older of its own queue's head and the global head. A busy worker's poll puts what it wakes at the queues' tails. The netpoller is polled every 16th dispatch whatever is queued, which replaces the 128th-dispatch guard. compiler/tests/sched_ready_order (one worker, 100 loopback TCP readers made ready in a known order behind seven yielders) measured a reader overtaken by 55 later ones before and by none after; its bound, below 16, comes from the poll cadence. Measured locally (4 vCPU, server on one core, 64 connections, three runs each), the whole request (connect, handshake, response) now has p99/p50 of 1.76–1.86 against rustls's 1.71–1.92 (before: 1.93–2.67, 11.2 on RSA-2048), with handshakes/s unchanged.
NURL_SAN=1 no longer builds a binary without AddressSanitizer on an
installed toolchain** (Agora #126). nurl.sh compiled with the bundled zig cc whenever it existed, and every install has one. zig ships no ASan runtime: asked for -fsanitize=address,undefined it keeps UBSan, drops ASan and exits 0. Neither the program nor runtime_san.o was instrumented, so a real use-after-free ran clean. tools/fuzz/holes/check.sh builds through the same path, so on a box with only the bundled zig it reported every probe as "ran clean". A sanitized build now always uses a system clang with compiler-rt. Without one, nurl.sh stops and names the reason. A runtime_san.o that lacks ASan is rebuilt, and a build whose runtime or linked program has no __asan_init fails instead of publishing. ./build.sh --san checks its stage 0 binary the same way. tools/tests/test_sanitizer_toolchain.py covers each case, against a stand-in for zig and, in CI, the real one.
Rc to another thread
(Agora #127, h344). The Send/Sync derivation walks a type's fields, payloads, generic arguments and aggregates, and a %Trait object is { i8*, i8* }, with nothing to walk: it answered Send and Sync for whatever the box held. A %Peek boxing a struct with an ( Rc i ) went into a thread's closure, and the thread and the spawner changed one non-atomic count; ASan reported a use-after-free or a double free on every run. A trait object is now Send only when its trait declares : Send (% Job [T] : Send { … }), and Sync only with : Send Sync. Through a supertrait counts too. This is a breaking change for a program that sends a trait object to a thread or a fiber, down a channel or into an Arc another thread reaches: declare the trait's promise. The promise is checked where each impl is, by the derivation: a : Send or : Sync supertrait used to demand an impl block of the marker, which the derivation never has, so it was rejected with advice to write % Send T { }. A [T: Send] bound the type fails now says what the type reaches, as a thread boundary does. Tests thread_dyn_send_trait, diag_send_dyn_object, diag_send_dyn_impl_breaks_promise, diag_sync_dyn_impl_breaks_promise and diag_sync_dyn_object_in_arc.
with every owner of that state** (Agora #121, h349–h358, h360). A PNCounter, a TcpConn or a Cell is copied by sharing. A copy made before the handle moved to a thread stayed with the spawner, and both threads changed one state with no lock (ASan: double free). Such a copy can come from an explicit share, a literal storing . h c, a share in a Vec, a channel, or a Locked's value: going in, changed under the lock, or copied out by locked_get. Any store of a borrowed handle makes one, so no checker can follow them all to the move. The crossing checks at run time instead. ( mem_send_check [T] x what ) walks T's graph: fields, payloads, Vec elements, a container's contents through its new S_sendck hook (HashMap, Set, Deque, BTree, Box), a closure's captures through a new slot of its environment's descriptor, a trait object's box through a new last slot of its vtable (filled for an impl of a : Send trait whose type reaches such a handle), and a handle's state. It counts how many places in the value hold each such handle's counted block, and panics, naming the crossing and the handle, if a block has more owners than that. Several owners of one state that all go along cross, such as a node's transport and the job node beside it holding a share of it. It compiles to nothing for a T that reaches none. thread_spawn, thread_spawn_owned, spawn, spawn_owned, spawn_joinable, chan_send, a pool taking a value back, and every Locked operation that lets a value in or out call it. The checker closes the rest at compile time: - An RwLocked's value faces the Sync question, as an Arc's payload does. Several readers reach it at once (h357). - A % Sync marker on a Locked, a Channel or a Pool no longer vouches for the value it holds. That value faces the Send question where the container is reached from several threads: an Rc in a Locked in an Arc crossed to every thread (h358). - A binding read out of a library handle field takes its struct along when a thread takes it or a sink consumes it (h356, h348). The field, emptied, holds no handle: reading it after was a null dereference. The struct is dead until it is given a fresh value.
compiler/tests/send_check_crossings covers each crossing, with the only owner and with a copy left behind.
inout parameter is the caller's binding, in a closure as in a
function** (h345–h347). Given a binding whose value another owner keeps, it stored the value as is, so two owners released one buffer. The bindings were a field read (: String z . r name … = y z) and, in a closure, a capture. It now gets a copy and drops the value it held. A closure's inout parameter given a fresh value was registered as an owner of its own, so the closure dropped the value on return, under the caller.
bindings under recover** (h359). The journal's elision decides which functions can panic from the calls they make. It knew nurl_panic, but not the runtime's other panicking entries: nurl_panic_index (every vec_at / slice_at past the end), a CPU tier's _missing gate, and the crossing check. A function whose only panic was an index dropped its bindings' registrations, and a recovered panic leaked them.
by a later panic** (h361–h363). An owned binding with a drop flag was recorded in the panic journal with the flag's value at that moment. A move clears the flag, but the record went on saying the binding owned its value: one moved into a struct literal, handed to a sink parameter or captured by a closure was released by its new owner, and again when a panic later in the scope drained the journal under recover. A trait object's box was freed twice. The binding is now recorded with its flag, which the drain reads, as a handle binding already was. Test recover_moved_drop_value.
name** (Agora #122). The capture scan took every type keyword in the body for a use of a binding: v, i, s and the other one-letter types are also valid binding names. A thread's closure that only spelled the type ( @ v i ) therefore moved the spawner's Vec named v, and the next vec_push on it was rejected as a use of a moved value. A closure that is not detached copied the value into its env for nothing. The scan now steps over each type position: a binding's type and name, call type arguments, a slice literal's element type, a select arm's type, a cast, Z, an aggregate's type, and a nested closure's parameters and return type. A : inside a call still labels an argument. compiler/tests/closure_capture_type_position covers each position.
(Agora #110, h334, h335). Two kinds of call result get an owner of their own for the rest of the scope. One is an enum a match takes and nothing binds (?? ( mk ) { … }, h233). The other is a result read for a field it cannot give up (: Conn c . ( mk ) c, h329/h330). Neither owner was in the panic journal. A panic under recover before the scope ended skipped the drop, so the value leaked and its destructors never ran (docs/MEMORY.md §7.2 promises otherwise).
Each such owner is now journaled once, by its slot and with its drop flag. The registration is made once the value and the flag are stored, and the scope's end forgets it by slot. A read run again in a loop's condition first forgets the previous round's registration, then releases that round's value. One registration per slot was a requirement: an alloca-keyed entry per round would have dropped values a later round had already released.
An enum matched in a loop's condition (~ ?? ( mk ) { … } { … }) was stored over the previous round's enum without releasing it, so every round but the last leaked. Each round now releases the one before, as a field read in a loop's condition already did. The owner's flag starts clear on every path. A match on one arm of an expression ? used to drop the slot unconditionally at the scope's end, on paths that never stored an enum too; it no longer does.
listener and UDP socket was bound with SO_REUSEADDR, which on POSIX lets a restarted server reuse its port past TIME_WAIT. On Windows it lets a second socket bind the same address while the first still holds it, so any process could take over the port. Windows never needed it for TIME_WAIT. It is now set there only for tcp_listen_opts with reuse_port, the case that asks to share a port.
signal_install_shutdown / signal_trigger_shutdown did not wake a thread blocked in accept: shutdown() does nothing to a listening socket. server_drain could not close an idle HTTP/2 connection with GOAWAY, because a receive-side shutdown wakes neither recv nor select/WSAPoll on Winsock, so it shut both ways and the GOAWAY had nowhere to go. The listener now has the accept wake channel POSIX has. A receive-side shutdown cancels the pending recv (CancelIoEx), signals a thread in tcp_wait_io and a parked fiber, and every read after it returns EOF, as SHUT_RD does.
clang.** Both were compiled out there for want of a thread-exit hook. The pthread shim now provides keys on fiber-local storage, whose callback is that hook. The keep-alive server's allocations per request on that build dropped from 8 to the ≤ 2 every other platform has.
two threads at once** (Agora #66, h336–h340). A handle over a counted block (a PNCounter, a TcpConn, a Cell) is copied by sharing, and the Send check asked only about types. So a thread could change the state the spawner was still changing, with no lock. ASan reported a double free in the counter's replica Vec. Such a handle, unless it is asserted % Sync, is now held to the same rules as a Vec: - A thread's closure moves it (h336). - A thread's closure may not capture a parameter of one, because the caller keeps it. Declare the parameter sink to hand it over (h337). - A call that keeps a closure beside a handle another thread takes it from moves the closure's captures that are not Sync. Such a handle is a channel, an Arc, a server, or one the call returns (h340). - Under the Sync question such a handle is not Sync. That covers an Arc of one (h338), and a closure capturing one when the closure is kept in an Arc or in a handle asserted % Sync, such as an HTTP server's handler (h339). - A closure kept where several threads run it may not change what it captured unless that synchronises itself (h342). A route handler that pushed to a Vec it captured grew one environment from every worker at once, a double free under ASan.
A handle made only of Arcs and handles asserted % Sync (nurldb's Store) is Sync when its fields are. It is no longer moved by a thread's closure.
The standard library's handles that are shared on purpose are now asserted % Sync, each with the reason beside its marker: TcpListener, HttpServer, QuicServer, H3Server and a router's Route. To back the markers: - HttpServer's acceptor count is an Atomic. - QuicServer's running and accepted counters are Atomic, and one loop runs at a time: quic_server_run returns at once if the loop already runs, or was stopped before it started. - quic_server_pump is removed. Nothing called it, and the loop already sends what an event callback writes. - RpcEntry (ext/cluster.nu) is asserted % Sync: an entry never changes after registry_register.
The new rules found races in the standard library, fixed here: - with_metrics incremented plain Vec i counters from every worker. Metrics now counts in Atomics. - The MCP server wrote its serving flag into a shared Vec i on every request. Its control block is now ( Vec Atomic ). - An MCP McpTaskStore attached to a server was changed by concurrent tasks/* requests with no lock. One lock now guards a store and its tasks, and every task function holds it for its whole call. The older hole beside it, a task handle kept past its task, is closed below (h343). - The tasks dispatcher read the hook and the store through raw pointers. It now uses vec_at.
A share made before the move, such as ( PNCounter_share a ) or a struct literal storing one, is checked where the handle crosses to the thread (Agora #121, above).
it has, and resets a connection closed with unread data** (Agora #74). It used to advertise the whole receive buffer however much was waiting for the application. A fast peer could therefore grow the buffer without bound and never met back-pressure. Now: - The window is the capacity minus what is queued. Its right edge never moves left, and moves right only by min(MSS, capacity/2) (RFC 1122 §4.2.3.3, silly-window avoidance). - A read that reopens that much sends a window update. - A close with unread data, or data arriving after the close, is answered with RST (RFC 9293), instead of an orderly FIN claiming the bytes were delivered. - A FIN riding on the last data segment is taken with it, instead of costing the peer a retransmit timeout.
memory** (h341). tcp_close_listener released the runtime's socket record while the handle, and every copy of it, still named it. A later tcp_accept on the same thread was a heap-use-after-free. The record now stays until the handle's last owner. A stop is idempotent and safe from any thread: an accept in progress returns NetAccept, and so does every later one. The fd is closed only once no accept is in progress, so an accept never runs on a closed or reused descriptor. tcp_shutdown_listener now releases the port too.
(Agora #124, h343). mcp_task_create, mcp_task_find and mcp_task_nth (ext/mcp_tasks.nu) handed out a task as an opaque s: the address of the record inside its rcbox, owning nothing. At the cap of 2048 tasks mcp_task_create evicts the oldest live task, and every tasks/* request sweeps the expired ones, so a handle kept across either read freed memory. A task handle is now an McpTask, one owner of the task beside the store's list: a task the store lets go of lives until its last handle goes, still readable and changeable, and is no longer found by id. mcp_task_find and mcp_task_nth return ?McpTask (F where they returned 0), every mcp_task_* function takes an McpTask, and McpTask is asserted % Sync because every function that reads or changes a task's state holds the store's lock. The record type is now McpTaskImpl. swarm-mcp 0.32.0 follows. A lookup by id now compares ids in constant time (std/subtle.nu), as the MCP server's bearer-token check does: a task id is a bearer token, and nurl_str_eq stopped at the first byte that differed.
(Agora #111, h331–h333). : String n . h name borrowed nothing in the borrow checker. The right-hand side's reads went with the row recorded before the binding's, so the borrow it decided read an empty list. n then outlived h handed to a sink or given a new value, and read freed memory (ASan: heap-use-after-free). Through a library handle (: PNCounter mine . h c), the same gap let a thread change h's counter while the spawner changed it through mine, which is the shape Agora #66 was found with.
Such a binding now borrows the field's path (h.name). A call that changes another field of h leaves it standing. A store replacing that very field hands the binding the old value, and a sink handed the binding takes the field over, as codegen already did. ( mem_take x ) makes the binding an owner. #66's probe is closed.
The summary of what a function may replace in an inout parameter now names fields (k.f), and follows calls that hand on a field. A borrow of pg.kit therefore survives a helper that rebuilds pg.zrec, a common idiom in grad's and whisper's GPU code.
nurldb 0.1.1 reads its log buffer's start before it grows the buffer. The checker still takes a change made through one field of a borrowed value as reaching the value's other borrows. A share kept by other means is checked where the handle crosses (Agora #121, above). That covers a thread's capture of the handle itself, a parameter, and a share stored before the move.
bigint_modpow ignored exponent bits above the modulus's bitlength.** Its constant-time ladder ran bit-length(m) steps, so an exponent wider than the modulus (a blinded RSA exponent, d + r·φ) lost its top bits and the answer was wrong. The ladder now covers the modulus's whole 16-bit limbs, or the exponent's when it has more: the count still never depends on the exponent's bits.
is stored** (Agora #73, h322, h324–h327). A % Drop value with no clone read out of a struct and stored into a literal, a container or another field was moved without being emptied: the struct released it again (ASan: heap-use-after-free). This happened in a destructor (@ ( Slot A ) { . g val 1 } in a drop hook, the shape of std/pool.nu's checkout guard) and with any local struct (@ Box { . p c 1 }, ( vec_push [Conn] v . p c ), = . b c . p c). A % Drop value has no empty state to leave in the field, so such a read is now a lend and each of those stores is rejected, as a sink argument already was. A returned literal may hold such a field only when a parameter lends it. One lent by a local released it on the way out, and is rejected like ^ . p c.
block** (h328). = a . p c, with p declared in an inner block, left a reading what the block's end released (ASan: heap-use-after-free). A handle field moves out there. This one cannot, so the assignment is rejected.
(h329, h330). : String s . ( mk ) name, with mk's struct having a % Drop of its own, and : Conn a . ( mk ) c or . . ( mk ) c fd, with a field that cannot be copied, left the temporary with no owner. Everything in it leaked, and no destructor ran. Such a temporary now gets an owner for the rest of the scope, as an enum a match takes does, and the field is lent out of it. Handing such a field back out of the function is rejected, as for a local.
its layout** (Agora #73). . ( mk [i] 5 ) k, with mk returning ( Wrap ?( Slot A ) ) and no top-level code naming that type, failed with "has no field 'k'": the instance was made in the body's scope and went with it, while its "already made" mark stayed. An instance's layout is now a module-scope fact wherever it is first made (nurl_sym_def_module), and a call's return type makes its instances in full at the call.
parameter.** ( Wrap ?A ), ( Wrap ( Pair A i ) ), `( Wrap ?( Slot A ) ) or ( Wrap ( @ A i ) )` in a generic signature or body was instantiated with the parameter still abstract (%Wrap__opt_A = type { { i1, %A }, i64 }): nurlc exited 0 and clang rejected the module ("use of undefined type named 'A'"). Such an argument is abstract like the parameter itself.
: ( @ i i ) f . ( mk 9 ) inner released neither the closure's environment nor the temporary's other fields. The field now leaves the temporary, which is dropped where it is read, as h266 does for handle fields. The environment belongs to the read: it is taken when the call handed its result over and copied when the call lent it. A binding, an argument, a return or a store then takes it over.
(Agora #106). A template body is stored as its tokens joined by single spaces, line breaks kept, so every line of the re-parsed body began at column 1 and every diagnostic raised there said file:line:1 with the caret under the first column, whatever the statement's indentation. Each line's first token is now padded to its source column. The columns after it were already right for nurlfmt-formatted source.
inside Guard_drop__Conn, which no call made, ended in first call site: <library handle>:0. It now reads `made for the drop of '( Guard Conn )', first needed at file:line`, where the line is the statement whose compilation first needed it. A copy or a cycle trace says so the same way.
The TLS performance release. bench/tls_bench now measures NURL's TLS against rustls on aws-lc-rs piece by piece, and most of the primitives it found behind moved: RSA signing from 450 ms to under a millisecond (fixed-width Montgomery, CRT, dedicated kernels), SHA-256 on the x86 SHA extensions (~7×), P-256 ECDH and verify twice as fast, X25519 keygen 37 % faster, ML-KEM-768 a third faster, ChaCha20-Poly1305 sealing 1.4× faster, an allocation-free key schedule, and signing keys prepared once per listener instead of per handshake. Two compiler ownership fixes (h320, h321) close leaks in ?? arms, and nurlpkg login no longer writes the registry token into the working directory on Windows.
API changes: rsa_priv_sign_key returns ?RsaKey instead of a CRT blob, and quic_tls_srv_new / quic_tls_srv_set_pq take a TlsIdent (see Changed).
bench/tls_bench and the manual tls-bench workflow: NURL's TLS
against rustls / aws-lc-rs, hyper and reqwest.** Full and resumed handshakes per group (X25519, P-256, X25519MLKEM768) and certificate (ECDSA P-256, RSA-2048) with server CPU per handshake split into the primitives' share and the rest; unloaded handshake latency; the record layer on small keep-alive requests; bulk throughput per suite, with and without kTLS; packages/http-client against reqwest; and the primitives underneath. About ten minutes of measurement; the report, bench/tls_bench/TLS_RESULTS.md, opens with a worst-first list of where NURL trails and what the gap is made of.
packages/http-client 0.3.6: http_client_set_resume — turn TLSsession resumption off for a client.
compiler/tests/x25519_wycheproof: all 518 X25519 cases of Project
Wycheproof (edge-case multiplications, twist points, low-order and non-canonical public keys), and x25519_base_agree: the fixed-base public key against the ladder for 300 scalars, seven of them chosen to walk the signed-digit recoding's carries.
Three new crypto primitives, nurl_v128_sha256rnds2, _sha256msg1 and _sha256msg2 (Intel's SHA256RNDS2 / MSG1 / MSG2), under the same contract as the AES tiers — compiled for x86-64-v2 + SHA, gated on the runtime's CPUID answer (nurl_cpu_has_sha, NURL_CPU_SHA=0 to take it away), stubs on AArch64 and under --no-cpu-dispatch — and two portable lane shuffles, nurl_v128_bswap32 and nurl_v128_alignr4. SHA is not on the AES chain (Haswell … Cascade Lake have AES-NI without it), so nurlc now keeps a tier as the set of extensions it needs: a body takes the union of what it uses and calls, a .tier copy inlines only where its set is contained, and a simd function that reaches AES and SHA is cloned, gated and dispatched on both. std/hash_sha256 runs whole runs of blocks through a SHA-NI kernel with the state in registers: SHA-256 of 16 KB 52 µs → 7.5 µs locally (aws-lc-rs 8.7 µs), which every transcript hash, HMAC and HKDF of the TLS handshake shares. compiler/tests/sha256_tier_agree holds both paths to one golden over every length 0–1100 and FIPS 180-4's million-a vector; crypto_tier_ir.sh checks the tier's IR, gates, machine code and the AES + SHA union.
std/rsa_mont's
Montgomery multiply is now FIPS (product scanning with the reduction interleaved, the column sum in registers) instead of FIOS, with a dedicated squaring (each column's cross products once, doubled) for the four in five operations of an exponentiation that are squarings; and for 16 limbs — RSA-2048's CRT halves — both are generated straight-line kernels (tools/gen_rsa_kernels.py) whose a·b and q·m sums run as two carry chains side by side. compiler/tests/rsa_mont_kernels holds the exponentiation at 15, 16, 17, 24 and 32 limbs to std/bigint. The rest of the gap to aws-lc (0.25 ms) is the per-product cost that mulx + adcx/adox remove (agora #95).
26.3 → 15.8 µs, key generation 22.2 → 17.0 µs** (local i7-11850H, where aws-lc-rs encapsulates in 15.0). Profiled with hardware counters, the time went to three scalar shapes std/mlkem had left behind its AVX2 wide layers: the NTT's narrow layers (len 8, 4, 2) and the base multiplication — now gathered into whole vectors by in-lane shuffles (permlo/hi128, unpack64, unpack32) with per-lane zeta tables built once per process (publish-once slot 12), the butterflies the same Montgomery and Barrett lanes as before; ByteEncode / ByteDecode, which ran a bit cursor whose inner loop changed trip count every coefficient — now eight coefficients (exactly d bytes) at a time through two 64-bit words, with d a literal at every call; and Compress, whose division by q in 64-bit signed arithmetic stayed a scalar loop — now in 32-bit unsigned, which the compiler vectorises. K-PKE's polynomials share one workspace allocation, and y ‖ e1 ‖ e2 are contiguous so all 2k + 1 CBD streams go through the four-way sponge (e2's scalar SHAKE256 is gone). The ACVP gate (180 cases) and the vector tests hold the bytes on both simd clones.
per-handshake key derivation.** The listener's credentials used to carry raw key bytes (an RSA key's CRT parameters packed into a blob in the private-exponent slot), so every handshake rebuilt the Montgomery contexts of p and q, and an ML-DSA listener unpacked s1/s2/t0, ran their NTTs and expanded  on every signature. Now a server identity is a TlsIdent (std/tls_server.nu: the certificate_list plus a prepared key — RsaKey from std/rsa.nu, MldsaKey from std/mldsa.nu, the EC scalar), built by tcp_listen_tls* / http3_creds_load, never written after, and shared through an Arc by every worker's handshakes. std/rsa_mont.nu splits the immutable modulus context (RsaMont) from the per-operation scratch (RsaTmp), so one key is safe to sign with from many threads at once. Locally: ML-DSA-65 sign 280 → 190 µs; RSA-2048 handshakes 1 703 → 1 858 /s (server CPU 1 736 → 1 593 µs per handshake, tls-bench smoke run). compiler/tests/sign_key_shared_threads signs from eight threads with one shared RSA-CRT and one ML-DSA key. API: rsa_priv_sign_key returns ?RsaKey (the CRT blob is gone; RsaPriv carries p q dp dq qinv); new rsa_key_crt, rsa_key_plain, rsa_pss_sign_sha256_key, mldsa_sign_key, mldsa_sign_with, tls_ident_ec / _rsa / _mldsa, tls_accept_ident, tls_accept_ident_dual, quic_creds_ident, quic_creds_set_pq_ident; quic_tls_srv_new / quic_tls_srv_set_pq take a TlsIdent. The byte-vector entry points (tls_accept*, rsa_pss_sign_sha256, mldsa_sign, quic_creds_new) remain, preparing the key per call.
93 % of aws-lc-rs (5.45 µs).** Poly1305 (std/chacha20poly1305) now runs four blocks at a time on 256-bit vectors where the CPU has them: radix-2^26 limbs, one vpmuludq a product, two accumulators taking the 64-byte groups in turn under r^8 and the last two groups under per-lane powers r^8 … r^1 (the OpenSSL / aws-lc AVX2 shape), with the key's power table built once per MAC into the record's scratch — 16 KB in 1.75 µs instead of 3.8. The scalar radix-2^64 blocks stay for short inputs and CPUs without AVX2. The eight-block ChaCha20 kernel went from 4.35 to 3.95 µs (79 % of OpenSSL's AVX2 code) through the compiler fixes below. New gates: chacha20poly1305_wycheproof (all 325 Wycheproof cases, incl. modified tags, through the Vec API and in place), chacha20poly1305_sweep (every length 0–1100 plus 4 and 16 KB, with and without AAD, against a Python RFC 8439 reference; each record also opens back and fails with one tag bit flipped), and machine-code checks in simd_dispatch_ir.sh.
nurl_v256_add64,
_mul32u, _shl64, _shr64, as v128 has — and three lowering fixes that the obvious IR missed: a rotate by a whole number of bytes (nurl_v256_rotl32 by 8, 16, 24) is emitted as a byte permutation, because as a funnel shift the x86 backend reassociated ChaCha20's rotl(rotl(t, 16) ^ a, 8) into a third shuffle per pair, twelve more a double round on the port that runs them; every broadcast and set is built on poison, not undef (an undef lane kept a mask on a broadcast vector-wide, where the backend cannot see it from inside a loop); and Poly1305's kernel carries its accumulator across the loop uncarried, so each multiplied limb is derived — mask and all — in the block that multiplies it (a loop-carried limb lowered as three multiplies and two shifts).
8.2).** The public key (x25519_base) ran a 4-tooth comb: 64 windows, each a doubling, a scan of sixteen table entries and a mixed addition. It is now ref10's fixed-base method: the clamped scalar recoded into 64 signed radix-16 digits, a table of j·256^k·B (k < 32, j ≤ 8) in affine niels form built once per process with a single batched inversion, then 64 mixed additions against one masked row scan each and only four doublings. Both X25519 paths now run on one scalar field of four 64-bit words, lazily reduced (2^256 ≡ 38), in place of five 51-bit limbs: a multiply is 16 + 4 products instead of 25 and a square 10 + 4 instead of 15. The ladder (x25519) is unchanged in speed — it is at what LLVM makes of portable 64-bit code, which a clang build of the same algorithm confirms — and its swap is now one per bit (RFC 7748's) instead of two. The rest of its gap to assembly implementations is the dual carry chain (mulx + adcx/adox), agora #95.
adc / sbb** (github#1243). nurl_addc_lo/hi and nurl_subb_lo/hi now detect the carry with plain 64-bit overflow / borrow tests instead of an i128 round trip, so LLVM fuses a chain of them into one add/adc (sub/sbb) run with no spills — every multi-precision routine built on them benefits (RSA, P-256, X25519). X25519's field add, subtract, reduce and multiply-by-121665 fold the second 38 in place; X25519 shared on the tls-bench runner went from 61 % to 63 % of aws-lc-rs. x25519_vectors adds RFC 7748 §5.2's iterated test (1 and 1 000 iterations).
either path.** The variable-base scalar multiply (p256ct_scalarmult: ECDH against a peer's share) ran the complete RCB projective formula for all 334 point operations of a 4-bit window ladder; it now runs on Jacobian coordinates like the fixed-base comb — 3M+5S doublings and 8M+3S mixed additions against an affine table 1·Q … 15·Q built with one batch inversion. The scalar is reduced mod n first (constant time), which makes the mixed addition complete for every scalar by construction: before each addition the accumulator is m·Q with m = 0 or 16 ≤ m < n, so it can never equal a table entry d·Q (1 ≤ d ≤ 15). ECDSA P-256 verify is one Straus–Shamir pass sharing the 256 doublings between u1·G and u2·Q, with an affine table of G built once per process (publish-once slot 11) and exceptional cases handled by branching, as public data allows. Peer points and public keys are decoded and validated (canonical coordinates, curve equation) on the fixed-width field instead of through BigInt. The complete-addition code path this replaces is removed. Locally against aws-lc-rs: ECDH shared 26 % → 47 %, verify 23 % → 40 %, Full · ECDSA · P-256 TLS handshakes 53 % → 63 % of rustls. compiler/tests/p256_ct_field adds 24 edge scalars against two base points — k = n + 30 (which reaches the mixed addition's degenerate case without the reduction), k = n, k = 0, k = 2²⁵⁶ − 1 — checked against the independent BigInt reference.
keys are prepared once (inner/outer midstates kept), HKDF-Expand and HKDF-Expand-Label stream their input from where it lives and write into the caller's buffer, and the TLS 1.3 client and server derive each secret's children from one prepared key. HKDF-Expand-Label 1.19 µs and 6 allocations → 0.49 µs and none. compiler/tests/hkdf_vectors adds RFC 5869 cases 2–3, RFC 4231 HMAC cases 1–4, 6–7 and the RFC 8448 key schedule trace through the resumption PSK.
std/rsa.nu ran on std/bigint.nu — 16-bit limbs and a full long division per modular multiply — so a TLS server with an RSA certificate completed essentially no handshakes under load (bench/tls_bench: 0.07 handshakes/s against rustls's 574). The private and public operations now run on std/rsa_mont.nu: fixed-width 64-bit-limb Montgomery arithmetic (FIOS on nurl_mac), a constant-time fixed-window exponentiation, and CRT when the key carries its factors — two half-width exponentiations recombined with Garner's formula, each exponent blinded per signature (d_p + r·(p − 1)), and the signature checked against the message mod p and mod q before it is released (a fault in one half would otherwise factor the key). RsaPriv keeps the CRT parameters (crt, rsa_priv_sign_key), and the TLS listener signs with them. Locally: RSA handshakes 0.33 → 512 /s (rustls 1 527). tools/rsa_sign_gate.nu + tools/tests/test_rsa_sign.py check, in CI, that the CRT and the plain-exponent paths give identical PSS signatures for 1024–4096-bit PKCS#1 and PKCS#8 keys and that OpenSSL verifies them.
?? / ? arm whose value is a call's fresh handle made from the
arm's payload no longer leaks the payload** (h321). T a → ( keep a ), keep handing back an arc_clone of a, parked the payload's drop as "the value may be one of them" — any value that can hold an address was — so the payload's count was never released (LSan: the Arc block, in http3_creds_load). The parked drops now also run, on the paths the value is the call's own, when the callee's lend summary says its result borrows from none of the arm's locals; an argument that is not a plain binding is not followed and keeps the old, conservative answer.
\ in a braced ?? arm no longer leaks
(Agora #72, h320). : ~ String st ?? ( vec_pop v ) { T x → x F → { \ ( mk ) } } treated the join as a borrow because the braced arm's ownership was guessed from its first token, so the stored String was never dropped (ASan: 32 B leaked). Every live arm now publishes its own ownership-lattice element — borrowed, owned, or an i1 predicate for mixed paths — and the join phis those elements; ownership follows the value through block and try syntax. The PostgreSQL prepared-statement cache uses the direct vec_remove/prepare match again, and its __pg_stmt_take workaround helper is removed.
When a read had taken part of an inbound frame, __h2c_read_one waited for the rest with write interest only if bytes were already framed in the writer — not if request body was sendable but not yet framed (only the pump frames body). A peer that answered only after receiving the whole body then waited for the client while the client waited for the peer, until the 30 s idle timeout reported H2CReadIo. Partial frames became common once both ends set TCP_NODELAY (below), and compiler/tests/http2_client_duplex began failing in CI on ~1 run in 30 under load. The read now also wakes on writability whenever the connection has sendable body, the same invariant the pump already used to decide whether to wait at all. compiler/tests/http2_client_partial_frame_send reproduces it deterministically — the peer sends half a PING mid-body and the rest only after the body — failing every time on the previous client (30 s timeout) and passing in ~12 ms now.
nurl_tcp_connect
left Nagle's algorithm on (only accepted sockets got TCP_NODELAY), so a client's second small write — the TLS Finished after the ClientHello, the request after the handshake — waited for the peer's delayed ACK. packages/http-client made 24 new connections a second against reqwest's 1 201 in bench/tls_bench; with the fix it is at parity or ahead (local run: 2 784 vs 2 482). Every connected socket now has TCP_NODELAY; tools/net_nodelay.nu reads it back in CI.
webdocs/, cloudflare/, registry/) pins
undici to 7.30.1 or later (GHSA-4qv8-2p9j-2685, GHSA-f72v-6w45-q472, GHSA-xjj6-f33q-929c), reached through wrangler > miniflare, which still pins 7.29.1. Development and deploy tooling only; nothing of it ships in the toolchain.
nurlpkg login on Windows no longer writes the registry token into
the current directory.** stdlib/ext/credentials.nu found the home directory through $HOME alone and fell back to . — and HOME is unset in PowerShell and cmd, so nurlpkg login saved the token as ./.nurl/credentials wherever it was run: into a source checkout, where it was not git-ignored and one git add -A from being pushed, and where the next nurlpkg publish from another directory could not find it. The stdlib now has one answer to "where is ~": env_home_dir (stdlib/ext/env.nu) — $HOME on POSIX, the user profile on Windows (USERPROFILE, else HOMEDRIVE+HOMEPATH, as Go and Rust do; a HOME exported by Git Bash no longer moves the file), and an empty answer rather than a relative one when there is none. The credentials file, the registry trust config, the toolchain prefix and nurlpkg's install and stdlib-check paths all use it; with no home nurlpkg login says so and points at NURL_TOKEN. The token file is now written with the new write_file_private (stdlib/std/fs.nu): created owner-only by mkstemp and renamed into place, instead of written world-readable and chmoded afterwards. credentials_basic, previously skipped unless NURL_CREDS_TESTS=1, now runs in every corpus run against a temporary home and pins all of this, on Linux, macOS, FreeBSD and Windows.
can write.** The program-independent objects every image links (boot shim, nolibc, runtimes) came from a shared cache trusted on its stamp and mtimes alone, so whoever could write that cache put code into every kernel built after them. build_unikernel*.sh now links a cache only when every principal that can write it — the objects, the cache directory and each directory above it, read from owner, mode bits and ACLs — can already rewrite the build script, and otherwise compiles the objects into a private directory of its own (unikernel/cache_trust.sh). A checksum kept beside the objects cannot do this, since whoever rewrites the objects rewrites it too. The playground's root-owned, read-only cache passes unchanged. zig's own object cache hands back a planted object on a hash match, and the playground's is the server user's, so every zig call in a unikernel build now gets a private one: the arm64 and riscv64 images no longer read the caller's ZIG_GLOBAL_CACHE_DIR. Measured cost: a warm build 0.13 → 0.19 s; a cold one is unchanged. Gate: unikernel/tests/cache_trust_gate.sh plants an object the way a writer of the cache would (checksums rewritten, stamp refreshed) and fails against the previous scripts.
A playground fix. Every deploy since v0.76.0 served a page script that did not parse, so play.nurl-lang.org showed no examples. Nothing in the compiler or the stdlib changed.
per-stage stderr fallback from the build-failure handler in nurlapi/static/index.html and left a stray }. The syntax error stopped the whole script, so play.nurl-lang.org loaded with no examples and its status badge stuck on "checking..." even though every endpoint behind it answered. tools/check_static_js.py now runs node --check on every inline script and .js file nurlapi serves, in CI's runner-controls job.
A distribution fix. v0.77.0 was published from the GitHub web UI and came out with no archives, and the Windows installer could not be parsed by the PowerShell that runs it. Nothing in the compiler or the stdlib changed.
get-nurl.ps1 is pure ASCII again. nurl upgrade runs
the installer shipped in libexec\get-nurl.ps1 with Windows PowerShell 5.1, which reads a script without a BOM in the ANSI code page: the em-dashes and box-drawing rules in it decoded to cp1252 quote characters that PowerShell takes as string delimiters, so the script failed to parse and every Windows upgrade aborted. The same bytes made irm https://nurl-lang.org/install.ps1 | iex fragile. tools/check_installer_sync.sh now fails CI when tools/get-nurl.ps1 holds a non-ASCII byte. A Windows install from 0.77.0 or earlier still carries the old installer, so upgrade it once with the one-liner (irm https://nurl-lang.org/install.ps1 | iex); nurl upgrade works from then on.
published in the GitHub UI creates its tag on the server without reliably raising the tag push event, and the manual workflow_dispatch run only ever built the dispatching branch and never published. The workflow now also runs on release: published, and a dispatch with publish ticked builds the named existing tag, gates on that commit's CI, and attaches the archives to its release. One run per tag at a time. RELEASING.md describes the repair.
The backend toward v1.0, and a security release. Outbound connects and DNS no longer block a fiber's worker; a keep-alive HTTP response allocates nothing in steady state, the servers drain gracefully and accept in parallel; AES-GCM runs on VAES (ymm/zmm) and on the ARMv8 Cryptographic Extension; TLS seals records in place and signs ECDSA without BigInt; psql 0.5 pools its connections (std/pool.nu). And the playground's holes are closed at their source: a compilation can be confined to its directories (nurlc --import-root), serve_static judges the decoded path and stays inside its root through symlinks, the build opt level is a closed set, and the unikernel object cache can no longer be poisoned.
--import-root / NURL_IMPORT_ROOTS confine what a
compilation may read** (agora t71). A $ import named any path, and a file that failed to parse was quoted in the diagnostic — the public playground compiled $ /proc/self/environ and answered with its server's environment. With roots set (the flag is repeatable; the variable is :-separated; the flag wins) an import must resolve, after every symlink and .., inside one of them, and a file outside is reported exactly as a missing one, so the answer does not tell which paths exist. Unset, nothing changes (docs/spec.md §3.2). compiler/tests/import_roots.sh (run by build.sh) checks /etc/passwd, /proc/self/environ, a symlink out, a .. escape and a sibling directory sharing the root's prefix.
serve_static judges the decoded path, hides dotfiles and stays
inside its root through symlinks.** It percent-decodes the request path before the traversal checks (%2e%2e is .. — 403; a bad escape or %00 — 400), answers 404 for any segment starting with . except .well-known, and serves a file only when its canonical path lies inside the canonical served directory — a link in the served tree to /etc was followed before. New std/path.nu helpers carry the two halves: path_rel_ok (lexical: not absolute, no drive, no .. segment) and path_within (realpath confinement). Regression tests http_static_traversal (decoding, dotfiles) and http_static_symlink. The http package goes to 0.7.9.
NURL_IMPORT_ROOTS to its
toolchain tree for every compiler it starts (and adds the build's own directory for the unikernel builds that compile files they wrote); build ids carry 128 random bits instead of a guessable counter, since /download/<id>/<file> serves whatever a build left; its path checks use path_rel_ok. The unikernel object cache is no longer writable by the server user: in the image it is root-owned and read-only, the build scripts link an explicit list of cached objects instead of nl_*.o (a planted object was linked into every later image), and a stale read-only cache builds a private copy for that request instead of rebuilding the shared one. nurlapi/e2e_test.py checks that imports of /proc/self/environ, /etc/passwd and a .. path are refused without leaking content, and that encoded /download and /stdlib traversals are refused.
opt went to
clang / zig as a raw argument, so --ld-path=<file> ran any binary as the linker — unauthenticated code execution on /build, /build_wasm, /build_windows, /build_macos and /build_target. Only the -O literals are accepted; anything else is a 400.
path_join
returns an absolute second argument verbatim, and the file routes checked only .. (or nothing): /examples//etc/passwd, /stdlib//etc/passwd, /tests//etc/passwd, /static//etc/passwd, /stdlib-docs, /download, the MCP read tools and resource templates read any file the server user could.
(#1220). nurl_build_project put entry into a single-quoted sh -c command and the path check accepted quotes, so a crafted entry ran a shell with --allow-run off. Paths are now a strict [A-Za-z0-9._/-] relative grammar, and over HTTP path= / out= must be workspace-relative (stdio unchanged).
(agora t57). nurlc now knows when it emits for AArch64 — --target=ARCH (aarch64/arm64, x86_64, or anything else; a triple's first word counts), and the native default of a nurlc on an AArch64 host (nurl_host_arch). There the nurl_v128_aes* / nurl_v128_clmul_* primitives lower to AESE / AESD / AESMC / AESIMC and PMULL / PMULL2 (aesenc a k = AESMC(AESE(a, 0)) ^ k, …; inlined, each round key folds into the next AESE, the native ARM sequence), functions using them are compiled with +neon,+aes and gated on nurl_cpu_arm_aes (getauxval HWCAP_AES|HWCAP_PMULL on Linux, hw.optional.arm.FEAT_AES on macOS, ID_AA64ISAR0_EL1 when freestanding; NURL_CPU_AES=0), and a simd @ function reaching the tier is one gated body. So nurl_cpu_has_aes is true on Apple Silicon and Graviton, and std/aes_gcm.nu and TLS take the hardware path there instead of the ~113 MB/s bitsliced cipher. nurl.sh, the build API's arm64 targets and the unikernel's arm64 build pass --target=aarch64. The cpu-aes test requirement now holds on AArch64 (Linux Features: aes pmull, macOS), so the macOS ARM64 CI runs simd_crypto and aes_gcm_hw_agree; crypto_tier_ir.sh checks the AArch64 IR and machine code on every host and runs simd_crypto, aes_gcm_hw_agree and aes_gcm_vectors natively or under qemu-aarch64 (here: -cpu max, all three match the x86 goldens, 2214 cases).
instruction** (agora t56). Two tiers above the AES one (docs/spec.md §4.1b): the VAES tier — nurl_v256_aesenc, _aesenclast, _aesdec, _aesdeclast, nurl_v256_clmul_ll/_hl/_lh/_hh on x86-64-v3 + AES + PCLMUL + VAES + VPCLMULQDQ, asked with nurl_cpu_has_vaes — and the AVX-512 VAES tier, the same on the new v512 lane type (<8 x i64>, grammar v2.11, §4.1d: ld/st, zero, xor/and/or, add32/add64, bcast128, join256, lo256/hi256, bswap128) on x86-64-v4, asked with nurl_cpu_has_vaes512. The tier contract holds per tier: compiled for the highest tier a function's own primitives need and gated on it (nurl__vaes_gate, nurl__vaes512_gate; runtime CPUID incl. XCR0 ZMM state, NURL_CPU_VAES=0 / NURL_CPU_VAES512=0 take a tier away); a .tier copy is called only from code of its tier or a higher one; a simd function reaching a VAES tier is emitted once for it. New v256 helpers nurl_v256_join128, _lo128, _hi128, _bswap128. std/aes_gcm.nu gains a sixteen-block stitched kernel per wide tier for messages of 256 bytes and more (the eight-block one keeps shorter ones): schoolbook GHASH (four VPCLMULQDQ a block beat Karatsuba's three plus a shuffle and an XOR on the AES ports, ~5% in a C model), one Montgomery reduction per step, a branch-free fold (LLVM had hoisted all of a conditional fold ahead of the rounds) and counters carried by a byte add, a pair-at-a-time tail folded with one reduction. Sapphire Rapids, 16 KB seal with a prepared key, best of 25 on a shared host: **zmm 11.8 GB/s (0.17 TSC cycles/byte), ymm 8.8 (0.23)**, the AES-NI kernel 5.1 (0.41); OpenSSL 3.0's AVX-512 assembly 12.3–13.8 (0.15–0.17), its AES-NI one 5.0. A C model of the two loops agrees: zmm ~1.25× ymm here (VAESENC has the same block throughput at both widths; zmm halves everything else), so the AVX-512 form earns its tier. _aes_gcm_key_tier key t builds the key a CPU of tier t would, and aes_gcm_hw_agree.nu now holds every kernel a machine has to the software oracle over 0–1100 bytes plus 4–16 KB lengths (2214 cases); simd_crypto_v256.nu / simd_crypto_v512.nu check the primitives lane by lane (FIPS-197 C.1 and the v128 primitives), and crypto_tier_ir.sh checks both tiers' IR, gates, stubs, machine code and runtime answers. Bootstrap snapshot refreshed (preamble changed).
tcp_sendfile (agora t46, part of t37). After
the pure-NURL handshake a TLS 1.3 connection can hand its record protection to the Linux kernel — tls_ktls / tcp_tls_ktls on one connection, NURL_KTLS=1 or tls_ktls_default T for all — and reports which directions went (a direction that cannot go keeps the userspace record layer). Linux 6.14+ only (the kernel must rekey an installed direction for KeyUpdate) with the tls ULP loaded. Alerts, KeyUpdate and tickets travel as typed records (cmsg); `tls_key_update conn request_peer` (new, RFC 8446 §4.6.3) works in both modes; a read side waiting on a buffered partial record goes once the buffer drains. Runtime: nurl_ktls_set / _send / _recv / _rtype / _supported, stubs on every other platform. tcp_sendfile conn file off len sends a PFile range with sendfile(2) on plain TCP and on a kTLS-sealed TLS connection (bytes never enter the process), else through one reused 64 KB buffer and the record layer (pfile_read_vec_into, new); the runtime blocks the SIGPIPE sendfile raises on a closed peer, and a process that installs kTLS ignores SIGPIPE (unless it has its own handler) — a kTLS socket can flush queued records in close() after the peer has gone, where no call can mask it. Measured on Haswell / Linux 7.0 (gcm-aesni-avx in the kernel), server CPU µs per TLS response: 1 MB file C=10 — userspace from memory 1202, pread + seal 1255, kTLS + sendfile 1202, kTLS + write() 1720; 16 KB — 36 / 59 / 62 / 66. Hence opt-in: it pays where the kernel's AES-GCM is faster than NURL's (VAES kernels, NIC offload) — docs/NETWORKING.md. Tests: tls_ktls.nu (requires ktls: both ends in the kernel, each mixed combination, KeyUpdate both ways, 1 MB each way, sendfile, close_notify), net_sendfile.nu (kernel path on plain TCP, fallback under TLS, a peer that goes away mid-file).
t37). A record used to cost about ten allocations and three or four copies each way: the body sliced out of the receive buffer, a nonce, an AAD, a Poly1305 input concatenating AAD and ciphertext, a new plaintext, an append to the application buffer and two more slices on the way out; writing, an inner-plaintext Vec, the sealed copy and a growing record — and on AES-GCM a full key schedule (round keys and eight GHASH powers) per record. Now: - aes_gcm_seal_in / aes_gcm_open_in and chacha20poly1305_seal_in / chacha20poly1305_open_in seal or open buf[data_at, …) where it lies, the AAD buf[aad_at, data_at), the nonce the connection's iv with the sequence number XORed in (RFC 8446 §5.3, RFC 9001 §5.3); the tag is appended, or checked and the range zeroed on a mismatch. Poly1305 is fed from the AAD and ciphertext where they lie (no MAC buffer); the scratch is the buffer's spare capacity. compiler/tests/aead_in_place.nu holds them to the Vec APIs over 0–300 bytes, both suites, the AES key on its hardware AND software paths, with foreign bytes around every record. - A connection keeps its AES-GCM keys prepared per traffic secret (s_gk / c_gk, rebuilt on KeyUpdate), seals each outgoing record in a reused per-connection txbuf (64 KB batches, one write each), and opens each incoming record inside its receive buffer, copying the plaintext once — into the caller's buffer through the new tls_read_into / tls_server_read_into, which tcp_read_into and tcp_try_read_into now use. - Steady-state record I/O allocates nothing: compiler/tests/tls_record_inplace.nu seals and opens 100 records between two connection states and counts zero allocations, for both suites.
hmac_sha256_key / hmac_sha256_mac: a prepared HMAC-SHA-256 key(std/hash_sha256.nu). The inner and outer midstates are computed once; each MAC then costs two compressions fewer and builds no K0 block.
length, J0 encrypted alongside the tail** (agora t58, part of t37). A TLS record of an API response is short, so its latency is per request. The hardware path used to encrypt E_K(J0) and each tail block as its own ten-round chain and reduce GHASH once per block, each reduction waiting on the one before. Now J0 and the tail's counter blocks go through the cipher in ONE interleaved pass (the narrowest of 1/2/4/8 blocks that holds them — a block nobody uses still occupies the AES unit), placed after the eight-block steps so it overlaps their last fold; and GHASH outside the stitched loop runs in groups of up to eight blocks with unreduced lo/hi/middle sums and ONE reduction per group (__ghw_take) — with no eight-block step, the AAD, the data and the length block of a record up to 96 bytes are a single group. Opening now folds each eight-block step's own input (the ciphertext is there before the cipher runs), which also makes in-place opening valid. i7-5930K, prepared key, cycles per seal: 16 B 360 → 295, 96 B 621 → 395, 256 B 644 → 599; ns: 16 B 94 → 79, 64 B 131 → 96, 96 B 161 → 103. aes_gcm_hw_agree.nu adds AAD past eight blocks before every message shape.
nurl_v128_ldn / nurl_v128_stn: partial-block load and store
(docs/spec.md §4.1b). The first n bytes (0–16) zero-padded, and their store, without touching a byte past n: two overlapping 8-, 4- or 1-byte accesses, one branch per size class, no loop and no memcpy call — which on the SysV ABI spilled every live vector register around it (the GHASH sums, for a 5-byte TLS AAD block). compiler/tests/simd_partial.nu checks every length 0–20 from an allocation of exactly n bytes (an over-read is an ASan report) and between sentinel bytes.
the language, and AES-GCM on them at ~3 GB/s** (agora t55, part of t37). nurl_v128_aesenc, _aesenclast, _aesdec, _aesdeclast, _aesimc and nurl_v128_clmul_ll/_hl/_lh/_hh (PCLMULQDQ's immediate as four primitives), the portable nurl_v128_bswap128, and nurl_cpu_has_aes (docs/spec.md §4.1b). They are not baseline anywhere, so nurlc keeps the contract rather than the program: - a function whose body uses one is compiled for x86-64-v2 + AES-NI + PCLMULQDQ and gated at entry on the runtime's CPUID answer (nurl_cpu_x86_aes) — a CPU without the tier gets a recoverable panic, not SIGILL; the gate is a readnone call the optimiser merges and hoists out of loops; - such a function is never alwaysinline (LLVM would inline it into a baseline caller): an inline @ or v128 signature becomes inlinehint, plus an ungated alwaysinline copy @f.tier that every tier function's own calls go to — helpers inline unconditionally inside the tier, and stay gated calls outside it; - a simd @ function that uses a primitive, or calls a function that does, gets tier clones: .base compiled for the tier and gated, .x86v3 with +aes,+pclmul, picked only when the CPU has both. A marked function's clones now depend on its callees, which a lazily compiled library generates after it, so marked functions and tier functions are emitted at the end of the module; - under --no-cpu-dispatch the primitives are panicking stubs and nurl_cpu_has_aes is false. std/aes_gcm.nu builds a HARDWARE key where the CPU has the tier: round keys from an aesenclast key schedule, and GHASH in the reflected domain with a two-PCLMULQDQ Montgomery reduction — for T = A'·G, R' = T1 ^ W ^ clmul(W.hi, q) with W = T0 ^ swap(clmul(T0.lo, q)), q = 0xC2…00 being y^121 + y^126 + y^127, the inverse of the reciprocal polynomial mod y^128 minus one; the one-bit shift and the y^-128 are folded into the key (G = y·H' mod P*). The kernel encrypts eight blocks while folding the previous eight into GHASH (one block of the fold per AES round, one reduction per batch). On an i7-5930K (Haswell): AES-128-GCM seal 2.9–3.2 GB/s over 16 KB – 1 MB, from ~0.11 GB/s bitsliced; 1.21 cycles/byte against **1.22 for the same algorithm in C intrinsics built by the same clang** — and OpenSSL's hand-scheduled assembly at 1.07. A 32-byte message seals in 101 ns with a prepared key (289 ns with the per-record key the TLS one-shot API still builds), ChaCha20-Poly1305 in 634. compiler/tests/aes_gcm_hw_agree.nu holds the hardware path to the software one over every length 0–300 with 0–37 bytes of AAD and both key sizes; simd_crypto.nu checks each primitive against FIPS-197 C.1 and hand-worked products, through a plain helper, a simd clone and a closure.
pub / unsafe (#68). It took
the % after the prefix for a %Trait object type and glued it (unsafe %Send W {}, pub %Ord [T] {}); it prints unsafe % Send W {} now, as at the start of a line. stdlib/core/marker.nu and four tests reformatted. Test: tools/tests/test_formatter.py.
% Drop field, handed to a call,
no longer cuts the function off (#67).** The env's drop asked for the struct's field-by-field drop, and the compiler printed that define in the middle of the function it was writing: the module held the function up to the capture and nothing after it (nurlc exited 0; clang rejected the IR). The drop is queued for module end. Test: closure_capture_drop_field_handed_on.nu.
unsafe outside the standard
library (h318; grammar v2.11).** % Send T { } and % Sync T { } stop the Send/Sync derivation at T and turn off the frozen-view check through a Sync type, so a safe module that asserted % Send over an Rc raced its count from two threads (AddressSanitizer: heap-use-after-free). A program writes them unsafe % Send T { } / unsafe % Sync T { }; without the prefix they are an error. % NotSend, % NotSync, % Frozen and % Resource only add checks and stay safe code (h317 settles that a program's own % Frozen over a Cell cannot reach two threads); unsafe on them, on any other impl or on a trait declaration is an error rather than a prefix that leaked onto the next foreign declaration.
nurlc --unsafe-report lists the whole trusted surface. Beside the
program's unsafe functions it lists its unsafe % Send / unsafe % Sync assertions and the in / sync / nocapture claims of its foreign declarations, one file:line each, and no longer lists the standard library's own unsafe functions (the trust test was asked of file:line rather than the file). Checked by compiler/tests/unsafe_report.sh. docs/MEMORY.md §3 / §6.4 and formal/CLAIM.md's trusted base name % Frozen and the foreign claims.
caller-supplied mutex (h319).** heartbeat_start handed its thread the PkMemberTable and a Mutex the caller had to take around its own changes; nothing made it, and two threads growing one table corrupted the heap. The thread holds the node's liveness now (pktable_liveness: a copy of the self pubkey and the table's incarnation, which is an Atomic), `heartbeat_start PkMemberTable t sink RelayClient rc ( Vec u ) group i interval_ms` takes no mutex, and PkMemberTable is % NotSend. heartbeat_payload_of builds the same payload from a PkLiveness; pktable_refute is one atomic max. The stop flag is an Atomic instead of an Arc read with raw FFI. Test: dist_heartbeat_thread.nu.
mqtt_listen takes its client over (sink). Its reader thread
changes the client's buffers and packet-id state with no lock; the doc asked callers not to use the client meanwhile. Test: borrow_mqtt_listen_takes_client.nu.
shares a library handle, and the Send derivation looks only at types: one copy moved to a thread while another stays races the state (tools/fuzz/holes/open/library_handle_copy_on_two_threads.nu, docs/MEMORY.md §6.2).
inout of every owning type, and a field replaced in place
(A2).** The calculus passed only a Vec inout; a struct, an enum and a closure are passed inout now, and a field is replaced in place as the struct taken apart and assigned again (unpair takes an inout struct its bound lets the callee replace). That is where h295 and h297 sit: a copy of a parameter is a borrow and never an inout argument (paramOK_inout), and a closure replaced through inout is an assign to the caller's slot. A2.memory_safe, A2.soundness and A2.no_leak_end are re-proven (formal/check.sh, Lean 4.35.0, standard axioms only); formal/README.md, RULES.md and corpus/map.tsv follow.
destructor that panicked while a panic's journal drained replaced the first panic's message (leaking it) and longjmp'd past the drain's epilogue, so the thread stayed in the drain's state for good: every later free went through a record that only grew, and one of an address the drain had freed and malloc reused was skipped. The recover frame that catches it now ends the drains begun inside it, and keeps the first message (the cause). formal/CLAIM.md states how the model treats it. Test: panic_in_drop_while_unwinding.nu.
v256 passed to or returned from a simd @ function was wrong on
every AVX2 machine.** The x86-64-v3 clone takes a <4 x i64> in one ymm register, the baseline dispatcher (and any caller without AVX) passes it in two xmm registers, and LLVM does not reconcile them: a clone summing two v256s a hundred times answered 301 where 101 was right. The clones now take wide vectors (v256, v512) by pointer and the dispatcher spills them and is alwaysinline; a crypto-tier function with a wide signature crosses a call from outside its tier the same way (f.vabi + a featureless alwaysinline f). A struct holding a wide vector is treated as one (passed by value it is split element by element), and an indirect call carrying one — a closure or a trait-object method, called from a simd clone or a tier body — goes through a featureless noinline thunk per signature that takes the vectors by pointer (__nurl_vcall.N), since every address-taken function is compiled without features. Regression test simd_wide_abi.nu (v256 / v512 / struct / closure, both clones).
own.** The allocator cache probes, once at start-up, whether glibc's chunk header can stand in for malloc_usable_size, by reading the word before a few fresh blocks. Under valgrind, whose malloc has no such header, every read was an "Invalid read of size 8" in nurl__sc_init (the probe then failed and the library query was used, correctly). The probe now asks first — valgrind's RUNNING_ON_VALGRIND client request, inlined for x86-64 and AArch64, a no-op on bare metal — and skips the read under any valgrind tool.
checks printed cycle collection: error: …, `thread-shared cycle: error: … and in function f: error: …`, with no file, line, column, echoed line or caret. A library handle with a _drop and no _trace is reported at its _drop's declaration; arc_set / arc_ptr on an Arc that can close a cycle at the check in the instantiated template, with the instantiation and its call site in the bracketed context; a borrowed value that cannot be copied at the statement that would copy it — also when the decision is made at module end (a callee's store flag), from the position parked with it.
frozen-Arc and closure-capture rules refused lingbot-map, map-anything, onnx (and through it yoloe, yoloe-demo, objdet) and benchmark, with nothing in CI compiling packages to notice. Fixed where each is wrong: - gpu 0.17.7: the foreign functions that only read host memory (cuMemcpyHtoD*'s source, wgpu_upload's, nurl_peek_*'s base) are declared in *u. Unmarked, a *u is written through, so every upload counted as changing its source and an upload from a mapped checkpoint (an Arc's payload) was a "write to frozen memory". - A GpuHost's memory is an Arc's payload, so filling one through gpu_host_f32's view is a genuine write to shared memory: gpu's new gpu_host_fill_f32 fills the cells before the buffer is wrapped (yoloe, objdet), and onnx's rt_download downloads straight into the buffer (gk_dbuf_download_host). - benchmark takes the view of the Vec sort_by changes inside the timed closure, not beside it.
t49). When the netpoller's batch left one fiber beside the one it runs directly, it woke an idle worker for it — but stealing takes half of a queue of two or more, and the poller role stays on the polling core, so the woken worker found nothing and went back to sleep: a futex wake, a futex wait and two context switches per request at c=10. A peer is now woken only for a stealable surplus. 6 workers, c=10: futex calls per request 1.24 → 0.63, context switches 0.46 → 0.23, server CPU 26.8 → 24.3 µs/request (−9 %), throughput and p99 equal or better; c=50 and c=200 unchanged. Profiled first, as the task asked: mutex lock/unlock is ~1 % of server CPU at c=50..200 with no futex contention on the netpoller lock to speak of — the idle-worker wake path was the cost, not the lock. The netpoller's design comment no longer describes EPOLLONESHOT re-arming or a lease-release wake.
(agora t52). With at most four live fibers, a fiber about to park first probed its fd with a fixed 256 poll(2) calls — worth it when the peer answers within the window (c=1 closed loop: ~28 k against ~12 k req/s), pure waste when it does not: a client with think time, an idle keep-alive connection, the listener. Now each fiber learns how long its waits take (every wait counts — a spin hit or a whole park, outliers clamped) and spins only while that is below the cap, for twice it plus a margin; the cap is NURL_SPIN_US (µs, 0 = never), else 100 µs, else 0 under a cgroup CPU quota or with one usable CPU. i7-5930K, 6 workers, server CPU µs per request, before → after: one connection at 2000 req/s 235 → 74 (TLS 239 → 72), at 500 req/s 484 → 84, two at 2500 each 183 → 72 with p99 2.14 → 0.46 ms; c=1 / c=10 closed loop unchanged (35.0 / 27.1 plaintext; TLS c=1 44.5 → 41.8). tokio on the same think- time loads: 65 and 76.
are truncated as verify truncates them** (agora t47). The private key and digest went through bigint_from_bytes_be / bigint_rem / bigint_to_bytes_be and every RFC 6979 candidate through two BigInt comparisons; now every scalar is a fixed 32 bytes on the GF(n) field (a constant-time p256n_in_range_be replaces the comparisons) and each RFC 6979 HMAC key is prepared once for the MACs made under it. The digest is now bits2int — its leftmost 256 bits (FIPS 186-5 §6.4.1) — where signing used to reduce the WHOLE digest mod n: a SHA-384 or SHA-512 digest signed with P-256 produced a signature that ecdsa_p256_verify (and every other implementation) rejects. ecdsa_p256_sign.nu adds RFC 6979's SHA-256 "test" vector, SHA-224/ 384/512 digests verified by OpenSSL, and RFC 4231 HMAC cases on the prepared key.
gates into the kernel** (nurlc). A function joined the tier only when its own body used a primitive, so an inline-wanting helper that only CALLED tier helpers (__ghw_take calling __ghw_acc1 and __ghw_finish) stayed baseline: inlined into a tier body it brought a gated call per callee — four CPUID gate checks in the AES-GCM kernel, and the reason helpers had to be merged by hand so each used a primitive directly. Such a wrapper now gets a .tier copy too (__tier_wrappers, a fixed point over wrappers of wrappers) whose calls go to the .tier copies, and tier bodies call it; the original stays as it was for baseline callers. crypto_tier_ir.sh checks the copy and that the simd clones call it.
nurl_docs / nurl_read_roadmap pages end on a character boundary.
A page (and a cut section) was sliced at a byte offset, so one that ended inside a — was invalid UTF-8 and a JSON client rejected the whole reply — docs/spec.md's second page did, once an edit moved the 48 KB cap into an em dash. Both ends now snap back to the character they fall in (the __mcp_utf8_cut the registry descriptions already used), and the printed offset= is where the next page starts.
(h316).** : v128 x ( lanes ( mk ) ) left the Vec mk made alive for ever: a v128 / v256 result was taken for one that could point into a temporary (like ( string_data ( mk ) )), so the temporary's drop waited for the result's consumer — and a binding is none. A vector is lanes of numbers; every question about what a value can point into now says so (__is_vec_num_ty). Found by the sanitizer corpus on the crypto tier's own test.
: i n 0 then : n 5 failed with "unknown type 'n' in a binding type": the parser took the annotated form : TYPE NAME EXPR whenever the first word was in the symbol table at all, and the table holds values as well as types. Only a word that names a type (a struct, enum, generic template or type slug) opens the annotated form now, as spec §5.1 says; a struct value rebound as : p @ P { … } no longer reports "type 'p' … declared LATER" either. : P 5 with P a struct — the one shape the rule cannot read as inferred — says why P was taken as the type.
Their src/ modules imported siblings as src/<name>.nu, which nurlc finds only through the working directory — the package root in every in-repo build, the consumer's root once the package sits under deps/<pkg>/. anomaly (0.36.5, whose README documents $ deps/anomaly/src/dynamic.nu ), arima, audio, benchmark, chart, hub, iforest, lingbot-map, map-anything, md2html, nurllama, registry, safetensor, template, tokenizer and whisper import them relative to the importing file now, with a patch release each. tools/check_package_imports.sh gains the rule: every import of a packaged source that is not stdlib/…, deps/… or packages/… must exist beside the importer. Spec §3.2 described the lookup as working-directory-only; it is importer first, then the working directory, then $NURL_STDLIB.
(agora #61).** A missing supertrait impl printed file:2: error: … with nothing under it, unlike every other diagnostic; so did the thread-crossing checks (a closure handed to another thread, a value stored into a thread-shared handle) and a start-time global's initialiser arity. Each records the construct's position when it is scanned and re-reads the line when it reports, anchored at the impl's %, the global's name or the statement's start; the supertrait message also names the impl to add. The doc-example gate no longer accepts the column-less form.
dns_reverse's
getnameinfo now runs on the runtime's resolver pool (a reverse job kind) with the fiber parked, like every forward lookup. The pure HTTP client's plaintext transport (ext/http_pure.nu: hp_conn_read_some, hp_conn_write) issued plain blocking reads and writes, so a slow server held the fiber's worker; on a fiber they now park on the netpoller, honouring hp_conn_set_timeout. hp_conn_open reports a failed TCP connect at once instead of one read later. A sweep of the stdlib's remaining raw socket calls found every other one already context-aware (udp.nu, net.nu, tls.nu) or wrapped in a TcpConn.
errno is a thread-local behind a const accessor, so the optimiser may compute its address once per function and reuse it; with the runtime inlined into NURL code that parks (read → EAGAIN → park → read), a fiber resumed on another worker would read the old thread's errno. Not observed in today's binaries (a scan of every corpus binary found no function that both computes errno's address and parks before using it), but nothing guaranteed it. Every errno access in the runtime now goes through a volatile function pointer the optimiser cannot merge or hoist; tools/check_errno_fiber_safe.sh (CI) fails if a direct accessor call reappears.
stdlib/std/pool.nu — a bounded pool of reusable resources
(database connections, sessions). `pool_new [A E] make check cfg timeout_err closed_err`: a factory closure, a check run on every resource coming back, PoolCfg { min max wait_ms idle_ms }. A checkout (pool_get, pool_get_ms) is a guard, ( Pooled A E ), whose drop gives the resource back — at the end of its scope, with its struct, in a panic's unwind; pool_discard drops it instead. When all max are lent a fiber parks and a thread waits on the pool's condvar, both bounded by the deadline. pool_fill, pool_evict_idle, pool_close, counts. Pinned by pool_basic and pool_concurrent (threads, fibers, both on one pool, deadlines racing returns; ASan clean). psql 0.5.0's pg_pool_new is built on it.
nurl_fiber_park_with_mutex_ms / nurl_fiber_wake (epoll and poll(2) backends, the unikernel runtime). A plain unpark racing the park's timer would make the fiber runnable twice; here the wake and the deadline claim the wait entry under the reactor lock, and the loser does nothing. cond_wait_ms (std/thread.nu) is the thread side.
never dropped** when nothing but generic code named its instance (?? ( get [String] mk ) { T g → … } with get → !( G A ) E): the caller computed the instance's result type before the instance (and so the ( G String ) struct) existed, took the handle for a plain value, and the arm binding leaked with everything the handle held. The instance's identity is recorded when the result type is computed (generic_handle_result_drop).
tcp_starttls closed a socket twice when the handshake failed (arefused certificate under verify): the TLS state had closed it, and the TcpConn's drop closed it again — a use-after-free of the runtime's socket record (ASan, psql's verify-full test).
fd number.** Closing a socket reset its netpoller slot and then closed the fd outside the netpoller lock; a fiber parking on the still-open fd in between re-marked the slot registered, and close(2) then dropped the kernel registration. The next socket handed that fd number was never added to the epoll set, so its first park never woke. Seen as server_stop on one listener, from another thread, leaving the next listener's accept fiber asleep (the new http_keepalive_cap_modes test hung in 8 of 30 runs on a loaded machine, 0 of 60 after). The reset and the close are now one step under the lock.
nurl_tcp_connect ran a blocking getaddrinfo and a blocking connect(2) even on a fiber, so one slow DNS answer or one SYN into a black hole stalled an M:N worker — and every fiber queued on it — for the resolver's or the kernel's full timeout; psql, redis, the HTTP clients and every tcp_connect_tls* caller inherited it. On a fiber the connect is now non-blocking and parks for writability on the netpoller, then reads SO_ERROR; a name is resolved by the system getaddrinfo (/etc/hosts, nsswitch) on a small on-demand resolver thread pool while the fiber parks on the job's eventfd; a numeric address skips the resolver entirely. Every resolved address is still tried in order. udp_connect, udp_send_to, udp_addr_resolve and dns_resolve(_port) resolve the same way. Off a fiber nothing changes.
tcp_connect_tls* reports a failed TCP connect as itself —
NetTimeout, NetOther, … — instead of NetTlsHandshake, and closes the failed socket.
scheduler looked at the netpoller and the global queue only once the local queue was empty, which a yield loop never lets happen: on one worker its timers, I/O wakes and unparked fibers waited for ever. Every 128th dispatch now looks at both first.
(agora t53). tools/fuzz/parse_harness.nu gains 16 targets beside the data formats: HPACK, HTTP/2 frames, QPACK, HTTP/3 frames, multipart, URL / query / form / cookie / permessage-deflate offers, the TLS Certificate-message chain walk, CSR and PEM, and — over loopback, with the input as the peer's byte stream — the HTTP/1.1 server loop, the HTTP/2 server path, a pure-TLS listener's accept (records + ClientHello), the TLS client handshake, the WebSocket reader (incl. permessage-deflate), the HTTP/1.1 client and the MQTT client. fuzz_parsers.py adds per-protocol generators that keep lengths and varints consistent, structure-aware DER mutation, real captures under tools/fuzz/corpus/, batched runs with per-input triage, a time budget per target, and LeakSanitizer plus allocation / RSS caps. The weekly fuzz.yml job runs the network targets by time (net_parser_minutes, default 4 each) beside the data formats; FUZZRESULTS.md gets a per-target table.
hostile chunk size** (stdlib/ext/http_pure.nu, found by the new http1resp fuzz target). The size parser had no ceiling, so a 16–17 digit size wrapped to a negative length and the body copy moved the read position 2^62 bytes back — a SEGV from one response. Sizes are now 1*HEXDIG under 2^48 with optional whitespace and extensions, anything else fails the response; the CRLF after each chunk's data is checked; chunk-size and trailer lines are capped at 8 KiB and the response head at 256 KiB, so a server can no longer grow the receive buffer without bound. (http_client_chunked_strict)
stdlib/std/rsa.nu).
A certificate chain or CSR could carry any key: a 600-bit exponent made one signature check take most of a second at 2048 bits (some 50× an e = 65537 check, and the cost grows with the square of the modulus), and e = 1 was accepted, under which the signature equal to the encoded message verifies anything. rsa_pkcs1_verify and rsa_pss_verify_sha256 now refuse a modulus over 8192 bits and an exponent that is even or outside 3 .. 2^32−1. (rsa_pubkey_bounds)
Probing map_iter's doc comment ("do NOT free the map until iteration is finished") found that the borrow checker did not tie a closure to what it captured: - h299: a closure a call hands back over an argument it captured without taking it over (: f ( mkc x )) stayed usable after the caller released x. Which parameters a returned closure lends (retclo) was known, but only to reject returning it past them; it is a source of the call's result now, so f holds a view of x. - h300, h301: the same through the standard library — iter_from_vec after its Vec was released, and map_iter after its map was. map_iter captured a raw pointer to the map's control block, which no summary could see; it captures the map and reads the block on each call. - h302: a returned closure over a parameter that owns memory but is not a handle (an option of a String) was not recorded as lending it. - h304, h305: a view a nested call lends, stored by the callee (( wrapv ( string_data x ) )), was not a view of x in the result, and an iterator chain over a temporary source (`( iter_map ( iter_from_vec v ) f )) held nothing of v: a sink` parameter the returned closure takes over (retcap) now carries what its argument reaches. - h306, h307: a closure literal holds a copy of a handle it captures by value (a : ~ Vec or String it only reads); giving the binding a new value — with =, or through an inout callee that replaces it — released what the copy pointed at. A handle captured by value is a dependency of the closure now: the closure ends when the value does — released, given a new value, a field of it replaced — not when it grows or its elements change (it reads them through the handle when it runs). borrow_closure_capture_use_after_free had this shape as a control (freed, rebound, called): ASan says it read the freed Vec; it is a positive now. - h308, h309: h307 for a closure a call returned, over a Vec, and over a struct one of whose fields the callee replaces. - h310: a closure literal over a local Vec, bound and returned by name (: f \ … ^ f), held only a copy of the handle; the function released the Vec on its way out. Rejected now, with the cure in the message: a closure literal written in the return (^ \ …) takes over what it captures. The metamorph control closure-over-heap-handle had the bound form — ASan says it read the freed Vec — and is the literal form now. A closure stored into an owner (a container, a struct field) copies in what it holds a handle of, as before: storing one is no dependency.
Probing the standard library's doc comments (agora t30) found lru_get's "do not free it, and treat it as invalid after a later lru_put" unenforced: a function returning a call's lend through a ?? arm (T s → ( vec_get v s )) or a ? ternary was not summarised as lending, so its caller took the payload for its own — released it (the container's element) or read it after an eviction. A join's lending arms now record the calls they end in, and returning the join records what those calls lend (h311, h312; lru_get / lru_peek: h313, h314). A payload of an option a call answers per run is handed to a consumer as a copy on the runs it was lent.
statement.** What the call does to its arguments was recorded with the arm's first statement, so that statement could read a borrow the call had already ended (v0.76.0 accepted it and read the cleared String). The call's effects now precede the arms; the same order stops a call from ending the borrow of the payload it just lent (`?? ( lru_get c k ) { T v → { … v … } }`, and any function that lends an argument while changing what it holds). toml_parse returns on a failed table descent instead of flagging it, which the new order made visible.
map_keys / map_values over owned keys leaked them and
emptied the map's slots.** An element read through a pointer and handed to vec_push was both copied (the callee keeps it) and moved out of its slot (the callee consumes it): the original leaked, and the map was left holding empty keys. A callee that keeps a copy no longer also empties the slot, as for a struct field.
tcp_connect_tls_timeout host port sni verify alpn sess ms
(std/net.nu) and tls_attach_full_until (std/tls.nu): one deadline over the whole TLS connect — name lookup, TCP handshake and TLS handshake — reporting NetTimeout wherever it runs out. The handshake's reads take what is left of the deadline as their timeout (the reactor deadline on a fiber, SO_RCVTIMEO on a thread) and its writes carry it as their write deadline, instead of the fixed 20 s per read that bounded every client handshake before. Existing signatures are unchanged; tcp_connect_tls_full is the deadline-free case.
tcp_connect_timeout host port ms (std/net.nu): tcp_connect
with one deadline over the name lookup and the TCP handshake, reporting NetTimeout — on a fiber a netpoller deadline, off one a poll(2). tcp_connect keeps its signature (no deadline).
client offers it, the client by listing it first; ChaCha20-Poly1305 otherwise, as OpenSSL and rustls do. It was ChaCha unconditionally, correctly, while AES-GCM was the slower suite on every NURL host.
(its IR is compiled for the host clang runs on, and x86 intrinsics in an AArch64 module are a codegen error, not the warnings the simd clones' feature strings were); --cpu-dispatch turns it on for an x86-64 target built there. A wasm nurlc keeps the native default — its output must match the native compiler's byte for byte (wasmc_gate).
NURL_CPU_V3=0 and NURL_CPU_AES=0 take a CPU-dispatch answer
away (never grant one), so one machine runs every clone and fallback; a test declares // requires: cpu-aes when its golden needs the tier.
own or warn them off what the checker already rejects: about fifty across core, std, net and ext (vec, string, fs, path, decimal, channel, lru, udp, args, inet, json, xml, serde, csv, env, mcp*, http_*, websocket, cluster, dchannel) now say who owns and drops a value, what is lent and what the checker refuses (agora t30). The vec_iota note on forward references and the replicator's field-shadowing note described limitations that no longer exist; http_cli's "do not import both" no longer applied. nurl_docs lists a guide under 1 KB in bytes instead of "0 KB".
before the memory guarantee describe what the checker enforces: map_iter, map_keys, map_values (std/hashmap.nu), iter_from_vec, iter_repeat, iter_zip and the source-mutation note (std/iter.nu), and the note on iterator pairs (core/pair.nu) — the pairs and vectors these hand out own their elements, and an iterator's source must outlive it (changing it in between is fine).
A connection served by server_run_async holds a fiber, not a worker thread, so the default per-connection request cap is gone there: the connection lives until the peer closes it, a Connection: close, or the idle timeout. Before, every keep-alive client was told to close after 1000 requests and had to reconnect, and the server re-accepted and spawned a new fiber. server_run and server_run_pool keep the 1000 cap. New server_keepalive_auto (the default every constructor without an explicit cap now stores) and server_keepalive_unlimited values for max_keepalive_requests; an explicit cap still applies in every mode.
costs half the instructions (agora #51).** The plaintext hello path made 11 allocations per request; it makes 0 (pinned ≤ 2 by the new http_response_recycle test, thread and fiber mode). User-space instructions per request (callgrind, one keep-alive connection): 15 878 → 7 709. Server CPU per request under oha -c 50 (shared, loaded host, A/B interleaved, median of 3): 8.85 → 7.60 µs total, 3.65 → 2.85 µs user. What changed: - The compiler drops the panic-journal registration of a binding that never owns while registered — a parameter its function does not take over, a view read out of a container, a temporary a callee sinks. Its drop flag folds to false, so a drain would skip it anyway; the registration and its forget were ~120 instructions inside a recover extent and half of all registrations. A flag that folds true is registered with a null flag. (docs/MEMORY.md §7.2.) - response_recycle (new): the servers release every written response into a per-thread stash of emptied control blocks (stdlib/runtime_core.c §9a′, vec_stash / vec_unstash, string_stash / string_unstash / string_unstash_bytes), so the next response_new / response_set_header reuse its header Vec, header Strings and body buffer. The router's Params recycle the same way. Bounded per thread; large buffers are given back. NURL_STASH=0 turns it off. - The handler closure and its recover closure are built once per connection, not per request. - response_serialize_head_to sizes the head once and copies at a cursor instead of ~15 appends and two extra header scans. - string_set_bytes (new), and fast paths in string_clear, string_push_bytes, string_push_str and the NUL seal: a recycled String is refilled with one copy, no view binding or reserve call.
deadline_ms` stops accepting (the listener closes), closes idle keep-alive connections at once, lets requests in flight finish with Connection: close — HTTP/2 connections get GOAWAY(NO_ERROR) — and waits up to the deadline before shutting the rest; T when everything finished in time. Works for server_run, server_run_pool and server_run_async. Also server_drain_begin, server_shutdown, server_draining, server_conn_count, h2_conn_serve_drain, http3_server_drain (GOAWAY on every HTTP/3 connection) and tcp_conn_shutdown (std/net.nu: shut a connection from another thread without closing it). ROADMAP listed "graceful shutdown" before there was any: server_stop only closed the listener, so an idle keep-alive client held a stopping server until its idle timeout. New test http_server_drain (in-flight completes, idle closed, HTTP/2 GOAWAY, new connections refused, deadline enforced; pool and fiber).
host port TcpListenOpts sets the backlog and SO_REUSEPORT` (runtime nurl_tcp_listen_opts; ignored where the platform has none). tcp_listen's backlog is 4096 (tcp_listen_default_backlog, clamped by the kernel to somaxconn) instead of 128, which dropped SYNs under a burst of connections — each one a 1 s client retransmission; the TLS listeners and packages/http use it too. server_run_async runs one accept fiber per runtime worker on its listener (server_set_acceptors, new runtime_worker_count) instead of one: connection churn (oha --disable-keepalive, 20 clients, 4 workers, shared 4-core host, interleaved, median of 3 and 5) 17.9k / 15.4k → 19.0k / 22.2k conn/s, server CPU per connection 28.5 → 24–26.5 µs. A SO_REUSEPORT listener per worker measured the same within the noise (20.8k / 17.4k) and was not kept: fibers migrate between workers, so it buys no affinity, and it needs a second socket (and TLS credentials) per worker. New test net_listen_opts.
http_app_drain_ms,default 10 s) instead of only closing the listener.
http_app_max_keepalive follows the serving
mode — no cap under http_app_async, 1000 for the thread servers.
The toolchain, read by an agent. Every compiler error a tool parses now has the file:line:col: shape — borrow-checker errors included — and names the caller's file rather than a temp path. Messages spell NURL types (?i, ( Vec i ), not { i1, i64 } or Vec__i64), a missing import ends with the exact line to add, and the prefix-arity trap is reported at the binding that swallowed the next line. An index past the end panics once, naming the line that indexed. The MCP servers serve the mechanized proof (formal/), read the roadmap by heading, open a package with its module index, and answer registry searches from a warm catalog instead of a round trip per term.
tools/check_doc_examples.sh,
a CI gate). Every nurl block in docs/*.md`, docs/stdlib/*.md, README.md and the package READMEs is wrapped into a program and run through nurlc --check; a block annotated expect-error="…" must be rejected with that diagnostic, so a reworded message fails the doc that quotes it, and in docs/ every fence names its language. MEMORY.md §2.11's example had no vec import and died before the use-after-move it was there to show; the first run fixed that and ~40 more — operators called like functions (gpukit), /* */ comments NURL does not have (spec §6.10, oauth, hub, redis), FFI and raw pointers in safe code (spec §3.1, §7.2), a continue that never advanced its counter (spec §5.4.1), m::alloc without its type argument, vec_push without [T] (MEMORY.md §2.5), a quoted diagnostic the compiler no longer prints (§2.10), vw_serve and tls_connect signatures that had changed under the README quoting them (ply, pqc), inout arguments bound immutable (grad). See docs/TOOLING.md#documentation-examples.
nurl --stdin NAME (nurl.sh, nurl.bat): the program arrives on
stdin and nurlc knows it as NAME (nurlc --stdin NAME), so a tool that holds source in memory builds it without writing a temp file, and its diagnostics read NAME:6:12 rather than that file's path. nurl-mcp 0.14.0 builds and runs inline source this way; wasmbuilder 0.3.7's wb_build_source / new wb_build_text do the same for wasm.
nurl_str_find_from (core/string.nu): the first match at or aftera byte offset, as an index into the whole string — the find-every-match loop without stepping a raw pointer into the haystack.
catalog at GET /api/v1/catalog (name, latest version, description, symbol index — the fields a search matches), and the playground and nurl-mcp keep it warm on a thread of their own (msearch_registry_warm, ext/mcp_search.nu): fetched at startup and every five minutes while searches run. nurl_api / nurl_grep registry lookups match against it exactly as /api/v1/search does — differentially checked against the Worker's own answers on 287 terms, every field equal — so a cold query takes 2 ms instead of a round trip per query (1.6 s from a cloud container). Against a registry without the endpoint, searches go to /api/v1/search as before.
/api/v1/search
(and the catalog page's search) built a LIKE pattern %q%, which D1 caps at 50 bytes: every query of 49+ characters answered 500 "LIKE or GLOB pattern too complex". The match is now instr over the lower-cased fields — the same ASCII-case-insensitive literal match, with no escaping and no length limit; on every query LIKE could answer the results are identical.
build directory.** nurlc reads the request's source on stdin under its logical filename (nurlc --stdin main.nu), so every diagnostic reads main.nu:6:… — in stderr and in nurlc_errors alike — and the source is no longer written to disk before compiling. The unikernel build passes the name through (build_unikernel.sh --name).
nurlc_errors carries every class of diagnostic. Borrow-checker
errors (then file:line:, no column) were dropped by a parser that only matched file:line:col:, so a build failing on a use after move answered nurlc_errors: []. Each entry now has a severity (error / warning / note) and a col — null rather than guessed where nurlc gives none (borrow-checker errors now carry one, below); the unikernel build answers with the same array.
POST to the server's own endpoints at NURL_MCP_LOOPBACK_URL, whose default was a fixed http://127.0.0.1:8000: a server on another port (NURL_PORT) sent every MCP build to whatever answered on 8000. The default is now the port it listens on.
filename could write outside its build
directory.** It was joined to the directory unchecked, so "filename": "../../x.nu" wrote the source, its IR and the binary wherever the path led. It is now one plain file name ([A-Za-z0-9._-], 1–64 bytes, not starting with . or -), else 400.
… } block was copied whole, method bodies included, so nurl_api` surfaces (and the registry's package docs) carried full source for every impl: nurldb's exec.nu surface was 17.7 KB, now 8.8 KB.
… Missing a '$' import (stdlib/core/vec.nu for Vec)?" — and its unknown-function, unknown-type and generic-function siblings — now find the stdlib module that defines the name and end with the exact line to add: `It is defined in stdlib/core/vec.nu — add this line at the top of the file: $ stdlib/core/vec.nu`. The stdlib is scanned only on the way to the fatal error (≤ 8 ms), so a program that compiles never pays for it. The playground's nurlc_errors entry carries it as an edit: "fix": {"insert_line": 1, "text": "$ …"}.
llvm_to_nurl spells an option
?i (not { i1, i64 }), a result !T E, a slice [T, a pointer *i (it printed Node* for %Node*), a generic instance ( Vec i ) (not Vec__i64); thirty message sites that interpolated the lowered type as is now go through it. An option operand that came from a _get with a non-option twin names it: "'vec_get' returns '?i'; 'vec_at' returns the element itself, bounds-checked". New gate tools/check_diag_nurl_types.sh (CI) keeps lowered types out of the diagnostic goldens.
: i b + a one
operand short swallows the next line, ( nurl_println_int b ), which used to read "use of undefined identifier 'b'" at the use. Now: "the initialiser of 'b' is one operand short and swallowed the statement on line 4", anchored at the binding.
character, the anchor die_stmt uses — and a caret under the echoed line, so every nurlc error has the file:line:col: shape a tool parses.
vec_at / slice_at printed
"index out of bounds: the index is 5 but the length is 2" and then panicked with a bare "index out of bounds", so the console (all a unikernel run shows) said it twice and a recover frame saw only the bare half. The detail is now the panic message itself (nurl_panic_index in the runtime, noreturn).
( vec_at [T] v i ),
vec_put, slice_at and slice_put calls compile to _loc twins that carry the caller's file:line:col as a constant: "index out of bounds at main.nu:5:7: the index is 5 but the length is 2" — the only trace a unikernel console has. Inlined, the location costs nothing until the check fails (a 400M-access loop runs the same instruction count).
nurl_docs serves formal/ — the mechanized proof the docs cite
as ../formal/: formal/CLAIM.md, formal/RULES.md, BLUEPRINT.md, README.md, and the probe table formal/corpus/map.tsv, whose rows a query returns as probe=… rule=… phase=… nurlc=…. name= with query= now searches that one document (it was ignored). The installed toolchain ships them as docs/formal/, the playground image as /opt/nurl/formal (and serves /formal/*, where the docs' links land).
nurl_read_roadmap reads by heading — outline, section, query
— with the same engine as nurl_docs, instead of 43 KB on every call. ROADMAP.md's "What is solid today" bullets are ### sections, so section='memory & safety' is 2 KB. Both servers' nurl_docs now run one shared implementation (msearch_docs_tool).
nurl_api package= opens with the package's module index — each
module's surface size and opening sentence — and module= (one, or several as txn,db) renders surfaces whole. A package larger than one reply answers with the index alone instead of being cut wherever the cap fell: nurldb's surface is ~60 KB, and txn.nu, db.nu, sql.nu and the recovery code were past the cut, invisible. An unknown module is a tool error that lists the package's modules. Both MCP servers (playground and nurl-mcp).
nurl_api query looked each term up in the package registry (the exact-name footer), nurl_grep searched it, package= fetched a tarball — an HTTPS round trip each, 0.7 s apiece from a cloud container against ~5 ms for the local search. Both servers now keep the registry's answers for ten minutes (msearch_cache_init, called from main): a repeated nurl_api query 1.4 s → 50 ms, nurl_grep 0.7 s → 28 ms, and module= after a package index is free. A query's terms are looked up at once, a thread each, so a first (uncached) query waits one round trip instead of one per term: 1.7 s → 0.85 s.
larger): whole-word matching finds candidates with strstr instead of comparing byte by byte at every offset; the same matcher ranks nurl_api's OR pass. Resolving a document's name no longer measures every file's size.
stderr is the whole log, every
stage in order; the per-stage copies beside it (nurlc_stderr, clang_stderr, clang_stdout) are gone — every failed build cost an agent twice the tokens. nurlc_returncode / clang_returncode still say which stage failed.
error (isError), not a result that reads like a build; the nurl_build_native schema marks source required.
msearch_api_package regbase name version module returns !String String (the error side is an unknown module, naming the ones that exist), and msearch_docs_query takes only, one document to search (empty: the whole corpus). msearch_docs_tool / msearch_file_tool are the one entry point for outline / section / query / read. A server built on the old shapes needs those call sites updated; nurl-mcp 0.14.0 is.
(packages/nurl-mcp/tests/stdio_test.sh), and runs the playground server against the checkout's toolchain (`nurlapi/e2e_local.sh --native-only`: native builds, the diagnostics contract, the MCP surface); the Windows job builds piped source with nurl.bat --stdin.
source goes to the
toolchain on stdin under filename, so nurl_check / nurl_build / nurl_run / nurl_build_wasm errors read main.nu:6:12; the formal/ docs, roadmap-by-heading, module= and the warm registry catalog above; stdlib names (core/vec.nu) resolve on an installed toolchain, where $NURL_STDLIB is the prefix that holds stdlib/.
wb_build_source hands the source to nurlc on
stdin under its file name (swarm-mcp's kernel errors no longer name /tmp/wasmbuild-*.d/); new wb_build_text compiles text held in memory to a caller's path.
service's stderr, the one log it now sends.
GET /api/v1/catalog, as the production Workerdoes.
Threads are in the memory model. A multi-threaded relational database written in safe NURL (packages/nurldb: a buffer pool, a write-ahead log with group commit, MVCC with a lock manager and deadlock detection, B+tree indexes with latch crabbing, crash recovery, SQL, a TCP server — no unsafe function) needed what the standard library did not have: an atomic word, a reader-writer lock whose read guards many threads hold at once, a file read and written at offsets by many threads. Building it found eleven holes in the memory guarantee (h288–h298) and a dozen places where the language refused, or mis-compiled, what it should take; each is fixed where it lives. Grammar v2.10: FFI parameter markers and a foreach over a field.
Atomic — a shared word changed only with atomic
instructions (load, store, fetch-add/sub, swap, compare-and-swap, fetch-max); Send and Sync.
RwLocked [A] — many readers or one writer. A read
guard (RGuard, % Frozen) lends a read-only view, a write guard (WGuard) a changeable one; try variants for a caller that must not wait; guards are released by their drop, through a panic too. A holder (a thread, or a fiber that may resume on another thread) may take a second read guard of a lock it already reads — counted, so it never queues behind a writer waiting for the first, whichever side the platform's lock favours (SRWLOCK and FreeBSD's favour writers, glibc's readers); asking for a lock it holds for writing, or to write one it reads, panics instead of waiting for itself, on every platform; a failed lock call panics instead of carrying on unlocked.
% Frozen (core/marker.nu): what a value of the type lends is
read-only, checked as an Arc's payload is; a struct holding an Arc or a frozen value lends frozen views too.
PFile — a file read and written at offsets (pread /
pwrite into and out of slices), shared by any number of threads. On Windows positional I/O is ReadFile/WriteFile with an OVERLAPPED offset instead of seek-then-read, which raced between threads. Its fdatasync and ftruncate exist on the freestanding targets too: nolibc's Linux syscalls, and in the unikernel guest a FAT truncation through the open handle (fatfs_ftruncate, which keeps the handle's size and chain; one truncated by path would write its old size back).
locked_await — wait on a Locked value until a
closure says it is done; locked_update / locked_apply / locked_set wake its waiters.
slice_copy and slice_move; std/deflate.nu
crc32_slice / crc32_update_slice over a view.
in — the C function only
reads the argument during the call, keeping and freeing nothing, so a temporary handed there is released after the call and a frozen view may be passed; sync — it changes what is there only atomically or under a lock of its own; nocapture — its result holds nothing of it. The standard library's path parameters are in (`file_delete ( nurl_str_cat dir /data )` leaked the path).
~ x . s items { … } (grammar v2.10).
Changing the container a loop walks is refused by path: a loop over . s a may change . s b.
: ~ binding, or hands it to an inoutparameter, captures it by reference**, so a callback counts into, bumps or replaces its caller's binding — a closure binding included; such a closure cannot escape its frame (the stack-reference rules refuse returning it or handing it to a thread). The old capture by copy dropped the write silently, or worse (h298); the "assignment is discarded when the closure returns" warning is gone with it. A capture taken by value is as mutable as its binding.
nurl-lang.org/docs/formal-verification holds an interactive explorer of formal/: a step-by-step simulator of the compile-time context and the store for small programs (a vec_push killing a Slice view, a panic unwinding through the drop journal), the theorem and module dependency graphs, every Lean declaration with its dependencies, and the hole corpus with the formal rule that rejects each probe. formal/BLUEPRINT.md is the same architecture as Mermaid graphs for GitHub. webdocs/scripts/gen-formal-data.mjs reads both out of the Lean sources, formal/RULES.md and formal/corpus/map.tsv: the explorer's data on every site build, the blueprint with --blueprint.
MEMORY §7.5, LIMITATIONS and nurl-lang.org/docs still described a closure assigning a capture as a copy whose write is discarded. The \ closure-or-try lookahead (spec §6.4, grammar.ebnf) lists every closure start the parser takes; the concurrency, standard-library, control-flow and trait pages cover Atomic, RwLocked, PFile, % Frozen, locked_await and a foreach over a field; and the hole counts read 286 probes, h01–h298.
Arc lent the payloadunfrozen.
*T parameter was no
change to anything; an unmarked *T is a write now, so a frozen view handed to it is refused.
no drop: the guard was never released.
% Drop implwritten below its first use, skipped the drop.
callee panicked.
stayed registered after it was dropped; a later panic released it again.
: ~ copy of a parameter passed inout to a function thatreplaces an owned field of it released the caller's field. Refused now, by a summary of which functions replace a field of what they are handed (directly or through a call).
?? arm hands out as the match's value stayedregistered for the arm's unwind.
inout parameter and replaced therewas taken for the callee's own: the new environment was freed when the callee returned, the old one leaked, and the caller ran freed memory. A closure binding reached through a pointer is now a place that owns its environment, as a struct field is (docs/MEMORY.md §7.5); a closure binding's owner slot is a flag, and what is dropped is the environment its value holds — right after a callee replaced it, a panic included.
: ~ String to an inoutparameter took it by value; the callee replaced the copy and released the buffer the caller's binding still held. (A number's write was lost.)
register of the enclosing function).
{ … } drops what it binds at its }, and a block that computes
a number (: i n { … }) drops at its end too: a guard bound there held its lock until the function returned.
( Pin WGuard )) had no
drop function emitted (undefined WGuard_drop__Fr).
= x ? c ( fresh ) ( fresh ) was taken for a borrow of whatever xjoined last.
sink parameter copies it
(( locked_new [i] n ) twice in one literal was "released by field 2").
~ x xs loop's element is a binding of its own for the borrow checker:
an earlier x that was moved no longer makes every use in the body a use after move, and its slot no longer outlives the loop (invalid IR).
? c { … } { … } followed by a block statement is not an arity errorwhen both arms are braced.
\ ( Slice u ) s → …: a closure's first parameter may be of a genericstruct type.
sort_by over a Vec of raw pointers emitted a clone call that doesnot exist (invalid IR).
~ T loop that only returnsis not taken for one that breaks.
multi-threaded throughout — 8 KiB checksummed pages; a buffer pool of reader-writer-latched frames with atomic pins and clock eviction that keeps the WAL rule; a log with group commit (one flusher thread, ~4 commits per fdatasync from eight sessions); snapshot isolation with a lock manager, FIFO waits and deadlock detection; B+tree indexes with latch crabbing; recovery with torn-tail cut, redo and full-page images; SQL with joins, aggregation run on four threads, EXPLAIN; a TCP server with a thread per connection. 151 tests, among them a child killed with SIGKILL mid-flight whose every acknowledged commit survives.
its own offset (PFile); the frozen check found the shared C stream.
Published to the registry after 0.74.0:
against itself — the allocator ended the compared operand's live range at its read, before the sum was written, so the sum could take its register (lea rsi, [rdx+7]; cmp rsi, rsi). The wasm benchmark's correctness gate found it: x25519 printed a wrong checksum under nwasm (3.1.2 and 2.4.2 alike), a bounds-checked little-endian u64 read losing its top byte. Not a toolchain fault: the module is byte-identical under 0.74.0.
A closure type and a trait object are generic type arguments like any other type: ( Vec ( @ v i ) ), ( vec_new [( @ i i )] ), ( HashMap i ( @ i i ) ), ( Rc ( @ v ) ), ( Vec %Show ). The spec has said so since generics landed; in fact ( vec_new [( @ i i )] ) was refused with "declares 1 type parameter(s) but this call supplies 6", ( Box ( @ i i ) ) instantiated a struct named Box__@__i64__i64, and a list of callbacks had to be a Vec of one-field structs. Four holes in the memory guarantee were found on the way and are closed (h281–h284).
Modelling closures for the mechanized proof of the memory model (formal/, phase A2) found ten safe programs the compiler accepted and that read freed memory under ASan (h274–h280, h285–h287), one family: a closure that changes a binding it captured, run where something else holds a view of it. Seven were a closure handed to a call, or captured, beside what it changes; the last three were a closure holding a view of a capture across a call to a closure its caller chose — one of its own closure parameters (h285), or a closure a parameter holds in a struct field (h286) or a Vec element (h287) — that re-enters and grows the capture: running a captured closure carried its effect onto the enclosing closure, but running one the caller supplied carried none. They are rejected now (Fixed), and no hole is known.
The mechanized proof of the memory model, which 0.73.0 started with nothing proven, has its first two phases done (formal/, ./check.sh: no sorry, Lean's standard axioms only). Phase A1 is the core calculus — owned values, moves and drop flags, borrows and views, a Vec that reallocates, inout and sink — sound by progress and preservation (A1.memory_safe). Phase A2 adds structs and enums with field-precise paths, closures and trait objects with their captures — by handle, and by name including a closure that changes a capture and is handed to a call, captured or held by a borrowed binding — panics with unwinding and recover, the drop journal, and leak freedom (A2.memory_safe, A2.no_leak_end). It proves the rules of the single-threaded language, not yet nurlc's implementation of them (phase C) or threads (A3). formal/README.md, formal/RULES.md and formal/A2/CAPTURES_DESIGN.md record what each rule of docs/MEMORY.md is in the calculus, and what A2 does not model yet.
one-word spelling travels through a template as its slug: a closure as fn<N> followed by _<len>_<slug> for its return type and each parameter (( @ i i ) → fn1_3_i64_3_i64), a trait object as dyn_<Trait>. The lengths make it a prefix code, so the slug reads back to exactly the type it was made from — parts that are closures, options, inout parameters, slices, results or structs with underscores in their names included (docs/spec.md §4.8). It is a spelling only: the value is the same { fn, env } pair, so no generated code changes.
closure field (docs/MEMORY.md §7.5): a literal's or a call's env moves in, a binding's is copied, dropping or clearing the container releases each, copying it copies each, and the cycle collector traces and releases an Rc of a closure that captured that Rc. A foreach over a Vec of closures and a vec_get payload are lent at no cost; vec_at and rc_get hand back a copy (a closure result is always its caller's).
( mem_dup f ) of a closure is a copy that owns its environment.ws_serve_messages_deflate_with_context and its
ws_client_* mirror (stdlib/ext/websocket.nu): the read loop gives its handler the deflate context beside each message, so a handler that replies compressed uses the context the loop reads with instead of capturing it — the same context reached twice, which the closure rule below rejects. examples/ws_deflate_echo.nu uses it.
formal/: phases A1 and A2 proven (Lean 4, core library only).
A1/ is the core calculus and its soundness (A1.memory_safe: a program whose functions check against their summaries never reaches a stuck configuration), including the call case — a callee's body, typed once against its own view of its parameters, is typed where a call places it (Rename). A2/ extends every module and adds the panic path (Panic), leak freedom at the end of a program, normal or by panic (Leak, A2.no_leak_end), and calls through a closure or trait object (Closure). formal/check.sh builds the proof and fails on a sorry or a new axiom; formal/corpus/map.tsv names, for every hole probe, the formal rule that rejects it, and formal/RULES.md each rule of docs/MEMORY.md.
deps/<x> import a package ships must be declared in its
manifest.** tools/check_package_imports.sh applies the registry publish gate's rule on every change: a packaged source (src/** or a *.nu in the package root) that imports deps/<x>/… needs <x> in that package's [dependencies]. deps/ is a local artefact, so an undeclared dependency compiled in the repo and failed for every registry install — objdet 0.4.4 imported deps/gpu without declaring it, passed CI, and was stopped only by nurlpkg publish during the 0.73.0 package sweep. tools/tests/test_package_imports.py holds the rules (10 cases).
repository docs (spec, MEMORY, LIMITATIONS, ASYNC, BUILDING, CRYPTO, NETWORKING) described several things as they were before 0.73.0 — among them that an out-of-range shift count is poison (a constant one is rejected, a dynamic one panics) and that the ? & a b c d arity trap is a warning (it is an error unless --no-strict-arity). The registry serves its favicon.svg.
owned nothing, so handing one back — ^ o after : ?( @ i i ) o @ ?( @ i i ) { T f }, in a struct field, or as !( @ i i ) E — returned an environment the frame had just freed (a crash in safe code). An option or result of a closure now owns the environment and is dropped and copied through its twin like ?String.
payload, or a container's element) did not own its environment, and the borrow checker tracked closure bindings only by their captures, so replacing the field and then calling the closure called freed memory. Such a closure is now a borrow of what it was read out of; using it after that owner died, was given a new value, had a field replaced or had its elements dropped is a compile error.
call** (docs/MEMORY.md §2.11). Handed to a call, or called itself, a closure whose body may move a capture's buffer or drop its elements may not meet another argument that reaches that capture — the binding, a borrow or a view of it, or another closure that captured it: inside the callee the two were unrelated names, and growing the Vec through the closure left the other dangling (h274: a closure holding a view of its capture beside one growing it; h275: a Vec passed in beside a closure growing it; h276: the closure called with the grower; h277: an element borrow beside a closure clearing its Vec). Decided at the borrow walk with the final summaries (clox rows, bck_clo_route_check), like the exclusive call of two arguments.
(h278, h280): a view of what the inner closure grows ends where the outer one runs. Before, the inner closure's effect was dropped because it concerned a binding the outer one reached only through it.
it** (h279), checked where the literal is made.
views of what the closure captured by reference** (h285–h287): a closure parameter, or a closure a parameter holds — a struct field, a Vec element, a closure a call handed back — has captures the caller picked, so running it may re-enter this closure and grow what it captured; its views end at the call, as at a known growing closure. Opaque is a closure whose capture set has an atom nobody at the call knows: a parameter's, anybody's (a closure read through a raw pointer, as vec_at hands an element back), or a call whose callee is compiled later and whose answer is not in yet (__cs_opaque, bck_opaque_param_run). Additive: it ends more borrows, admits no program it did not. A verdict-only change: the compiler before and after it emits byte-identical LLVM IR for every benchmark, example and compiler test (1478 programs), the benchmark binaries are byte-identical, and compiling compiler/nurlc.nu takes the same time (within noise).
Published to the registry after 0.73.0:
detector, not a loudness threshold.** Silero VAD v5 runs in NURL on the CPU (0.4 ms per 32 ms chunk, within 5·10⁻⁶ of onnxruntime; the weights install beside the binary), and the endpointer learns the room's noise floor, the speaker's level and the speaker's own pauses, ignores voices well below the speaker's (a television), and makes the final during the pause. End of speech to final on large-v3: ~930 ms → 73–306 ms in a quiet room; with a television 12 dB below, 1–6.7 s with wrong text → 185–406 ms with every answer right. Drafts with a stable prefix, a per-stream configuration and whisper vad <audio.wav> are new.
whisper serve ran one worker thread, so one open
WebSocket stream held up /health, /inference and every other stream until it closed. It runs 8 workers; transcriptions take a GPU lock one at a time, and a draft that finds the GPU busy is skipped.
log_mel_whisper_padded computes whisper's log-melonly over the frames that see audio, bit-identical to padding first (a 2-second clip: ~117 ms → ~10 ms).
No hole in the memory guarantee is known. The 37 that 0.72.0 shipped as known issues are closed, along with 95 more found while closing them (probes h142–h273): each is rejected or runs clean under ASan/UBSan/LSan, CI holds every one, and tools/fuzz/holes/open/ is empty (docs/MEMORY.md §6.2). Nothing a program keeps is released by hand any more — not in the examples, the bench kernels or any package, and in the standard library only inside the owners that implement it. An Arc's payload is frozen once made, shared state that changes lives in the new Locked [T], and a scalar mutable global is read and written atomically. Grammar v2.9 adds raw for functions whose soundness rests on their caller; with it and checked views (slice_window, file_map, par_map_index) the packages shed most of their unsafe: gpukit's GEMM, attention and convolutions and every kernel of nurllama, whisper, grad and f5tts are checked kernels, bit-identical to the raw ones and as fast or faster, and WebGPU builds run again (Packages). Code that calls mem_forget, mem_take, mem_put_back or the rcbox primitives must be unsafe now (Changed). The compiler compiles itself — 10 % more source than 0.72.0's — in 8 % fewer instructions and 8 % less time than 0.72.0 compiled its own (same build, 11.58 G against 12.62 G instructions). formal/ starts a mechanized proof of the guarantee (Added).
A view handed to a function defined further down the file no longer makes the container it was handed with stale by itself. In raw code, ( f c ( string_data x ) ) with f below the call was taken to keep the view in c, so a later mutation of x rejected the next use of c even when f only copied the string — and with f above, the same program compiled. The borrow is now conditional on f keeping that argument and is decided after the module, like the other verdicts that depend on a callee (compiler/tests/diag_stale_borrow_kept_forward.nu). gpukit hit it: gk_run_checked sits above the window parser it calls.
Mutable globals no longer race. A scalar mutable global (an integer, bool, float or s) is read and written with relaxed atomics, so two threads touching it see whole values instead of having undefined behaviour; on x86-64 and AArch64 the instructions are the same ones (the compiler's self-compile, the heaviest user of globals there is, retires 0.4 % more instructions in the same cycles). Two holes in safe code are closed: a struct global was read and written whole from several threads (h272; it is now unsafe-only, like a global that owns something), and a s global took ownership of a fresh string, which the next assignment freed under a reader in another thread — a use-after-free ASan reports within a few thousand iterations (h273; in safe code a s global now holds literals, and text that changes at run time lives in a ( Locked String ) set at start). agora's refusal message had exactly that race and moves into a Locked String. nurlc runs on one thread, and the 47 functions that store owned strings into its globals are marked unsafe. A ( mem_dup x ) copy handed straight to a call (( f ( mem_dup x ) )) was never dropped: the copy is no call, so the argument path had no callee to ask whether the caller owned it. It is dropped after the call now, as any owned call result is (compiler/tests/mem_dup_temp_arg.nu); packages/embed leaked a share of every weight chunk per forward this way. The borrow checker no longer takes a binding whose block has ended for live storage of the caller's: a local copy of a parameter's element read in a loop body made a later store into another field of that parameter an error (compiler/tests/loop_copy_then_param_field_store.nu). A bare { … } block in statement position is a dead value exactly when its last statement is one: a scope block that ends in an assignment or a call no longer draws "has no effect", and one that ends in a bare value is reported at that value's line instead of the block's (compiler/tests/should_warn_dead_value.nu).
Taking unsafe out of the packages. A handle's state is changed in safe code now: rc_take / rc_restore (stdlib/std/rc.nu) and locked_take / locked_restore (stdlib/std/thread.nu) check the state out of an Rc or a Locked into a local — nothing copied — for inout helpers to change, and back in; every other use of it panics (Rc) or waits (Locked) until it is back (docs/MEMORY.md §7.6). slice_prefix cuts parallel columns to one length, so a walk over them checks each index once. Bounds checks (vec_at, vec_put, vec_get, vec_set, slice_*) are one unsigned compare: a vec_at / vec_put write loop runs in 1.0 s where it took 1.4 s. arc_get lends a payload that has a % Drop and no clone (the copying branch for a cyclic payload is dead for it, and the compiler no longer rejects code a constant condition leaves dead). now_ms / now_seconds read the clock in one runtime call instead of looking CLOCK_REALTIME up by name first (~2 300 instructions a call). term_read_password and tls_accept_conn are new safe wrappers.
Grammar v2.9 adds raw (spec §3.3e). raw @ f … is a function whose soundness rests on its caller — an address it is handed, a size it trusts, kernel code it runs — so only an unsafe function may call it, the rule a foreign function declared unsafe & … already follows; raw : S { … } seals a struct whose fields carry such an invariant. raw is no reserved word: it is the prefix only right before a top-level @ or :. nurlfmt keeps it on its declaration's line.
string_push_window appends a byte range of a view to a String, string_bytes views a String's own bytes (NULs included, no strlen), and slice_bytes_eq / slice_bytes_cmp compare two byte views — what a tokenizer or parser used pointer arithmetic for. tokenizer has one unsafe function left of 42, with bit-identical encodings.
slice_window s from n is the n elements from from, checked once, so a loop to n over it needs no check per element; storing a number through one window no longer ends a borrow of another window of the same Vec. mlp's training loop over windows has no unsafe left and runs 26% faster than the raw-pointer loop it replaces (2.12 s against 2.85 s, bit-identical output).
A split build (nurl.sh's default, one module part per core) inlined nothing small across parts: ThinLTO imported slice_at and the Vec accessors, but mapped their struct types onto structurally equal ones of the importing module (every Vec is { ptr } under opaque pointers), and a call whose type differs from its callee's is never inlined — mlp's slice loop ran 4× slower split than unsplit. A small function is now also copied (linkonce_odr) into each part that calls it; a large program's build takes ~20% longer than before (anomaly: 48 s → 58 s, against 192 s unsplit). Functions start on a 64-byte line (-align-all-functions=6), so one function's size no longer moves another's hot loop across a Skylake JCC boundary: mlp's gradfit_test retired the same instructions in 9 s or 13 s depending on an unrelated edit, and no longer does.
Seven more holes are closed (h249–h255), and none is known open: functions that take a runtime handle as a plain integer (tcp_conn_view, tls_attach*, tls_accept*, the nurl_tcp_* / nurl_udp_* / nurl_proc_* builtins and stdlib FFI, the library's raw symbol table nurl_sym_*) were callable from safe code, so a made-up number was read or freed as a socket or a table, and the unikernel HAL's mmio_* / virtio_* / virtq_* helpers wrote to any physical address. They are unsafe only; a stdlib foreign function says so with unsafe & …. SymTab could be built around a made-up number or shared between threads: it is sealed, move-only and % NotSync now. The language server reaches its tables through two accessors instead of raw handles. h256: file_from_raw closed any string as a C stream and file_raw handed a FILE* out as text; both are raw, and file_is_open is the safe question.
Packages, where raw was missing: gpukit's gk_run / gk_run_dev ran kernel source over device addresses given as integers and gk_buf_wrap took any integer as device memory, from safe code; they are raw, and GkBuf, gpukit's pool blocks and onnx's RTensor are raw structs. gpukit's operations check what their kernels index before they launch: sizes through overflow-checked products (a wrapped m*k passed for a buffer's length), views inside their buffer, gk_matmul_f / the elementwise and dot kernels against their Vecs' lengths, and the expression gk_map_f / gkd_map splice into kernel source limited to x, numbers and math functions (operators to + - * /). lingbot-map's rope kernels range-check the positions they look tables up by. onnx's tensors carry the engine and run they came from: rt_download and dtensor_from_output read a tensor of an earlier run — whose device memory rt_reset had released — and now refuse it. Functions that launch kernels of their own (f5tts, grad, onnx, lingbot-map) are unsafe where they establish what the kernel needs, and f5tts's launchers over raw device addresses are raw.
The gpu package (0.17.0) underneath them had the same gap, six ways: safe code built a GpuBuffer from any number (a literal or gpu_buffer_view), copied device-to-device from any address (gpu_dtod), made an owner that freed an address it never allocated, compiled and launched arbitrary kernel source, and indexed a GpuHost past its end with the unchecked gpu_host_get_f32 / …set…. The handle structs are raw, those functions are raw, and the host accessors panic past the end; packages/gpu/tests/holes_test.sh holds all six. nurllama's and whisper's kernel launchers took device addresses as plain integers from safe code and are raw too. The safe way in is a view: gpu_host_f32 lends a GpuHost as a bounds-checked Slice f32 that lives no longer than it, and onnx's rt_run_host / rt_run_two_host / rt_run_tokens_host take GpuHost inputs they check against the shape. With them objdet (7 → 4), yoloe (34 → 24) and yoloe-demo (14 → 5) keep unsafe only for their FFI — bit-identical, and the mask and image-packing loops run 10–30% faster through windows than through the pointers. template (35 → 0) and pki-server (19 → 0) keep their state as a frozen Arc read with arc_get and, where it changes, a Locked snapshot or an inout local; template's tset_add used to change a set in place while a render on another worker read it, and an {% include %} pointed the render at a source a concurrent add could free. gRPC (22 → 0), http-client (32 → 0) and audio (29 → 0) follow: an HttpClient is a Locked (requests on two threads changed its pool and cookie jar through arc_ptr at once), a GrpcClient an Rc the compiler keeps on one thread (its HTTP/2 transport is not synchronised), a gRPC server's call table and an origin pool are changed by checking a record out and back in, and a VadStream is a Locked. Three stdlib additions close the gaps they hit: h2_stream_data_at sends from an offset of a buffer (what a flush loop faked with a raw view of the unsent tail), hp_stream_open_on_vec takes the request body as a Vec, and h3_client_ok asks whether h3_client_connect connected at all (callers read the handle's raw control word). A raw : S { … } on the line after an import was taken for the import's alias (only raw @ was excluded), and the struct then broke as a generic argument; a raw before : is the prefix too now. Storing an f32 through a slice window ended the borrows of the other windows (its instances are named for LLVM's float, which the scalar rule did not list); it no longer does.
http (30 → 0) and image (14 → 0) follow: an HttpApp's setters check its state out of the Rc, the request dispatcher reads a frozen Arc of the router and the static root, and the HTTP/3 side of listen holds options instead of handles forged from zero; a JPEG decoder reads its input through a Slice u (0.3% fewer instructions, same output). http3_creds_ok asks whether http3_creds_load loaded anything. h257: a binding read out of a field takes the field's value over when the field is replaced (h196), and storing it back copied it as if it still borrowed — the value it owned was never dropped (`: ( Vec i ) c . s v = . s v ( vec_new ) ( work s c ) = . s v c`, the progressive JPEG scan loop). Such a store moves the binding when it owns the value and copies it only while it borrows. h259: a closure-valued ? / ?? statement whose arm ended in an assignment (= base ( wrap base )) took the assigned closure for an owned temporary of the join and freed it when the statement ended, under the binding that held it (http's TLS listener crashed at startup wrapping its handler for HTTP/3). A join nothing takes owns nothing now: an arm drops a temporary it made, an assigned closure stays its binding's, and a join whose value is taken gets an owned copy from every arm.
stdlib/std/mmap.nu maps a file read-only (file_map, or a whole-file read where the platform does not map files; mapped_of_bytes wraps bytes already in memory) and lends it as a checked Slice u (mapped_bytes) the borrow checker holds to the MappedFile; the last copy unmaps it, and mapped_ptr is the raw address for a device upload. Every model loader wrote its own open / lseek / mmap / munmap and read through the pointer. slice_u16_le / slice_u32_le / slice_u64_le read a little-endian word at a byte offset of a view and slice_put_u16_le / slice_put_u32_le / slice_put_u64_le store one — one bounds check per word, the bytes fused into one unaligned access — and slice_to_vec copies a view into a Vec of its own (the borrow checker's messages already named it; it did not exist). safetensor (34 → 0) reads through them: its tensors are views of the mapping, a tensor table that lies panics rather than reading past it, and st_dequant is 6–24% faster than its pointer loops (F32 is one copy).
h260: zip_open (stdlib/ext/zip.nu) was safe and the archive it returned kept a bare pointer into the caller's Vec, so the Vec could go first and every later read of the archive read freed memory. A ZipArchive now reads a MappedFile it owns or shares, through checked views: zip_open keeps a copy of the bytes, zip_open_owned takes them over, zip_open_mapped shares a mapping, and zip_open_ptr is gone (its one caller, torchpt, maps the file instead). torchpt (94 → 0) reads its checkpoint and its pickle that way. h261: a String / Vec / handle temporary handed to a call whose result is !T E with an enum error (!ZipArchive ZipErr) was never dropped — the result did not count as a value that cannot hold the argument, so the temporary waited for a consumer a binding never is.
gguf (73 → 0) reads its file the same way. Its dequantiser takes each block as a window of the block's own size (slice_window P * b 18 18) and writes it through a window of the output, so every index inside is provably in range and the bounds checks leave the loop: 2–4× faster than the pointer loops it replaces, the same bytes out. For that, a view of plain numbers handed to a safe function (( Slice u ) w into a helper that stores an f32 through it) no longer ends the borrows of the other views of the same Vec — a Slice never moves its storage and a number owns nothing to drop; until now only a direct slice_put was known to be harmless. h262: a float passed where a parameter is a struct by value (( first x ) with f x and a ( Slice u ) parameter) compiled to an ill-typed call that read the double as the slice; integers were already rejected (h188), floats are now too.
stdlib/std/par.nu's par_map_index n nt f runs f k for every index on up to nt threads and returns the results by index — each worker sends (index, result) back over a channel, so nothing is shared mutably. A batch of independent evaluations (an optimizer's gradient stencil, one model per series) was otherwise written by handing worker threads raw addresses of their result slots. arima (101 → 1, the GPU kernel launch) evaluates through it; its matrix rows are windows and its a[i+1] loops read windows that start at 1, so the bounds checks leave the loops: the same fits take 23% fewer instructions, bit-identical. An ArimaModel is a Locked handle — two threads stepping one model through its Arc raced. map-anything (42 → 1: the ONNX sky-mask hand-off) works on checked slices throughout, and uploads weights from a checked view of the mapping with gpukit's new gk_dbuf_upload_bytes; gk_dbuf_upload_raw is raw. embed (43 → 2: carving device memory and the batched upload) keeps its engine in a Locked beside its tokenizer, configuration and status, and its server queue is a Channel. gpukit (64 → 34) keeps a kit's state — kernel cache, profiler, device-memory pool — in a Locked beside its immutable device record: two threads using one kit raced on the cache and the pool through arc_ptr. lingbot-map (66 → 2, its RoPE launches) runs its host reference code over slices and its checkpoint through a checked byte view; its parsed options held views of strings the parser had freed. lsmdb (104 → 0) is a Locked store over values it owns (memtable, log) and table readers whose Bloom filters answer without a lock; it matches the previous release's speed. locked_restore and rc_restore no longer run the zero bytes *_take left through the value's drop glue (200 instructions a call on lsmdb's store), and locked_restore wakes nobody when nobody waits. nurl-cov (103 → 0) reads its coverage graphs through checked byte views into a plain value and freezes it in an Arc once solved; its per-line table is read in place, where the old accessors handed out a copy of a whole column per call. Output is byte-identical, llvm-cov differential and 201 corrupted graphs included. A call to an undefined generic inside another generic's body is now reported as such, at the call: it was typed i64 and reported as a type mismatch against what it initialised (rc.nu called mem_replace without importing stdlib/core/mem.nu). onnx (76 → 2, the census's dummy device buffers) keeps its engine in a Locked and gives every tensor its own pooled device buffer: an aliasing Reshape holds a view of the block it aliases, a tensor outlives the run that made it, and a host-int operand fails its kernel where 0.11 launched on its -1 sentinel address and crashed. Its run entry points take the input as a checked byte view; gpukit adds the matching gk_dbuf_download_bytes and a checked device copy, gk_dbuf_copy. Outputs are byte-identical and as fast. A struct passed where the parameter is a number is rejected (h269): the call was emitted with the struct's type, and the callee read its control block's address as the number — onnx's parse_dump checksummed a weight block's control block. A function handing back the payload of a vec_get on its parameter's field (^ ?? ( vec_get . p items i ) { T x → x … }) now lends that parameter (h270): its callers' binding of the result was not tied to the argument, so emptying the argument's Vec afterwards left the binding reading freed elements, where the same vec_get in the caller was rejected. So does a function returning another's lent result over its parameter (^ ( g p )) when g was compiled first (h271): only a g compiled after it passed its lend on. onnx read its ops' inputs through such an accessor and then grew the value map they live in; each op now gets its inputs as tensors of its own (a counted share of the device block), and rt_output1 no longer hands back a view of the engine's map after the engine goes back into its Locked. nurlbox (90 → 8, the shell's fork/pipe/exec and find -exec/xargs/ env) passes its shell state inout instead of reading it out of a raw global word. The standard library gains what it reached for raw pointers to do: string_append (a whole String, NULs included), write_slice (a byte view to stdout), fs_link (a hard link) and proc_self_uid / _euid / _gid / _egid / _groups, with Windows and unikernel fallbacks in the runtime. grad (146 → 6, the kernel launches and f32 device transfers) passes its tape, optimizer and device replay as plain values inout; backward takes a node's gradient out of the tape while it accumulates into its inputs' and puts it back. Gradients are bit-identical on every test; nn, mlp and nurllama follow. swarm-mcp (105 → 0) keeps the coordinator's registry in a Locked its tool handlers check out per call, and its node, retry plans, iterate runs, roster and parser are plain values. process_run_strings runs a command over an argv of Strings the caller owns (built in a loop, say), and mcp_server_set_task_store takes the store it keeps (sink; pass a share to go on using it) — borrowed, a hook capturing state that held the store's other share read as a cycle through the store. f5tts (108 → 6, its raw kernel launches) keeps its engine and vocoder in Lockeds built whole, uploads weights from checked byte views, and serves through a Channel whose model thread owns the engine and the voice cache outright — a voice added or deleted over HTTP was dropped from that cache on the HTTP thread while the model thread could be reading it. nurllama (68 → 8) keeps its engine and its finetune state in Lockeds — two requests evaluating one model raced on its KV cache — and samples from a row of logits read in one take. whisper (57 → 16, the raw device work) keeps its engine in a Locked beside its dimensions and its server state in Locked globals set at start; its ggml loader reads the mapping through checked slices. It now checks every tensor's size against the config at load: a checkpoint whose FFN was not 4·d_model was read past the end of its buffers. tokenizer (1 → 0) owns its map keys; its pre-tokenizers now classify characters by Unicode category, as HF's regexes do — every non-ASCII byte counted as a letter, and (§, ½ or a no-break space split unlike the model's own tokenizer. yoloe has 13 left (19), each an ioctl, an mmap'd camera buffer or an Xlib call. agora (36 → 0), mermaid-server (3 → 0) and tensor (6 → 0) keep their program-wide state in Locked globals set at start instead of raw globals behind mem_addr. benchmark (11 → 0) reads its CSV keys from checked cell views and sorts them with sort_by, which beats the pointer quicksort it carried by a quarter (58 ms a pass against 78).
Packages: oauth, iforest, vindex, data, cli, psql, redis, zst, video, ply, swarm and mlp have no unsafe function left (video / ply / swarm keep their state in a Locked or a frozen Arc; zst's inspector read past a truncated file and reads zeros now), and 13 functions elsewhere were marked unsafe with nothing raw in them; about a hundred more raw vec_data loops are slices. oauth's provider and policy were shared between a server's workers through an Arc changed in place — a data race; they are a Locked now, with the key set and the policy read as Arc snapshots, so verification still runs in parallel.
Four holes found while taking unsafe out of the packages are closed (h245–h248): a view of an Rc's value outlived an rc_set made through another handle on the same value — a clone, a field or an aliased parameter — and vec_put_back wrote an element back into a slot it was not read from. vec_put_back is now unsafe only, as mem_put_back is (docs/MEMORY.md §6.2). slice_at / slice_put (stdlib/core/slice.nu) are the bounds-checked indexing a kernel over several Vecs wants: a loop that writes one Vec while reading others through slices runs as fast as the raw-pointer loop (0.30 s against 0.31 s on a 4000-element c[k] = a[k] + 2 b[k], where vec_at / vec_put take 1.3 s), so the packages' raw vec_data loops become safe ones.
Every hole the 0.72.0 release review left open in the memory guarantee is closed: the 34 probes of tools/fuzz/holes/open/ (now h145–h178) and sixteen more found on the way (h179–h194) are rejected or run clean under ASan/UBSan/LSan, and CI holds each of them — no hole is known (docs/MEMORY.md §6.2). None of the fixes asks a program to release anything by hand: a raw string a callee takes over is released by the callee, and a value a destructor gives up is still dropped by its glue. The compiler compiles itself in 4.0 % fewer instructions and ~7 % less CPU time than 0.72.0 on the same source, json_parse retires 3.8 % fewer instructions, and every other bench kernel the same or fewer (Performance).
Code that calls mem_forget, mem_take, mem_put_back or the rcbox primitives must now be unsafe (Changed) — and no package's library handle needs them any more: the handles hold an Arc (an Rc where the state can reach its own handle), whose hooks are safe code, and a handle that may be absent is an option. The 42 packages this touches, and objdet, are released with it, and they have fewer unsafe functions than under 0.72.0, not more (Packages). A package's version is written once: ( pkg_version ) reads it from nurl.toml at compile time (Added).
Nothing a program keeps has to be released by hand any more, in safe code or in unsafe. A field written through a pointer replaces what it held, a module global owns its String, Vec, struct, option or handle, a C function that writes its result through a pointer takes an inout parameter, a ( Box T ) keeps a record's address put, a RawBlock is raw memory that releases itself, and mem_replace takes a value out of a place by leaving another in it (Added, Changed). None of the 164 package functions that allocated, released or claimed (mem_take) memory themselves does any more — the 322 calls are gone — and neither do the examples or the bench kernels: the 30 packages are released without it (Packages). The standard library's own such calls fell from 651 to 194, every one of them now inside an owner's implementation (a container, a counted block, Cell and RawBlock, a pthread primitive): its 87 handle types hold their state in a generic ( Shared T ) whose hooks do the releasing, and a foreign function that returns a string it allocated says so (→ sink s). Making module state and raw-pointer records manage themselves found nine more holes, making the TCP handles close themselves four more, and probing the new rules one, all closed (h195–h208, Fixed). A TcpConn or TcpListener closes its socket with its last owner (Changed). The compiler compiles itself in 16 % fewer instructions and 9–10 % less time than before this work (Performance).
An Arc's payload is frozen once made: safe code no longer changes what every clone reads without a lock, on any thread and under any lock, and shared state that changes lives in the new Locked [T], whose own lock its operations hold (Changed, Added). That closes the last two ways found to race a payload — a mutation the check did not follow (h212) and the making thread changing it under a worker (h213) — and replaces the compiler's lock counting with a rule that needs none. The rule runs on the borrow walk, and building it there found two holes in the walk itself, both use-after-free from safe code on the previous release: a binding of a nested lending call borrowed nothing (h214), a helper handing back what a generic accessor lent said it lent nothing (h215), and a block's value handed out through the block's own binding borrowed nothing (h216) (Fixed). Following the one source it still left unmodelled, a call through a closure value, found two more: a body's final value written without ^ was not judged as ^ judges it, so a closure falling off with what it lent (h217) and a function falling off with a lend — even @ idv ( Vec i ) v → ( Vec i ) { v } (h218) — made their callers release what they did not own. Both spellings now mean the same, and the metamorphic matrix holds them side by side (Fixed). What a closure hands back is its caller's own, so three more followed: one that cannot be copied and that the closure does not own is rejected (h219), a raw string it lent comes back a copy and any other view of what it captured is rejected (h220), and no function hands back a field it cannot copy out of a struct it drops (h221) (Fixed, Changed). What a closure captures it holds by the same rules: a value it cannot copy that its body moves out is the env's from then on (h222), a closure that only reads one is a view of its binding and does not outlive it (h224), and a sink parameter is the function's own to the view walk (h225) (Fixed, Changed). An option or result of a value that cannot be copied is now dropped when bound (h223), a match on a borrowed one no longer releases its owner's payload (h226), and a payload the arm only borrows is not handed to an owner (h227) (Fixed). What a lending call hands back is no sink's to release: a String comes over as a copy (h228), and a value that cannot be copied — lent, or a field of a struct that still drops it — is rejected where it would be stored or released (h229, h230) (Fixed, Changed). An enum lends its payload to a match's arms — a String goes to a sink as a copy, one that cannot be copied is rejected (h231) — and a temporary that cannot be copied, or a matched enum a call handed over, is dropped where nothing else owns it (h232, h233) (Fixed). A temporary that cannot be copied handed straight to a call is dropped after it or moves into the result (h234), and a generic that copies its T cannot be instantiated with one (h235) (Fixed, Changed).
inout parameters (spec §7.2). A closure type may
write ( @ R inout T ) and a closure literal \ inout T x → R { … }: the closure takes the caller's mutable binding by address, as an inout function parameter does, and changes it in place. The borrow checker holds it to the same rules: the argument must be a mutable binding, and every view into it ends at the call (a closure value carries no summary). It also rejects handing a closure inout a binding the closure captures, and passing a binding inout beside a closure argument that captures it. New in stdlib/std/thread.nu: locked_update and locked_apply run a closure on a Locked value where it lives, instead of locked_take moving it out and locked_restore moving it back. nwasm 3.0.0 uses them to run its instance under a lock without unsafe.
float_round_even (stdlib/std/float.nu): round to the nearest
integer, ties to even (IEEE roundTiesToEven, wasm's f*.nearest), where float_round takes ties away from zero. A package that needed it declared libm rint itself, a foreign call that only unsafe code may make.
vec_update / vec_apply (stdlib/core/vec.nu, spec §7.2). Run
a closure on one element of a Vec in place: a record that owns buffers is neither copied nor moved. vec_apply hands back the closure's result, or a fallback when the index is out of range. The Vec is passed inout, so a closure that could reach it (capturing it, or the record it is a field of) is rejected: nothing grows or shrinks the Vec under the element. The capture check now also covers a field passed inout (. c fds beside a closure capturing c) and closures written inline at the call, which it missed before. nwasm 3.1.0 keeps its WASI descriptor table with them, without unsafe.
process_run_inherit runs an argv as a child sharing this process's stdio and returns its exit status. posix_execvp replaces the process image from a ( Vec String ). posix_wait_exit waits and decodes the status, posix_pipe returns the descriptor pair as a PosixPipe, and posix_read_append reads into a Vec. Code that drove fork/exec by hand had to build argv arrays, status words and pipe buffers through raw pointers; nurlbox's shell, env, find -exec and xargs need no unsafe now.
grammar start_init). : ( Locked T ) g ( make ) runs make, a function of no arguments, once before main; nothing writes g after, and its type must synchronise itself (Locked, Arc, Channel, Mutex, Cond, Semaphore), so safe code reads it from any thread — program-wide state no longer needs an unsafe accessor over a raw global and a hand-held mutex. A ( Vec T ) set at start is rejected (h263: two threads pushing through it raced).
locked_zero, fd_write_all, file_map_stream, file_none. locked_zero [T]
is a Locked over the all-zero T, under the arc_zero rule (outside unsafe only for a T a zero is a value of): a large record built field by field under locked_take. fd_write_all fd v writes a whole Vec u to a file descriptor, continuing short writes. file_map_stream maps a file for one sequential read (MAP_POPULATE, MADV_SEQUENTIAL). file_none is the null File, a placeholder for one opened later (programs forged it from a null pointer).
map_get_with and stdlib/std/unicat.nu. A HashMap that owns its
String keys is looked up by a borrowed s with nothing allocated (map_get_with [K V Q]: the query's own hash and a key-to-query equality). uc_is_letter / uc_is_number / uc_is_space answer \p{L}, \p{N} and \s from Unicode range tables (tools/gen_unicat.py), built once at start.
Locked [T]: shared state threads change (stdlib/std/thread.nu).
A shared handle over a mutex and the value, reached only through locked_with (runs a closure on the value with the lock held), locked_get (a copy) and locked_set. The lock that guards the data is the one its type names, so it is never forgotten, never the wrong one, and the compiler counts no lock. It is % Sync: an `( Arc ( Locked T ) ) or a Locked` captured by every worker is the one value.
"Module globals"). : ~ T g 0 for a T that owns something — a String, a Vec, a struct of them, an option, a closure, a library handle — starts as the zero value, an assignment to it or to one of its fields drops what it replaces, and the program's exit releases it. Every thread can reach a global, so only an unsafe function reads or writes one that owns something, and the rest of the program calls safe accessors around it. An option global (: ~ ?Model g 0) is F until set, and = g @ ?Model { F } lets the old value go. They replace the hand-allocated blocks kept as addresses, and the handles kept as words (arc_into_word), that held module state.
( mem_addr g ) (unsafe): a *T to a mutable global's storage —a struct global's fields for a helper, or an address a foreign call or an atomic operation writes through.
inout parameters on foreign functions (spec §7.2, grammar
ffi_param): & cuda @ cuMemGetInfo_v2 inout i free inout i total → i32 passes the address of a mutable binding, so a C function that writes its result through a pointer needs no allocated cell.
box_null (stdlib/core/box.nu): a ( Box T ) that holds nothingyet, for a slot filled later; dropping it releases nothing.
( mem_replace [T] place x ) (stdlib/core/mem.nu, unsafe): x
goes into the place and what the place held comes back, owned — nothing dropped, nothing copied (Rust's std::mem::replace). The place is an inout argument: a mutable binding, a field of one, or a field through a pointer. It replaces the read-then-mem_take pair that claimed a value out of a record, and the mem_put_back that wrote one back.
RawBlock (stdlib/core/mem.nu): ( raw_block n ) is n bytes from
the C allocator, released when the block is dropped, and ( raw_block_ptr b ) a pointer into it — raw memory worked through a typed pointer with nothing freed by hand. It goes with the struct it is moved into. The pointer is malloc's own result, so the optimiser keeps the alias facts a Vec's data pointer, read back from its control block, loses.
ffi_decl):
& c @ strdup s src → sink s — the C function hands over a string it allocated, and the binding the result lands in owns it (released at scope exit, or handed on to string_adopt / a sink). The standard library's wrappers over such runtime calls (tz_name, fs_user_name, dir_list, path_canonical, the DNS and socket-address getters, sys_uname_field) free nothing by hand. An inout foreign parameter may be a struct, which the C function fills in the caller's frame (now_ms, fs_stat, fs_statfs and nanosleep allocate nothing).
( Shared T ) (stdlib/core/rcbox.nu): the standard library's
handle over a counted block. A handle holds one (: Regex { ( Shared RegexImpl ) ctl }) and its hooks release nothing themselves — Shared_drop does — so the 87 hand-written mem_forget + rcbox_release hook bodies are gone; the compiler reads the share hook's shared_clone [T] for the thread-shared cycle rules. Making, reaching into and publishing one are raw.
vec_retain, vec_put_back, vec_publish_once(stdlib/core/vec.nu): drop the elements a predicate rejects in place; write an element read out of its slot back (a table that lends its records by value checks them in with it, and a function passing its parameter on to it writes it back too, for its own callers); offer a table for the runtime's publish-once slot.
( mem_addr p ) of an inout parameter is the caller's place,
valid for the call (mem_replace is built on it); and an inout argument may be a field through an immutable pointer binding or a pointer parameter (( f . p out ), p a *S): the pointer stays, the memory it points to is written.
( pkg_version ) is the version of the package the source file
belongs to, from the [package] table of the nearest nurl.toml above the file, as a string literal (spec §6.5). The lexer hands the form on as that literal, so it is read-only data like any other; a dependency's source reads its own manifest, and a file outside any package is a compile error. Seventeen packages print their --version or report their MCP version with it instead of a hand-written number, which had to be bumped with the manifest and was not always (tools/check_package_version_strings.sh exists because it drifted).
Arc as one word: arc_into_word, arc_from_word and
arc_word_share (stdlib/std/arc.nu) give a module global — which holds a scalar — an owner of an Arc, take it back, and make another owner from it. The word has no type, so they are raw primitives, callable only from unsafe code (spec §3.3d); they replace the hand-built # i . h ctl / mem_forget pairs that kept a handle in a global.
formal/: the memory guarantee as a theorem to prove. CLAIM.md
states docs/MEMORY.md §6.2 as a theorem, README.md the phases of a mechanized proof (a core calculus; the full single-threaded language with leak freedom; threads; then the standard library's unsafe base, the compiler and code generation), RULES.md every ownership rule against its formal rule, and corpus/map.tsv each hole probe against the rule that rejects it. It is under construction: nothing in it is proven yet, and the guarantee still rests on the compiler's tests. A change that touches only formal/ does not run the compiler CI jobs.
test run kept its artifact directory under build/tests (or build/tests-san) for good — a few hundred MB each with sanitizer builds. A run now keeps the newest $NURL_TEST_KEEP_RUNS (default 3) and removes older ones as it starts; a run in progress holds a lock on its own directory and is never removed. nurl.sh's object cache had only a two-week age limit; it is also capped at $NURL_OBJ_CACHE_MB (default 2048), least recently used first, checked at most once an hour, and the same pass drops probe results unused for two weeks and the ThinLTO directory no build reads any more (~/.cache/nurl: 6.5 → 2.3 GB here).
unikernel job (no-libc corpus and unit gates, the guest on three architectures, the wasm-compiler and swarm appliances) is three jobs, and the sanitizer corpus runs in two shards beside the targeted controls: run_san_tests.sh takes NURL_SAN_SHARD=i/n (disjoint, near-equal shards; a malformed value or an empty shard fails) and run_qemu_tests.sh takes NURL_QEMU_SKIP_APPLIANCE=1. swarm_gate.sh builds the guest image and the host node concurrently. ci.yml's code filter counted every change as code (several negations under the default some quantifier); it now uses every.
Arc's payload is frozen once made (docs/MEMORY.md §6.5;
breaking). Arc makes the count atomic, not the data: every clone reads the payload without a lock. Safe code may no longer change it — on any thread, the one that made it included, and whatever lock it holds: a call that hands an arc_get result, or a field of one, to anything that changes it is an error at that call, and arc_set is for unsafe code only. Build the value first and arc_new it whole; put state threads share and change in a Locked (Added); keep state one thread changes in an Rc. A payload that synchronises itself (% Sync: Mutex, Channel, Locked) is used through its own operations as before. No package changed its payload; three tests did, and http2_client_duplex keeps its outcome flag in a ( Locked i ).
- Cell is a ( Shared RawBlock ): the bytes release themselves with the last owner. - Thread, Mutex and Cond hold a ( Shared T ) over an impl whose pthread object sits in a RawBlock. The impl's drop only detaches or destroys the object; the memory goes with its fields. thread_null builds the empty handle that @ Thread { # s 0 } spelled before. - Deque keeps a boxed control record whose buffer is a RawBlock. - Arena keeps its chunks in a Vec of RawBlocks; arena_reset truncates it. - Set and HashMap keep a boxed control record whose key, value and state buffers are RawBlocks; a rehash moves the entries out of the old buffers before they go. - Channel is a ( Shared ChImpl ): the last owner drops the queue, the waiter lists and the lock with the block. A select waiter is a Box. - A BTree node is a BtNode in a Box whose keys, values and kids are RawBlocks; a kid slot holds the kid's box and the root's is the tree record's field, so a node a merge or a root shrink gives up is taken out of its slot and dropped. The node arrays are no longer zeroed (no slot is read before it is written), which makes insert + remove + drop 1.6 % fewer instructions on a 200 000-key run and 0.2 % fewer over six build-and-drop rounds of 60 000 keys (callgrind). - SymTab (stdlib/core/symtab.nu) is an owning handle over the string table: symtab_new / symtab_def / symtab_get, released when it goes. The raw i handle stays the compiler's. - Outside the primitives that implement ownership (Vec, String, Box, RawBlock, the counted block behind Shared, Rc, Arc and now SymTab) nothing in the standard library releases memory itself; the containers above only move elements out of their buffers. - raw_block_zero gives zeroed raw memory.
TcpConn and TcpListener close themselves. Each is a handle over
a counted block: every copy is the same socket, and its last owner closes it, so a forgotten tcp_close_* no longer leaks a descriptor. tcp_close_conn / tcp_close_listener still close early, for every copy. A conn is no longer built field by field: tcp_conn_adopt raw owns a runtime socket, tcp_conn_view raw views one something else closes (psql and redis keep theirs so), tcp_conn_raw / tcp_listener_raw read the handle. A conn handed to an owner (http2_serve, a client) moves there: closing it by hand afterwards is now a use after move, and the owner closes it.
hp_perform hands back an HpResult (status, headers, body) and process_run a ProcResult; a spawned child's pid, pipes and buffers are a ProcChildImpl record. They replace C-layout blocks built with nurl_zalloc / malloc / strdup and read by slot; the C backends' blocks (Win32, WASI) are copied out on arrival. Bitset keeps its words in a Shared Vec, Semaphore is a Shared record, and heap_pop pops instead of claiming the root.
{ ( Shared T ) ctl }, where it was
{ s ctl }. Code that only calls the API is unchanged; a zero handle is the cast # File # s 0 (a pointer cast follows a handle's one-pointer wrapper, spec §6.7), no longer the literal @ File { # s 0 }.
listener's certificate and keys (a table of Arcs, so a handshake still running when the listener closes keeps its own), the STARTTLS registry, the DoS counters (dos_state_new returns a DosState handle), the terminal state (TermState.saved is a ( Vec u )), the buffered reader's buffer, the inotify watcher (a handle, closed by its last owner), the HTTP/2 stream tables (vec_retain, vec_put_back, mem_replace), the job, lease, identity, relay, transport, TCP-stack, socket and FAT tables (( Vec ( Box T ) ) and owned globals), the sqlite connection and statement state (Boxes, the error message a String, the authorizer a closure the handle keeps), the JSON parser's state (in the caller's frame) and the HTTP server's upgrade and streaming hooks (owned globals; setting one again no longer leaks the last). An OR-set's tags are ( Vec OrTag ); mcp_sampling_params, mcp_sampling_request and mcp_session_request_sampling consume their messages (sink), as documented.
mem_replace takes x as sink: the value moves into the place.
§7.4). = . p f v, p a *S, drops what f held, as every other = drops what it overwrites: an unsafe setter over a handle's state is the store alone, and the : T old . p f ( mem_take old ) pair that preceded each such store — 92 in the standard library and the packages — is gone. The memory has to hold a value of the field's type, and the compiler sees to the ways it might not: through a pointer bound to an allocation in the same function a store fills rather than replaces; an allocation cast to a pointer to a struct that owns something is zeroed; a value claimed out of a place (mem_take, or . p f handed to a consumer) leaves a zero behind; and a value written back into the slot it was read from replaces nothing. An element written through a pointer (= . p i v) is still raw — past a container's length nothing lives. Code that kept the mem_take pair stays correct.
mem_forget, mem_take, mem_put_back and the rcbox primitives
(rcbox_new, rcbox_zero, rcbox_share, rcbox_release) are unsafe to call**, as spec §3.3d always said. In safe code mem_forget leaked what it forgot, mem_take claimed a borrowed field or element as owned, rcbox_new parked a value nothing released and rcbox_release freed whatever address it was given (h151–h160). A drop hook that gives its receiver up (unsafe @ T_drop sink T h → v { … ( mem_forget h ) }) is unsafe with them, and a program's own handle whose hook is unsafe is sealed: its literals and field writes are raw memory too (docs/MEMORY.md §6.4). No package needs such a hook any more (Packages); their published versions do not compile under this release.
String, Vec, library handle or closure has no control block, shared block or code, and the first use read or called through null. Outside unsafe none is made any more: a struct literal gives such a field (h180–h182) and an enum literal such a payload (h183); an option or result literal gives the value its tag names (@ ?( Vec i ) { T }, h184); a bare variant is a value only when its payloads are plain numbers or flags (h185); vec_zeroed, vec_resize_zeroed, box_zero, rc_zero and arc_zero take only a type a zero is a value of (h186); and a number never becomes a String, a Vec or a handle — in a binding, an assignment, a field or an argument (h187, h188). Numbers, flags, options, raw strings and structs of them may still be left out.
s) parameter of safe code is a sink only when it is
declared one**, never inferred. A function that hands its borrowed s on to a consumer (string_adopt, another sink s) hands over a copy, and its caller keeps, and later frees, its own string. A declared sink s is the callee's own string, released on every way out unless it moves on. A caller hands a sink s its own string (which leaves the binding) or a copy of anything else — a literal, a view of a String, a borrowed parameter, a field, a result a callee lent, or a result owned on only some paths, decided per call at run time (docs/MEMORY.md §1). unsafe code and the standard library manage a raw sink s by hand, as before.
the trusted standard library — every safe-code check off — when its path merely contained /stdlib/. Now only a file inside the tree stdlib/ imports resolve to is (the stdlib in the working directory, or $NURL_STDLIB/stdlib, each recognised by its core/vec.nu; h178).
?? whose arms each hand over a value of their own no longer
borrows what it matched on.** `: ( Vec u ) h ?? ( vec_get [Fd] fds 0 ) { T d → ( vec_clone [u] . d host ) F → ( vec_new [u] ) }` was taken for a borrow of fds, and changing fds afterwards was rejected. The scrutinee's lend went to the binding wholesale. Now the binding's source expression decides, arm by arm, and a payload view (`T d → ( slice_from_vec [u] . d host )`) still borrows (bck_join_owned_arms, should_fail_join_payload_view).
inout argument makes that argument hold it.
@ add inout H h ( Slice f ) s → v { ( vec_push [( Slice f )] . h fs s ) } called as ( add h ( slice_from_vec [f] v ) ) left h free of v: clearing v and then reading h's slices compiled, and read freed memory. The same vec_push straight into . h fs was caught. An inout argument is passed by address, so it was classed as a view rather than as the owner a keeping callee stores into. It is classed now as the value it names (h, or w for . w h), so the caller's value depends on what the stored view points into — wherever the inout owner sits among the arguments (( add_last ( slice_from_vec [f] v ) h ) too: a keeping callee stored only into its first argument before). The same class feeds the argument-alias check: a call handing p inout together with a view into p (( f p ( string_data . p uri ) )) is now refused, as it already was for a by-value owner. Two package call sites did this (oauth, torchpt) and are fixed (inout_stores_view, should_fail_inout_stores_view, should_fail_inout_field_stores_view, should_fail_inout_later_stores_view).
pointer is* (h268). A `f handed to a ( Slice f )` parameter — or, in safe code, an s string to a ( Slice u ) one — compiled to an ill-typed call: the callee read the address as the slice's data and whatever followed in the registers as its length. Every other pairing was checked (a *T for its own T, a String for an s, two named types); this one was not. anomaly's imp_span_of_text passed its text where its digit helpers take a byte view, and indexed past a short string; nurllama widened a Gemma checkpoint's norm weights through safetensor's _st_u32 with the buffer's raw address where it takes a view.
unsafe function nothing calls no longer marks the next foreign
function raw.** The flag behind unsafe & … was cleared only when a function was compiled, and lazy compilation skips an unreached one, so its prefix leaked onto the next foreign declaration: a program that called stdlib's unlink and not the unsafe @ __fd_write_all before it was told unlink takes a runtime handle (ply's viewer).
Locked leaves its slot empty (h267).
mem_take read where the value came from as <addr> <type> <depth> split on spaces, and an option's or a result's type ({ i1, %K }) has spaces of its own, so locked_take [?K] left its copy in the slot and locked_restore, which drops what the slot holds, dropped the value it was handed back — the next take read freed memory (whisper's server: the tokenizer, on the second request).
rest of that result with it** (h266). ^ . ( mk ) backend, `: String b . ( mk ) backend, ( len . ( mk ) name ) and @ W { . ( mk ) device }` left the temporary's other fields undropped, and the read itself was taken for a view, so nothing dropped it either (embed's health report leaked through ^ . ( locked_get … ) backend). The rest — the value with that field emptied — is dropped where it is read, and the field is its reader's exactly when the call handed its result over (a lent one, . ( arc_get a ) file, stays a view).
source, and drops when handed to a call as a temporary** (h264, h265). A struct holding a library handle's option (`St { String path ?Database db }) taken into a returned literal (^ @ Who { 0 . w st rs }) was neither zeroed in w nor copied — freed with w` while the result held it (agora's GET /m/me: heap-use-after-free). Handed to a call that returns another such struct (( conn ( mk ) )), the temporary was never dropped (agora leaked a path per REST call). It moves whole now, and the callee's summary decides the temporary's drop as for a String or a Vec.
(h214). : e ( vec_at ( vec_at top 0 ) 0 ), `( vec_get ( vec_at top 0 ) 0 ) bound or matched, and the same over arc_get` or a helper that hands back its argument: the binding borrowed only what the outer call was handed directly — a temporary — so it borrowed nothing, and releasing top left it dangling (ASan heap-use-after-free on the probe). A let / assignment of such a call, and a match of one, carry the call's source expression, and the binding borrows every source a lent argument reaches. Block and ?? expressions now have source expressions of their own (a block's tail; a match's arms, an arm handing back its payload standing for the matched value), and so does a field of a call's result.
^ returns it (h218). A body
that falls off with a name, a call, a field read or a literal — `@ idv ( Vec i ) v → ( Vec i ) { v }, { ( vec_at xs 0 ) }, { . b items }`, { : e ( vec_at xs 0 ) e } — was summarised as handing over a value of its own where the same body with ^ lent, so every caller released its argument's contents (ASan heap-use-after-free on main for each); a literal holding a parameter's field copied it where ^ lent it. The fall-off path had grown its own, narrower copy of gen_ret's rules; now such a final statement is compiled by gen_ret itself (gen_block_ret → g_ret_implicit). Whether a statement is the last is read off the source text — balanced parentheses, backtick strings and // comments skipped as the lexer skips them — so nothing is lexed twice. One fewer rule set; and the fall-off tail now gets ^'s tail calls too.
(h219). A % Drop value with no clone — its parameter, its capture, a field of either, or what a lending call lent (( vec_at rs 0 )) — was handed to a caller that takes a closure's result as its own: released by its owner and by the caller (ASan heap-use-after-free on main). The value is rejected where it is handed back; a call, at module end on the callee's final summary (resolve_clo_nc_rets).
closure value had the source expression * (not modelled), and a raw string the body lent was taken as owned: ( string_data x ) of a parameter or capture, or a Slice of the Vec it was handed, dangled once the source grew (ASan heap-use-after-free on main). A raw string the closure lent now comes back a copy at the call (mem_clo_ret_own_str); any other view holds every argument (\(args)), so the walk ends it as it ends a function's; and a view of what the closure captured — which its caller cannot see — is rejected in the body, followed through the body's own bindings (__cvt).
drops** (h221). : Hold h @ Hold { … } ^ . h r, with r a % Drop value with no clone, returned the field while h's drop released it on the way out (ASan heap-use-after-free on main). Such a field of a local, a module global or a closure's capture is rejected; a parameter's field stays a lend, and one read through a pointer is raw code's to vouch for.
are dropped** (h237, h238). ! i Res never dropped an error it held, and ??String / ??Res — bound, returned, discarded or popped out of a ( Vec ?T ) — never dropped the inner option's payload (LSan, or valgrind for ??String, on main). Such an error is owned through the same twin as its Ok payload; the inner option is the outer one's payload, dropped through its own twin, and a match arm's inner option is a cursor over the outer binding.
(h240–h244). The env of a closure handed to a thread or a fiber, to a callee, or bound to a name used other than as a callee (^ f, ( go f )) now owns a capture it cannot copy, as a returned one does (h239), from the moment it is made; a later use of the binding in the frame is a use of a moved value. A copy of the env takes the capture over — the env it was copied from gives it up, through the env's own address, with no runtime change — so the copy a thread runs releases it, and one bound for a thread but never handed over releases it with its env (it leaked, h240). On main a frame using the value after a thread's closure took it (h241), a closure argument whose callee ran it (h242 — its body reset the key telling a literal argument apart, so the move was never recorded), a bound closure handed back or kept (h243) and a closure run after its thread's copy (h244) all released one value twice; h243 reached a frame's drop flag after the frame returned on this branch (h236). The original run after its copy took the value stops with a panic. A thread's closure may now read such a capture.
returned right where it is made (^ \ → v { ( eat r ) }) took such a capture over when made and released it only if run: never run, it leaked (LSan); one that only read it could not be handed back (h224). Its env now owns the capture under a flag of its own: the body clears it where it moves the value out, the env's drop releases what was not taken. Run again after the move, or copied, it stops with a panic (memory-safe: the caller cannot see the move, and two envs would own one value).
closure whose body moves out a capture it cannot copy took it over when it was made, so one never run — or run without reaching the move — leaked it (LSan). A closure that stays in its frame now carries the binding's drop flag (as a by-pointer capture does): the body clears it where it moves the value out, and the frame drops what the closure did not take. A closure returned or handed to a thread keeps the old rule.
look ( mk ) ) or : Res r ( id ( mk ) ), mk handing over a % Drop` value with no clone, left the temporary with no owner (LSan on main). It is dropped after the call unless the callee keeps it, and moves into the result when the callee hands it back — the lend check of h230 takes such a result for the caller's own.
[Res] b ) copied the payload's bits through mem_dup`: two boxes, one buffer (ASan heap-use-after-free on main). mem_dup of a value that cannot be copied is rejected at the instantiation.
( consume t ) } … }, consume taking a sink String`, released the payload the enum still held; a % Drop payload was made the arm's own and released twice even when only read (ASan heap-use-after-free on main). The payload of an enum a binding or parameter holds is lent; a lent binding handed to a sink goes as a copy, or is rejected when it cannot be copied.
( mk )
thrown away, or . ( mkh ) n read for one field, never dropped a `% Drop` value or a struct holding one (LSan on main). Such a temporary is dropped where it is discarded, and a call's struct read for a field that owns nothing is dropped there — exactly when the call handed it over.
… }` left the enum with no owner: its cell and payload leaked (LSan on main). It gets an owner of its own for the rest of the scope, and its payload is lent to the arms.
sink is not handed what a call only lends (h228). `( consume (
vec_at v 0 ) ) with consume taking a sink String` released the element the Vec still held (ASan heap-use-after-free on main): a declared sink counted as an explicit release. A lent call result that can be copied now goes to a sink as a copy.
(h229). ( eat . h r ), r a % Drop value with no clone, handed the field to a sink while h kept it (ASan heap-use-after-free on main); a String field is emptied where it lives, this one cannot be. Rejected.
vec_at v 0 ) of a ( Vec Res )` pushed into another Vec, put in a struct literal or handed to a sink had two owners (ASan heap-use-after-free on main). Whether the call lends is decided at module end on its final summary — a generic instance is compiled late — and such a store is rejected (resolve_nc_lent_stores).
?Res or ! Res E (Res a % Drop value with no clone) bound to a name, returned, discarded, replaced or handed to a sink was never dropped: its payload leaked (LSan on main). It is now registered through the same twin as an option of a String (__opt_payload), and a match arm's payload is a cursor over the binding, so a payload moved out — into a sink, out of the match — takes the option along.
?? o { T r → … } on a ?Res parameter the function only borrows made r the arm's own and released it: the caller's next use read freed memory (ASan heap-use-after-free on main). The payload of an option parameter is the caller's, as a String payload's is.
on an option a struct field keeps handed its payload to the binding the match initialised (: Res q ?? . h o { T r → { r } … }): the binding and the struct both released it (ASan heap-use-after-free on main). A field scrutinee is always lent now — a local's too — and a lent payload that cannot be copied is not handed to an owner.
body moving a value it cannot copy (a % Drop value with no clone) out of its capture — into a literal, a container or a callee that keeps it — left the enclosing binding owning it too: released by the value the closure built and again at the end of the binding's scope (ASan heap-use-after-free on main). Only a sink call or a release counted as taking the capture, and only a String or a Vec moved into the env. Such a move now takes the capture over (__clo_take_capture): the enclosing binding no longer drops it, and a second use of the closure is a use after the move. (A closure that does this and is never run leaks the value, as one that releases a String does.)
capture by value of a % Drop value with no clone copies its bits into the env while the binding keeps owning what they point at; a closure handed back — ^ f, or inside a struct literal — outlived the binding and its caller read freed memory (ASan heap-use-after-free on main). The closure literal's source expression now holds such a capture (\(r)), so the borrow walk ends it with the binding: handing it out of the frame is rejected, using it in the frame is not.
sink parameter is the function's own to the view walk (h225).
A view of a sink parameter handed back — a Slice of the Vec it was given, a closure over a % Drop value it was given — passed as a view of storage the caller holds, but the caller gave that value up and the function dropped it on the way out (ASan heap-use-after-free on main). Only a raw-string sink counted. Every sink parameter the function drops is now a binding of its frame to the walk.
a closure value takes the result as its own, and a body written with ^ copied what it only lent — but one that fell off its end with it did not: \ → ( Vec i ) { ( vec_at outer 0 ) }, a capture handed back by name, a field of a capture or of a struct the body made, a binding that owns nothing (an alias, a join), each released outer's element or the closure's own capture in the caller (ASan heap-use-after-free on main). The final value is now copied unless the body owns it: a call by its callee's answer for this invocation, a binding by its drop flag (it then leaves with the return; a capture by pointer stays the enclosing frame's), a field read always — except out of a call that handed the whole over, where it leaves with it. The body's final value is now compiled by gen_ret as functions' is (g_clo_body_ret), and gen_ret itself copies the closure results its own rules left lent: a capture by pointer, a field of a capture, a field of a call that lent it — ^ had the same gaps.
borrows what that binding held** (h216). `: v { : t ( vec_at outer 0 ) t } — or the same in a ? / ??` arm, through an assignment in a nested block, or through a chain of the block's names — borrowed nothing: the walk resolves names where the value is used, and the block's names had ended with it, so releasing outer left v dangling (ASan heap-use-after-free on the probe; it also hid a view of an Arc's payload from the frozen-payload rule). A value-position block notes what each binding it declares holds — its let, an assignment in it, one in a block nested in it — and its value's source expression names those instead (g_blk_lets, __sx_close).
so** (h215). `@ first_of ( Vec ( Vec i ) ) xs → ( Vec i ) { ^ ( vec_at xs 0 ) } — or : e ( vec_at xs 0 ) ^ e` — was summarised as lending nothing: vec_at is a generic instantiated at the end of the module, so its summary did not exist when first_of's was written. A caller's temporary was dropped under the result (( first_of ( mk ) ): ASan heap-use-after-free at run time), and a caller could release xs while the result was still read. Each parameter handed to such a callee is noted with its position, and once every summary is final a fixed point decides which parameters the helper lends back (resolve_retfwd_lends): the caller copies out of a temporary, and the walk keeps the source alive.
Arc can no longer change its payload while
a worker reads it** (h213). The race check asked only what a detached closure did to the payload, so the spawning thread's own vec_set / vec_push through arc_get passed as "one thread changing its own data" — while the worker it had started read the same buffer: ASan's heap-use-after-free in every run, the worker reading a String the maker had just replaced. A held lock did not help either: the check took any mutex_lock as proof, whichever lock it was. The payload is now frozen once made (Changed), which needs no lock bookkeeping at all — the compiler's lock counter, its spawn records and its per-closure summaries are gone — and shared state that changes lives in a Locked (Added). The rule is decided on the borrow walk, not on the spelling: what a call lends through an Arc parameter is a view of the payload, followed into bindings, names it is copied to, borrows of it, fields, elements an accessor lends, ? / ?? arms and match payloads — each of which a check that tracked arc_get bindings by name had let through. arc_set was unsafe-bodied but callable from safe code (an unsafe function's callers need no marking, spec §3.3d); the call is now refused outside unsafe, like the raw-memory primitives.
Arc's payload is rejected however the
payload reaches the mutation** (h212). The check knew one shape: a binding arc_get bound, then a container mutator on it. Two threads mutated one payload unlocked — lost updates, `realloc(): invalid old size`, ASan's double free — when the view was written in place (( vec_push ( arc_get a ) x )), was a field of a view, was handed to a helper that mutates its parameter, or was a String or a map handed to its own mutators, whose summaries (writes through raw pointers) close only after every body is compiled. Each argument's provenance is now followed — a view bound or written in place, a field path rooted at one, an Arc handle — and a call handing one to a mutated parameter is parked; after the module a fixed point over the final summaries decides which functions mutate the payload of which Arc parameter, and a call that does, at any depth, is rejected (h213 then moved the error from the detach to that call).
(h211). A share that read an owner count of 1 skipped the locked add and stored 2, on the premise that only the one owner could be sharing. A clone is asked of a borrow, though: threads that each hold a clone of an Arc clone the handle in its payload — another Arc, a Mutex, a Channel — through the same borrow at the same time. Each read 1 and stored 2, the shares were lost, and the releases freed the value while the payload still held it (free(): invalid pointer, or ASan's double-free, in every run of eight threads). nurl_rc_share and nurl_arc_share always take the locked add now; a release, which only an owner makes, keeps the shortcut. Found by arc_threads failing on Windows.
btree_remove no longer loses a value at a merge (h210). Removinga key that sat in an inner node whose two neighbouring kids were both minimal took the value out of its slot — emptying it, since a take from a place zeroes the place — and then merged the kids around the emptied slot; the removal below returned the empty value and the real one leaked. The value now leaves its slot only when a predecessor or successor fills it; at a merge the entry moves down whole.
% Drop value inside a container is dropped
(h209). : Imp { ( Vec R ) xs } with R a type with its own % Drop (or the R in a Box, an option, an enum payload, a Vec of Vecs) was neither a handle, since it cannot be copied, nor given the field-by-field drop, which went only to a struct whose own fields had the % Drop. The struct was never dropped: the Vec and every R in it leaked, and R's drop never ran. Found by rebuilding Arena on a Vec of self-releasing chunks. The structural fuzzer (tools/fuzz/genprog.py) now builds these shapes and counts their drops; on the previous compiler it reports them.
state and raw-pointer records manage themselves: - A closure stored into a struct field or a slice (h195) kept only a view of a String its maker's frame owned and read it after the frame ended. The stored environment owns its captures: a closure environment's descriptor gains a second, owning one (`{ size, drop, clone, trace, own, adopt }), and nurl_closure_adopt` / nurl_closure_own_copy copy in what the environment only viewed (docs/MEMORY.md §7.5). - A binding read out of a field (: String b . s name, h196, h197) read freed memory once the field was assigned, here or through an inout parameter: the replaced value is handed to that binding, which drops it at its scope's end, instead of being dropped under it. - An option or a result replaced in place — a field (h198), a whole inout ?T parameter or an owned option global (h199) — leaked what it held: the store drops it, as it does a String or a Vec. - A local option moved into an `inout` place (h200) was still dropped by the local, and a borrowed parameter assigned over one (h201) was stored as is: two owners of one value either way. The local moves over; the borrowed parameter is copied in. - *A raw-string global assigned a call's result whose ownership is decided per call* (= g ( f n ), or = g ? c ( f n ) - , h202) recorded the fresh string as not its own, and each reassignment leaked the one before. The global takes over the owner the call's guard slot proves, and a value lent back from the global's own buffer (= g ( keep_or_extend g n )) is kept rather than freed. - *A mutable string binding declared from a ? join of views or literals* (: ~ s r ? c a b, h203) and advanced like a cursor (= r ( rest_of r )) had no owner slot: no fresh string assigned to it was released. It gets the empty owner slot a cursor declared from an identifier or a call already had.
inout parameter is never a tail
call.** ^ ( f c ) with c a local passed inout was marked tail, and LLVM's marker promises the callee reads no alloca of the caller: the optimiser dropped the stores that initialised c, and the callee read stack garbage — a view's pointer among it (compiler/tests/inout_local_tail_call.nu printed 0 and 0 for 50 and 82).
thread-shared cycle rules.** A job node's handlers lived in raw blocks, so neither rule saw them. Now that they are owned records the store rule checks them; the frozen-Arc rule, which treats a closure as leading anywhere, does not follow a closure inside a library handle's state — the store rule has already refused any that could lead back (docs/MEMORY.md §7.7) — so an Arc holding a job node may still be updated in place (compiler/tests/arc_payload_vetted_closures.nu).
it back too.** vec_put_back writes its value into a slot the caller had taken it out of, so the caller's binding must not drop it; a helper passing its own parameter there (__h2c_set_stream) did not carry that mark, its caller dropped the value the table now held, and the next read of the stream was a use after free under the sanitizer (http2_client_streaming). Every call now propagates the mark to the calling function's own parameter (mem_putback_pass).
making the TCP handles own their sockets, and h206, found by probing h205's rule: - A closure a fiber or a thread runs, capturing a `??` arm's payload (or a parameter its caller frees), only viewed it — the arm binding has no drop slot to hand over — and the fiber read what the arm's end had released. Every spawn now runs on a copy of the environment that owns what the environment only viewed (nurl_closure_own_copy: a String copied, a handle shared): nurl_fiber_spawn*, nurl_pthread_create_owned, the signal-handler slot, and the freestanding runtime's spawns (unikernel/runtime_bare.c). - *An aggregate literal moving a binding into one field while another field hands it to a callee that consumes or keeps it* (`^ @ Two { t ( wrap t ) }`) made two owners of one value. A literal's fields are checked against each other as a call's arguments are (bck_check_lit_moves): a name moved in bare is not moved in twice, handed to a consuming or keeping callee, or kept lent beside its move, and no field reads what another released. A callee whose summary is not final yet is decided at module end (resolve_lit_handoffs). - A literal nested in another field (^ @ Two { t @ Holder { t } }) moved the same binding a second time unseen: the nested literal now hands the names it moved in bare up to the outer one, which checks them as moves of that field. - A parameter handed to a callee that keeps it (h207: ( mid h ), mid binding ( mk h ) and dropping it; h208: : K k ( mk h ), then ( kfree k )) was read afterwards. The call cleared the parameter's drop flag, but the borrow walk asked only whether the callee sinks the argument; a local in the same place was already "moved into an owner". A parameter now moves into a callee that keeps it, unless the callee puts it in its container argument (an escape: vec_push). It found __serve_conn closing a connection its keep-alive loop had already closed, and h2_conn_new_until and the HTTP/2 client reading a socket they had handed on; those now pass a share (TcpConn_share). - A parameter a wrapper only hands on to a parameter its callee only invokes (ws_serve_messages_deflate → __ws_serve_messages_deflate) is invoke-only in the wrapper too. A closure capturing a TcpConn handed to such a wrapper beside that connection was taken for a closure stored into it (the thread-shared cycle rule). - The inverse-oracle fuzzer (tools/fuzz/genreject.py) gains both literal cores, and its two view-invalidation cores expect the diagnostic the checker gives since the ownership rules became the default.
udp_bind "" binds the IPv4 wildcard when the kernel has no IPv6.
The resolver offers :: even there; socket(AF_INET6) then fails with EAFNOSUPPORT and the bind failed. async_udp and udp_basic pass in such a container.
check_diag_anchor.sh measures a line in bytes, as the compiler's
columns do (nurl_lex_col). A caret after a → was reported past the end of its line.
generic instance's %T = type line was printed where its first function was instantiated, so it could follow a function that already passed a %T by value, or another type whose closure field took one (`{ %Json (i8, %Json), i8* }`). LLVM 18 resolves such a forward reference; LLVM 23 (the macOS job's Homebrew clang) reads the parameter as an opaque struct and rejected eleven programs with "invalid type for function argument". The module writer now moves every type definition to the top, each after the types its body names (__types_first), and tools/check_ir_type_order.py holds that on every module the corpus emits, under any clang.
its root: - A raw string handed to a consumer (h161–h170): a literal, a view, a borrowed or closure parameter, or a result fresh on only some paths — directly, through a method, a %Trait object or a recover — is copied before string_adopt or a sink s takes it over (the rule above); a sink s the callee only reads, or leaves in a parameter's container, is released; a generic's sink A instantiated at s no longer leaks. - A view leaving through a call's result (h174–h177): ^ ( pick x ), or a closure or Vec a helper builds around a string it was lent. A returned call result that may lend a released string is the caller's own (the callee's string, or a copy), and a call's result holds views of what the callee lends, stores or hands back inside it. - A view stored into what the caller holds (h171–h173, h179): a Vec parameter, an alias of one, a field of an inout struct or a field of a struct that outlives the binding viewed may not keep a view of a binding released here; a field store records the view. - Destructors (h146–h150). A value leaves the panic journal before its destructor runs, and its receiver's drop glue is journaled while it runs, so a destructor that panics no longer runs again on the unwind and still releases the fields. A field the destructor replaces releases the value it held. A disposer drops its parameter by its drop glue (the fields, never the impl again), whether the value came from the impl or straight from its owner. A closure defined inside an impl has no destructor context. - A closure value called with a fresh string releases it after the call (h145). - A zero value of a type that has none (h180–h188): the rule above. - A `;` defer that used a field the return value had moved out (^ @ ?V { T . x f }, h189): under a defer the returned literal takes a copy of the field and the struct keeps its own. - A drop hook with nothing of its own to do (`@ H_drop sink H h → v {}`, h190) dropped its receiver through the drop glue that had called it: unbounded recursion (a stack overflow at -O0, a hang at -O2). The hook is the receiver's destructor, so it is not dropped again. - A field taken out through a struct cursor (h191, h192): a struct copied from another binding (: E b a, or : E m m0 over a ?? payload) is a cursor, and the binding it copied still drops its own slot. A returned literal, a binding read from the cursor, or a sink argument that took a handle field of it left that slot full, and the owner freed what the taker held (audio's MP3 encoder returned an empty stream through it). The take now empties the field in each owner the cursor is over, a parameter owner is consumed with it, and a literal copies a field nobody gave up while the struct still holds that very handle (a cursor over a parameter or a borrowed vec_get element). - A closure field of an owned struct, returned in a literal (h193), skipped the struct's drop and leaked its other fields: the closure's type has blanks, and the field-read record split on them. The closure moves out like a handle field. - A `?` join arm that copied a call's result (h194): when the last identifier loaded inside a call arm was an owned string — only the call's argument — the arm was copied, and the result the call had handed over through its per-call guard leaked once per evaluation. It is freed right after the copy.
# X w builds a one-word wrapper around a one-word handle (`: X {
( Arc XImpl ) a } over Arc's { s ctl }`): the word goes in at the innermost slot. It produced an all-zero X — the cast's fallback for a first field that is neither a pointer nor an integer — silently, in the unsafe code that reads a handle kept in a global as a view.
T x → / F e → arms on a ?? whose scrutinee is neither an option
nor a result** (a String, a struct) are rejected at the arm, naming the scrutinee's type. The binding got an empty type, nurlc exited 0, and clang reported "Cannot allocate unsized type" about generated IR.
and at no run-time cost — the generated code of all 1 642 programs in the test corpus, bench, examples and packages is byte-identical to 0.72.0's — and at none at compile time either: the compiler compiles itself in 0.19 % fewer instructions (callgrind; json_parse 0.14 %), because the borrow checker's statement lists (reads, pmoves and eight more) now grow in place instead of being copied whole for every identifier read or move recorded, which pays for the new check: - One owner consumed twice, or consumed and read, in a single call (( f m m ) with a sink and a borrowed parameter; ( f ( string_data s ) ( eat s ) )) is a compile error naming both arguments. A call's moves were flushed into the borrow walk only after the statement, so its own later arguments never saw them. Each argument now records its span of the statement's reads and moves, and a call any of whose arguments moved something checks the others: no other argument may hand the callee the name or a view of it (only a plain scalar computed from it), and none may read it after an earlier argument released it as it ran (( g ( string_len s ) ( eat s ) ) and ( take s + ( string_len s ) 1 ) stay legal). Where each argument starts is packed into integers (an O(1) read of the lists' cached lengths), so a call allocates nothing for the check, and only a call that moved something runs it. Probes h143, h144. - *A keyword-argument call to a function defined later that hands its argument on to a sink* no longer double-frees: the kwargs path now runs the positional path's whole-module consumption rule (one shared helper, mem_arg_consumption). Probe h142.
bytes_push_u32_le/_be and bytes_push_u64_le/_be write the word
with one capacity check and one length bump** (vec_push_word_u8, new in stdlib/core/vec.nu) instead of four or eight vec_push calls, whose inlining into the serializer came and went with the optimizer's module split. anomaly's dynamic suite, which writes its forest blobs through them, retires 6 % fewer instructions.
of the whole module.** __types_first looked for \n% with memmem across every module it emitted, megabytes at a time. It now finds each line ending at memchr speed and checks the byte after it. Together with removing the per-argument string work of the old Arc check, the self-compile retires 1.5 % fewer instructions than main on the same input (11.508e9 vs 11.684e9). The new checks — the borrow walk's frozen-payload rows, block and ?? source expressions, the forward lend fixed point (h212–h216), the final value read as a ^ (h217, h218), what a closure may hand back (h219–h221) and what captures, options, enums, sinks, temporaries and generics own (h222–h235, about 1.5 % of the self-compile on their own) — are included in that figure. The bench kernels compile to the same code as on main.
sponge (and every four-way one, and SLH-DSA's context) built its own Vec of the 24 ι constants: two allocations and 24 stores per hash. They are published once for the program (keccak_rc_shared, once-slot 10) and lent. SLH-DSA, which hashes with a fresh sponge per F call, gains most: the pq bench retires 0.49 % fewer instructions than before this work with a fixed entropy stream (3 147.8 M → 3 132.4 M).
multiply (__m6_mul) read and wrote its 8-word accumulator through the scratch's acc Vec on every round, because nothing told the optimiser that dst, a and b do not overlap it. Its rounds are now written out over locals, as _p256_mul_d's already were, and so are the add and the subtract. crypto_hotpath retires 13.7 % fewer instructions (1 116 M → 964 M at 50 iterations), and ecdsa_p384_verify is the share of that. The scratch loses its acc field.
nurl_free inlines its NULL test. Most releases are of NULL — a
slot whose value moved on — and each was a call into the runtime's one shared test, whose branch no predictor can learn: ~8 % of a self-compile's time. A module now carries nurl_free as a linkonce_odr alwaysinline NULL test in front of the runtime's release (nurl_free_nonnull), as it carries nurl_peek, so every program gets it, with or without link-time optimisation. The compiler build.sh installs had inlined no runtime function at all: a full -flto link optimises the thin-bitcode runtime.o apart from the program. For the same reason NURL_LTO=full is slower than nurl.sh's ThinLTO default, not faster as its documentation said (json_parse: 6.9 % more instructions); the comments in build.sh and nurl.sh now say so.
nurl_alloc's freelist
hit and nurl_free's freelist push — the thread counted, the cache on, glibc's chunk header vouched for — no longer go through the general paths, which saved registers for calls they rarely make, and a release inside a recover extent takes the same push after its journal walk. The self-compile's allocator instructions fall by a quarter (2 530 M → 1 905 M), json_parse's by enough for 3.8 % of the program.
trip.
Arc skips the locked read-modify-write for a sole owner, as the
rcbox counts did: cloning and dropping an Arc that has one owner and no Weak is a plain load and store (nurl_arc_share, nurl_arc_release; the weak count is read first, with acquire, so an upgrade a Weak made before letting go is seen). The packages' handles lose nothing by moving from rcbox to Arc, and every other Arc gains.
^ . x f moves the field out of a struct the function owns instead
of copying it: x is dropped on the way out, so the field is emptied in place and the drop skips it (not for a struct with a % Drop of its own, which may read it, nor under a ; defer). The MP3 and JPEG encoders hand their output out this way, with no mem_take.
Together, against 0.72.0 compiling the same source: 12 983 M → 12 460 M instructions (−4.0 %), 2.76 s → 2.57 s CPU (−7 %, median of nine runs).
copied the value it found and the caller released the copy — the larger part of each of the millions of lookups a compile makes. Every key and plain value is now stored once, in an arena that lasts the compile (indexed by open addressing, one word a slot, the hash kept beside the bytes), and a lookup hands that copy out as a view that never goes away; a value a table grows in place keeps a buffer of its own and is interned when read. Peak memory is unchanged for an ordinary program (the registry service: 79 MB) and 10 % higher for the self-compile (89 → 98 MB).
(nurl_recover_lean). The compiler recovers from an error in one declaration to report the next; under nurl_recover every owned binding and temporary of the compile was registered with the panic journal and every release walked it, for a run that almost never panics. The lean extent skips both: a failing compile reports and exits. Under AddressSanitizer it reclaims exactly as nurl_recover does.
function for every journaled slot; one pass over the function's lines now records every label, store and use it needs.
These three, against the compiler before them, each compiling its own source: 12 889 M → 10 730 M instructions (−16.8 %, callgrind; first-level cache misses −14 %), 3.88 s → 3.50 s (−10 %, median of fifteen runs pinned to one core); compiling the registry service (packages/registry, the same source for both): 10 693 M → 9 165 M (−14.3 %), 3.57 s → 3.33 s (−7 %). Both compilers generate the same code for the bench kernels, apart from the two new slots of a closure environment's descriptor (http_server's request parser drops the fields it replaces, where it claimed each first). With the rest of this release's compiler and standard library on top, against the compiler before this work (the first half of this release), each compiling its own source: 12 860 M → 10 783 M instructions (−16.2 %), fastest of nine runs pinned to one core 3.77 s → 3.42 s (−9 %); json_parse retires the same instructions (77.92 M) with its parser state in the caller's frame.
anomaly 0.35.1, arima 0.5.4, data 0.2.4, embed 0.5.4, f5tts 0.3.4, gpu 0.16.0, gpukit 0.10.1, grad 0.12.0, gRPC 0.2.4, http-client 0.3.4, image 0.7.6, lingbot-map 0.11.1, lsmdb 0.2.4, map-anything 0.6.1, mermaid-server 0.3.4, mlp 0.3.11, nn 0.2.2, nurlbox 0.3.4, nurllama 0.19.0, nwasm 2.4.2, onnx 0.11.1, pki-server 0.5.1, ply 0.3.4, redis 0.3.4, swarm-mcp 0.30.4, template 0.2.4, tensor 0.6.4, whisper 3.0.1 and yoloe 0.7.4 — the 322 calls that allocated, released or claimed memory (232 of them nurl_alloc, nurl_zalloc and nurl_free) are gone. Module state is an owned global (where it was a hand-allocated block or a handle kept as a word), a record whose address must stay put is a ( Box T ) (nwasm's frames, predecoded functions, JIT and thread records too), a buffer a C function fills is a ( Vec u ), a C result written through a pointer is an inout parameter, the mem_take before each field store through a pointer is gone, and a value taken out of a record is mem_replaced (gRPC's call table, template's rendered output). The CUDA harness swarm-mcp generates keeps its scratch cells in a Vec the generated program owns. gpu 0.16.0 (the CUDA wrappers return Vecs and Strings, cpu_malloc / cpu_free are gone) and grad 0.12.0 (grad_of lends its result from the tape) change their API, and their dependents' requirements are raised. Each changelog says what changed.
rcbox handle is an Arc handle (Cli and
HttpApp, whose state holds closures that may capture the handle, an Rc, whose cycles are collected): 79 handle types in 40 packages. Their share and drop hooks are safe code, and so is every constructor that builds a handle from a whole value. The unsafe left is the raw-pointer accessor of each (arc_ptr), constructors that fill a zeroed state in place, and a few one-line helpers that keep a handle in a module global (arc_into_word).
gguf_none, st_none, pt_none, gg_none, lw_none, rt_none, their *_is_open, gpu's and gpukit's null resource fields, a failed CA load — are gone: the slot is a ?T, and F costs nothing. These are API changes, released as minor versions (gguf 0.5.0, safetensor 0.5.0, torchpt 0.3.0, onnx 0.11.0, gpu 0.15.0, gpukit 0.10.0, pki-server 0.5.0, anomaly 0.35.0, lingbot-map 0.11.0, map-anything 0.6.0, whisper 3.0.0) with their dependents' requirements raised; the rest are patch releases. Each changelog says what changed and why.
(onnx, tensor, grad's GPU files, embed, f5tts, yoloe, yoloe-demo, lingbot-map, map-anything), anomaly, whisper and nurllama did not compile under 0.72.0, whose sealed handles made their null literals raw memory. All 464 package sources compile now (348 before).
# File # s 0) in the
packages that keep one until it is made: data, http 0.7.4, hub 0.2.2, lsmdb, nurlbox, nurllama, ply, pqc 0.2.6, psql 0.4.4, redis and video 0.2.4 (the others' releases above carry it). nwasm's host imports that return a runtime-allocated string declare it → sink s.
buffer worked through a pointer is a RawBlock (13 bench kernels and 9 examples; every kernel prints the same checksum), an expression tree's nodes are Boxes (calculator, chaotic-showcase, showcase, enigma; in an arena where trees share subtrees), the stress test's stack holds its block (chaotic-aggressor), test_05_closures_and_capture's buffer is a Vec, and the C64 and Game Boy emulators keep their memory in Vecs an owned global holds.
authentication reset in agora leaked the OpenID provider it replaced, and anomaly's per-version fine-tune report left its note a zero String, which a client reading it read through null. The MP3 and JPEG encoders, which left Vec fields of their state out of a literal and filled them in before any read, build the state whole now.
operations — the GEMM family, fused attention, the convolutions and the rest — are checked kernels: what a kernel indexes is checked against its buffers, through windows checked once so the checks leave the loops, and devops.nu / kernels.nu have no unsafe left. Each is bit-identical to the raw kernel it replaces on CUDA and the CPU backend (133 oracle cases on CUDA, 123 on the CPU backend) and as fast or faster: GEMM 783×1024×1024 78 → 66 µs, attention 8×2048 (head 128) 930 → 760 µs, yoloe-v8s-seg on the CPU backend 2.80 → 1.49 s. A launch can be recorded once and replayed (gk_record_dev / gk_replay). nurllama 0.22.0, whisper 3.3.0, grad 0.13.3 and f5tts 0.5.0 run on them with no unsafe left and the same outputs: on an RTX 4090 Qwen3-14B decodes in 50.7 ms a token (56.7) and whisper large-v3 in 3.89 ms (7.97); on the CPU backend Qwen3-0.6B decodes in 84.5 ms (109.4). gpu 0.17.6's driver functions over integer handles are raw, its CPU backend no longer lets two processes cache each other's kernel, and WebGPU builds run again: the WGSL set follows the checked kernels, and a view is a buffer at an offset. onnx 0.12.3's static backend generates its convolution instead of a hand-written override (tinyyolov2 2.17 → 1.65 s, bit-identical to the other backends).
0.7.0, anomaly 0.36.4, arima 0.6.2, audio 0.8.4, benchmark 0.1.8, cas 0.1.6, chart 0.1.6, cli 0.4.4, data 0.2.5, embed 0.6.3, f5tts 0.5.0, gRPC 0.3.0, gguf 0.6.1, gpu 0.17.6, gpukit 0.11.12, grad 0.13.3, http 0.7.6, http-client 0.3.5, hub 0.2.3, iforest 0.1.9, image 0.7.7, lingbot-map 0.12.1, lsmdb 0.3.0, map-anything 0.7.2, mermaid-server 0.4.0, mlp 0.3.13, nn 0.3.0, nurl-cov 0.3.0, nurlbox 0.5.0, nurllama 0.22.0, nwasm 3.1.2, oauth 0.2.6, objdet 0.4.5, onnx 0.12.3, pki-server 0.5.2, ply 0.3.5, pqc 0.2.7, psql 0.4.5, redis 0.3.5, safetensor 0.6.0, swarm 0.3.3, swarm-mcp 0.31.1, template 0.2.5, tensor 0.6.6, tokenizer 0.4.6, torchpt 0.4.1, video 0.2.5, vindex 0.2.3, wasmbuilder 0.3.6, whisper 3.3.0, yoloe 0.8.1, yoloe-demo 0.3.6 and zst 0.2.3. The versions named in the entries above are the steps on the way there.
0.71.0's one known hole in the memory guarantee (a Slice of a Vec, probe h32) is closed, along with 109 more found around it (probes h33–h141), each at its root — every probe is now rejected or runs clean under ASan/UBSan/LSan, and tools/fuzz/holes/check.sh runs in CI. The release review found 37 more that are still open — raw strings handed to string_adopt or a sink s, views that leave through a helper's return value, mem_forget, mem_take and the rcbox primitives callable from safe code, among others; each has a probe (Known issues). With the new checks json_parse retires 0.2 % more instructions (the null-safe string length and the checked growth), every other bench kernel the same or fewer, and the compiler compiles itself in 0.7 % fewer. The standard library's hashes, ciphers, codecs and collections are rewritten for speed: BLAKE2b 5.1x, BLAKE3 10x, ChaCha20 2.4x, Poly1305 1.67x, inflate 2.4x, hashmap inserts 1.7x, sort_by 2–25x (Performance); a program that does TLS compiles 4.6 % more instructions for it. nwasm 2.4.0 runs the bench's Poly1305 and X25519 modules 1.75–2.1x faster. Large programs that do TLS or post-quantum crypto build on every core (agora's and anomaly's cold builds 3.7–4.7x faster), and nurlc warns when a loop walks a string with nurl_str_get — the quadratic scan that held nurl-lsp for 658 s on the compiler's source (now 2.8 s) and nurlfmt for 148 s (now 0.07 s).
Code that handed raw pointers or caller-given lengths around outside unsafe — a call of a function that takes or hands back a *T, of a raw C primitive or of a standard-library __ function, a Slice, Vec or library handle built field by field or by a cast — may need unsafe, or the safe API now beside each raw one, and a fresh raw string (s) can no longer be stored in a struct, an option or a container: hold a String (Changed). break and continue in a blocking select arm now apply to the enclosing loop. nurlpkg install and publish, which refused every package on Windows under 0.71.0, now work there, and neither checks package.nurl-version any more. The registry packages are republished for 0.72.0 (53 of 58: the published versions of 47 no longer compile under it, and six more now use its slice_of_str).
Each is a safe program — no unsafe function of its own — that this compiler accepts and that leaks, frees memory it does not own or reads freed memory under ASan/LSan/UBSan. Each has a probe in tools/fuzz/holes/open/ (./check.sh open/*.nu open/stdlib/*.nu reports all 37), kept out of the CI gate until it is fixed at its root.
string_adopt takes
over whatever s it is handed: a literal, a view of a String, a borrowed parameter, a call's result that is fresh on only some paths or a closure's parameter is freed though the caller does not own it — directly, or through a sink s, a method or a %Trait object, and across a recover. A sink s the callee only reads, or leaves in a parameter's container, is never released, and a generic's sink A instantiated at s leaks (string_consume_*). Until then, take a string over as a sink String, and hand string_adopt only a string the caller owns.
^ ( pick x ),
where pick hands back its argument, or a closure or Vec a helper builds around a local string, outlives the string it views; bound first (: s y ( pick x ) ^ y) it is rejected (view_through_return_*).
inout struct, a Vec that aliases a parameter or sits inside one — outlives the local it views (view_into_*).
( f m m ) with two sink parameters, or ( f ( string_data str ) ( eat str ) ), which reads str after the later argument consumed it (consumed_*).
mem_forget and mem_take are callable from safe code, although
the specification makes them unsafe (§3.3d): mem_forget leaks what it forgets, and mem_take claims a borrowed field or element as owned (mem_forget_*, mem_take_*).
rcbox primitives are callable from safe code: rcbox_new
parks a value nothing releases, rcbox_release frees whatever address it is given, and a program's own handle written in the old convention releases a block it forged from a string (rcbox_*).
% Drop impl that panics runs again on the unwind;
one that reassigns a field of its receiver leaks the new value; one that hands its receiver to a sink disposer leaks the fields; a value handed straight to such a disposer skips the impl; and a closure defined inside an impl runs the type's drop glue on its own first parameter (dtor_*).
(closure_call_fresh_temp_string), and a keyword-argument call to a forward-declared function that consumes a handle frees it twice where the positional call is handled (kwargs_forward_consuming_callee).
/stdlib/ is compiled
as the trusted standard library, with every safe-code check off (stdlib/trusted_by_path_substring).
linkonce_odr global. The
partition defined every module-level global in part 0 and declared it external elsewhere; a linkonce_odr one (.nurl.peek.zero, read by every inlined Vec accessor, and the cycle collector's ops tables) is discardable, so a part 0 whose own code did not use it dropped it before the link and every reference to it was left undefined. It was latent — every package split so far had a part 0 that read a Vec — and surfaced when packages/agora was first split (see Performance). Such globals are now replicated into every part, and compiler/tests/split_equivalence.sh checks that no part declares one external.
Slice, a struct, Option or container
holding one, a view a closure captured, one stored in a global or returned from a function — is tracked as a view of its source wherever it goes, and ends with it (docs/MEMORY.md §2.10). Freeing or growing a Vec while a Slice of it (or protobuf's ProtoReader) is still read, 0.71.0's known exception, is rejected (h32–h50, h80, h81, h89–h95).
Slice, Vec, String, Box, a library
handle, or any struct with a raw-pointer field can no longer be built field by field, have a field written or a raw field read, or be made by a cast outside unsafe; slice_from_raw, vec_borrow_raw and vec_set_len are unsafe to call (h51–h62).
as another argument — the same owner twice, or a view or borrow of it (h63–h68, docs/MEMORY.md §2.4).
reallocates or drops elements of a capture ends the views and borrows of it where it runs, directly or through a callee it is handed to (h69–h71, §2.11).
and reallocates is read from its body (raw provenance), so map_get, box_get, deque_get and btree_get results borrow from their container as vec_get's do, and replacing the value ends them (h72–h76).
(( vec_at [String] . b items 0 )) lends and loses its field like a binding does; a nested field (. . o b name) moved into a sink parameter or replaced is its binding's own storage — no double free, no leak (h82–h88).
n * size and n + 1 wrapped in
vec_with_cap, vec_zeroed, string_with_cap, string_repeat, bitset_new, alloc / zalloc and every Vec / HashMap / Set growth; a count whose byte size cannot be represented now panics before anything is allocated (h77–h79). The checks run only where memory is allocated, not on reads or on a push that has room: a growth is tested once, not once per doubling, and a division is taken only for a count or element size of 2^31 or more.
( vec_get [String] ( mk ) 0 )took the element for the temporary Vec itself: the Vec was never dropped. The element is copied and the temporary dropped after the call; an element that cannot be copied is an error naming the fix (h96). A temporary an instance of a generic function hands back was freed under the result: the instance is compiled after its callers, so the call did not ask it whether its result was its own (h105).
trait's bare name, which has a contract and no body: an impl that kept its argument had it dropped under it by the caller, a temporary handed to a method (or a temporary receiver) leaked, and a method taking or handing back a raw pointer was callable from safe code (h106, h107). A call the receiver's type dispatches statically now asks its impl everything a call of a function asks. A call through a %Trait object asks every impl at once: its arguments are kept, lent or dropped as any impl may, and a handle result is owned per call. Impls that disagree on keeping an argument, or an impl that keeps its receiver (the object still owns it), are an error where the object is called (h110–h112). sink and inout parameters in a trait's signature no longer make every dyn call of the method an arity error, and a trait method with a closure parameter (( @ v ) f) parses — its header scan stopped at the closure type's @. The thread rules reach a method's impl too, on the concrete type and through a %Trait object (every impl's at once): a closure an impl runs on another thread or fiber must be Send where the method is called — one that captures an Rc, handed to a method that spawns it, is an error — and a closure an impl stores in a thread-shared handle is held to the cycle rule.
sink parameter handed back is the caller's. ^ x out of a
sink parameter — a builder that takes its value and returns it changed — was read as a second name of the argument the caller had handed over, so the result was nobody's and leaked (h108).
*T parameter was
summarised as keeping what it was handed when its function stored it in raw memory: json_parse_n's text, held by its scratch parser, made json_parse keep its text, and every temporary handed to it leaked (h109).
anomaly's multi-tenant issuer template was a view of the discovery reader's String, freed when the reader returned — every later token check read freed memory; nurl-mcp's --token was a view of main's String kept in a global, which outlives it (in practice main held it as long as the server ran).
owning call, or a mutable one born from a literal or an owned string, owns its buffer: every value assigned to it after is fresh or its own copy. The view walk took every s for a view. A copy of an owning one (: s y x) borrowed nothing, so it was read after = x … freed the buffer (h113–h115), handed back while the owner was dropped on the way out (h116), or reached through a result that may be the argument itself (h117) or the whole rest of a split cursor (h118) — each a read of freed memory. And a view assigned into an owning binding, which copies it, was taken for a borrow of its source, rejecting correct code: a match arm's payload, the String of a shorter block (f5tts's bearer check and {id} capture were rewritten around this; they are back as they were). Now a copy of an owning s is a view of its buffer, ending when the binding is given a new value or ends; the binding handed back by name goes to the caller; a binding that copies what it is given, or takes an owned value, holds a fresh one.
it is given.** Since such a callee stopped being asked per call, the binding had no owner slot, and a tracked local assigned to it — copied, as it must be — leaked once per assignment (h119, h120). It gets the empty owner slot a binding born from a name gets; so does one born from a struct's field, whose owned strings leaked the same way (h122).
literal gave a fresh string is freed with the struct; assigned a view, an owned local or a literal, it stored that pointer as it came — a double free, an invalid free, or (the struct handed back) a caller reading a String the callee had dropped — and a fresh value assigned to it leaked the one it replaced (h123–h127). A field read assigned to a string binding that owns its value was stored the same way (h121). Both now hold their own copy of anything not fresh, and free what they replace.
payload, a slice element, a struct temporary, a struct pushed into a Vec, a field assigned after the struct was built with a literal or a view, a join or a reassignment of struct literals, a vec_push [s] (h128–h140) — leaked; it is now rejected (Changed). And a closure or a struct literal handed back as written was not checked for what its views point into: one that captured an owned local string read it after the function released it (h141).
unsafe. A C primitive that reads or
writes as far as its caller says (memmem, memcmp, fwrite, nurl_fast_atof, the TCP/UDP buffers), takes a FILE* or a format string (fopen, fclose, printf), allocates or frees, or is the runtime's own machinery is marked "nurl.raw" and callable only from unsafe code. So is any function that takes or hands back a raw pointer (a * in a parameter's or the result's type, outside a closure type: vec_data, slice_data, arena_alloc, box_ptr, the raw-length helpers, a program's own function with a *T parameter or a → *T result), and any standard-library internal (a __ name): 0.71.0 accepted both in safe code, a stdlib __ name with only a warning. A raw pointer could become a string (( nurl_println ( vec_data v ) )) and a CSV cell, which is not NUL-terminated, was handed out as one (h97–h101, h103). A generic internal is caught by its declared name, which its instance name (__vec_grow__fp1__u8) hid: __vec_grow could be handed a string's bytes for a Vec's control block (h104).
# s 0 and an unset
getenv crashed nurl_println, nurl_str_len, strlen, string_from and every string primitive built on them; a C string parameter ("nurl.cstr") now takes a null as "" (h102).
select arm bodies run after the wait loop, with every channel
disarmed: a break or continue in an arm body of a blocking select now means the enclosing loop, as it reads. Before, either one left only the select's own wait loop (the author's loop went on) and skipped the disarm, freeing the waiter while other channels still held it; a ^ skipped the disarm and the free both. The waiter is now dropped however the body leaves.
nurlpkg install and nurlpkg publish refused every package on
Windows** (nq, nwasm, anomaly, zst, …), on a v0.71.0 toolchain that met every requirement. nurlpkg looked for the installed compiler only at <prefix>\bin\nurlc.exe; the install puts it at build\nurlc.exe and only a .bat shim in bin\. Install read an empty version, which compared as too old (PkgToolchain: upgrade NURL to satisfy package.nurl-version), and no longer asks for one (Changed). Publish, --dry-run included, had no compiler for its typecheck of the packaged sources (nurlpkg: no installed compiler under <prefix>); it now looks in build/nurlc[.exe] first, then bin/.
v128 or v256 inside a compound type lowers. An option
(?v128, also as a struct field) reached the IR as the undefined type v128, which LLVM rejected, and a call passing a vector to an inout v128 or inout v256 parameter was refused as a type mismatch (passed 'inout' as 'v128*' but the parameter is declared 'v128'). The vector now lowers to <4 x i32> / <4 x i64> wherever it stands, as the bare type always did.
without --no-cpu-dispatch, so every simd function (the Keccak x4 permutation, the ML-KEM and ML-DSA kernels) reached a wasm module twice: its baseline body, and an x86-64-v3 clone compiled for wasm32 behind a dispatcher asking for a CPU wasm never is. wasmbuilder now passes the flag, as nurl.sh does on every non-x86-64 machine. Its IR rewrite, which nurlapi's wasm builds share, also folds nurl_simd128_native() and nurl_cpu_x86_v3() to 0, the answer the wasm runtime gives to both, so --gc-sections drops the vector kernels they guard. bench/chacha20's module, whose new four- and eight-block kernels never run in wasm, is 49 KB (48 KB in 0.71.0); without these two changes it is 75 KB.
unsafe no longer compiles
where 0.71.0 accepted it (docs/spec.md §3.3d; the holes each rule closes are under Fixed): a call of a function that takes or hands back a raw pointer (*T in a parameter or the result: vec_data, slice_data, box_ptr, arena_alloc, slice_from_raw, vec_borrow_raw, a program's own), a trait method's included, statically or through a %Trait object; vec_set_len; a C primitive marked "nurl.raw" (memmem, memcmp, fwrite, fopen, printf, realpath, nurl_fast_atof, nurl_tcp_read / nurl_tcp_write, …); a standard-library function whose name begins with __; a literal of a String, Slice, Vec, Box, library handle (a program's own too, when its drop hook is unsafe) or struct with a raw-pointer field, a write to any of its fields, or a read of a raw one; a cast that makes a String, a Vec, a closure, a struct or option of one, or an enum with payloads out of a value of another type. Each error says what to use instead. Two ownership rules are new too: the impls behind a %Trait call must agree on keeping each argument, and none may keep the receiver, which the object only lends (declare the parameter sink in the trait, or keep a copy); and an element that cannot be copied may not be borrowed out of a temporary container (( vec_get [T] ( mk ) 0 )) — bind the container first (docs/LIMITATIONS.md).
code a raw string (s) held by a struct, an option, an enum, a slice or a container owns nothing, so a fresh one — a call's result that hands its string over — can no longer be stored there (@ T { ( nurl_str_cat … ) }, @ ?s { T … }, [ s | … ], = . r name ( … )) nor handed to a parameter that keeps it (vec_push [s], map_set): each leaked (h128–h140). Hold a String (a field, a payload, a ( Vec String )), or bind the string and store the binding — a view the walk keeps from outliving it. A parameter that takes a string over is a sink String (a sink s is not yet released by its callee: Known issues). unsafe code and the standard library keep managing raw strings by hand. In the repository only tests built structs that way; they are unsafe now.
unsafe to call —
nurl_str_at, nurl_memcmp_lex, nurl_memmem_range, nurl_parse_int_range, nurl_parse_float_range, string_from_take, string_from_n, nurl_str_slice_unsafe, json_parse_n, bytes_extend_raw, b64_encode_len, b32_encode_len, proc_write, constant_time_eq_n, utf8_decode_n, the block-device and FAT transfers (blk_read_raw / blk_write_raw, nurl_blk_read / nurl_blk_write, fatfs_read_raw / fatfs_write_raw) and nurl_print_bytes: each trusted its caller's length. The safe forms carry their own: slice_of_str + slice_byte for a scan (O(1) per byte, like nurl_str_at), string_adopt, write_string, b64_encode_string, utf8_decode_at, slice_parse_int / slice_parse_float.
csv_table_view and csv_table_view_by_name return a ( Slice u )— the cell's bytes with their length (a cell is not NUL-terminated); the empty view when out of range.
an out-struct or a C handle is *u (sqlite3 handles and statements, termios, stat and socket address buffers, the Rc collector's internals), a string is s: what is raw memory is unsafe to call by its type. The C stdio pass-throughs in stdlib/core/builtins.nu (fopen, fwrite, fread, …) still declare their FILE* and buffer as s; they are marked "nurl.raw", so they too are unsafe to call.
select machinery is typed. The functions a ?? {} select
lowers to took integers and cast them back to pointers, so any program could call them with any integer. select_waiter_new now returns a SelWaiter handle, released by its drop (select_waiter_free is gone), and select_waiter_prepare / select_waiter_wait take it. chan_select_arm, chan_select_disarm (each taking the Channel and the SelWaiter) and chan_select_poll (the Channel) replace chan_raw_arm / chan_raw_disarm / chan_raw_poll, which are internal now (__chan_raw_*). chan_raw_closed is gone: chan_select_poll answers 2 for a closed, drained channel.
package.nurl-version no longer gates anything. nurlpkg install
(registry packages, the root project, local dependencies) and nurlpkg publish no longer compare it against the toolchain, and the PkgToolchain error is gone. A gate that reads the toolchain wrong refuses every package on a current toolchain; that costs more than the incompatibility it guards against, which still surfaces as a compile error. The field is still parsed and validated. Toolchains up to v0.71.0 keep the old gate until they are upgraded. In the library (stdlib/ext/pkg_fetch.nu) that error was the PkgToolchain variant of PkgFetchErr; a match arm that names it no longer compiles. pkg_install_one, pkg_install_locked and their *_for_toolchain forms keep their signatures and no longer refuse a package for its nurl-version (the toolchain argument is ignored); an archive is still checked for its checksum, its signature and the name and version in its manifest. manifest_supports_toolchain (stdlib/ext/manifest.nu), which nurlpkg no longer calls, answers F only for a toolchain version that is known and strictly older: an empty or unparsable version (unknown, a bare commit SHA) and the build scripts' v0.0.0 fallback pass, where they were refused; a git describe build (v0.71.0-3-gabc1234, v0.71.0-dirty) compares as its tag, not as a prerelease of it; and surrounding whitespace, such as a CRLF from a Windows pipe, is ignored.
nurlc warns when a loop walks a string with nurl_str_get.
nurl_str_get bounds-checks by measuring the string from its start to the index on every call, so a loop reading a string bound outside it — in its body or its condition — is quadratic in the string's length. Its doc comment said so; the shape kept coming back anyway (the stdlib's parsers, tokenizer 0.3.0, nurlfmt, md2html, nurl-lsp). The warning names the binding and the O(1) form: measure once with slice_of_str, read with slice_byte (0 outside the string, like nurl_str_get). utf8_decode, which measures the whole string per call, is flagged the same way and pointed at utf8_decode_at. A literal index (( nurl_str_get src 0 ): O(index), not a scan) and a string bound inside the loop are let through; compiler/tests/should_warn_strget_loop.nu pins both sides. The check reads the string operand's name as written, so it does not yet see a string reached through a field path (. ps text) or a call (( string_data t )), or one bound to a one-letter name that is also a type keyword (s, b, i, u, f, v). The tutorial's wc example ("Walk the string"), which taught the quadratic loop, now reads its text through slice_of_str + slice_byte, and the doc comments on nurl_str_get and nurl_str_at point there (nurl_str_at is the raw form, for unsafe code).
slice_of_str, slice_byte, slice_parse_int, slice_parse_float,
string_adopt, write_string, b64_encode_string, utf8_decode_at: the safe forms of the raw-length helpers above. slice_empty [A] (stdlib/core/slice.nu), the empty ( Slice A ), is the safe form of a @ ( Slice A ) { # *A 0 0 } literal, which now needs unsafe (Sealed representations); the error for such a literal names it.
the helpers the library sizes its buffers with (Fixed): ( alloc_size size n ) is n * size and ( alloc_count_add a b ) is a + b, each panicking (allocation too large: …) on a negative count or a result past what a size can hold; ( alloc_grow_cap cap need ) doubles cap (4 when empty) until it holds need, or gives need itself where doubling would overflow; and ( alloc_grow_pow2 cap need first ) doubles cap (first when empty) for a power-of-two table, panicking when need is past 2^62.
tools/fuzz/holes/) and their check in CI: eachmust be rejected or run clean under the sanitizers.
chacha20, poly1305,
blake2b, sha512 and x25519 — in NURL, C and Rust. The NURL side is a driver around the standard library's own implementation (stdlib/std/chacha20poly1305.nu, hash_blake2b.nu, hash_sha512.nu, x25519.nu), so the rows measure the stdlib a NURL program gets. C and Rust carry the stdlib's formulation by hand: Poly1305 at radix 2^64 (four 64×64→128 products a block and two small ones), and the TweetNaCl ladder over the donna-c64 X25519 field (radix 2^51) with native 128-bit products. ChaCha20 is the one deliberate difference: C and Rust run the scalar RFC rounds, while the stdlib runs four blocks a pass over v128 lanes, eight over v256 lanes in its x86-64-v3 clone; that gap is what the row measures. Every source is checked against its RFC/FIPS vector, and all three languages also pass the wasm suite's ten-cell gate.
bench/manifest.tsv takes an optional fourth column naming a row's
languages; Node and Python report n/a on the NURL/C/Rust-only rows.
bench/bench.sh --scale N (default $BENCH_SCALE, else 1), the
workload multiplier wasmbench.sh already had: a xN run is NURL / C / Rust only and writes RESULTS-xN.md / results/xN.json beside the x1 report. bench.yml gains the matching scale input; push and tag runs stay x1. wasmbench.sh now also defaults --scale to $BENCH_SCALE.
bench/bench.ps1 -Scale N, and bench/wasmbench.ps1, a port of
wasmbench.sh for Windows (exercised under pwsh on Linux; its Windows-only branches are not yet run on a Windows host).
nurl_v128_unpacklo32 / unpackhi32 / unpacklo64 / unpackhi64,
and nurl_v256_add32 / rotl32 / bcast32 / unpacklo32 / unpackhi32 / unpacklo64 / unpackhi64 / permlo128 / permhi128 (docs/spec.md). nurl_simd128_native() is a runtime function, not an intrinsic — declare it & c @ nurl_simd128_native → i — that returns 0 on a wasm32 module built without simd128, where LLVM scalarises a v128, and 1 everywhere else; a kernel that only pays off in vector registers asks it before running. wasmbuilder folds the call to 0 (Fixed).
simd functions build on every core: 3.7–4.7x faster
cold builds, 5.4–6.2x faster edit rebuilds.** nurl.sh lowers a large program as up to one module per core, but a module holding any simd function was never split — and every program that does TLS or post-quantum crypto holds some, so the largest NURL programs were the ones lowered on one core. The partition now keeps each simd function — dispatcher, both clones, and everything they reach — in one part, whole, and splits the rest as usual (the group is 6.7 % of agora). The wide clone sees exactly the code it saw before: ML-DSA-65 sign + verify retires −0.6 % instructions against the one-module build, where cutting the group apart costs +60 %. 6-core Haswell-E: packages/agora 61.3 → 16.6 s cold, 59.4 → 10.9 s after a one-line edit; packages/anomaly 106.7 → 22.9 s cold, 102.6 → 16.6 s after an edit. Peak RSS of the agora build 354 → 260 MB. The rest of such a program is now split like any other large one, so a TLS or post-quantum program built by a plain nurl.sh (and a tool nurlpkg install builds) is no longer one module by default. The other wide kernels also stay within 0.7 % of the one-module build (ML-KEM-768 +0.2 %, SLH-DSA-128f +0.7 %, ChaCha20-Poly1305 +0.5 % instructions), but the code around them pays the split build's usual trade (the split-built compiler retires 3.4 % more instructions, docs/BUILDING.md); build with NURL_SPLIT=0 for a release, as for any split program.
nurlfmt is linear: compiler/nurlc.nu 148 s → 0.07 s. Its
tokenizer read every byte with nurl_str_get, which measures the string from its start on each call, so formatting was quadratic in the file (16 000 lines 12.4 s → 0.02 s). The two 2.8 MB compiler sources cost the pre-commit hook two and a half minutes per compiler commit, and CI's nurlfmt_check.sh as much. It now reads through slice_of_str + slice_byte; output is byte-identical on every tracked source.
measures its string once.** Every loop it flags in the standard library, the tools, nurlapi, the examples, the unikernel and the tests (262 calls) and in the compiler itself (124) reads a measured view instead; every tracked source keeps its verdict and errors, and the compiler emits byte-identical IR for every one of them. Where the strings are long it shows: nurl-lsp opening compiler/nurlc.nu and answering a symbols and a references request 658 s → 2.8 s (its token scanners and reference matcher take the document measured once, instead of measuring it per call); nurlapi's GET /CHANGELOG.md, the 1.2 MB changelog rendered as HTML, 36.5 s → 0.09 s — a single request held a worker for half a minute. Same output byte for byte.
tokenizer's byte-level BPE are linear** (packages/md2html/CHANGELOG.md, packages/tokenizer/CHANGELOG.md): 535 calls in 37 packages. md2html 0.1.4 renders the 1.2 MB changelog 36.5 s → 0.02 s and a 263 KB table-heavy document 1.11 s → 0.00 s, byte-identical. tokenizer 0.4.2 encodes 1.2 MB with byte-level BPE (GPT-2 and qwen2 pre-tokenizers, the word merger) 50.7 s → 0.37 s, and its SentencePiece merge keeps candidate pairs in a max-heap like llama.cpp's llm_tokenizer_spm, O(n log n) where it was O(n²): 99 KB with gemma-3's 262k pieces 222 s → 0.05 s past the 0.66 s vocabulary load, the same ids, ties included.
speed** (bench/ crypto rows, i7-5930K, the same output everywhere): BLAKE2b 5.1x (the bench row ~1.9x faster than its C peer here, 1.5x on the runner behind bench/RESULTS.md), BLAKE3 10x, SHA-1 3.4x, MD5 2.6x, SHA-256 1.21x, SHA-512 1.17x; ChaCha20 2.4x (four blocks a pass over v128 lanes, eight over v256 lanes in the x86-64-v3 clone); Poly1305 1.67x (radix 2^64, four products a block — the bench's C and Rust peers carry the same formulation); X25519 1.15x (field elements as scalar limbs). The pattern that did it: working words as scalar locals, inline helpers taking the words they update inout, rounds unrolled one rotation period per pass with permutations as argument order — not the whole function, whose compile time grows faster than its speed (What it costs to compile, below).
deferred modulo 8.3x, a table-driven inflate 2.4x (zlib's speed), and deflate's match search 1.7x with byte-identical output.
hashmap keeps one state byte per slot with
a 7-bit hash fragment (insert 1.7x, lookup 1.3x, same iteration order); sort_by is a branchless Lomuto introsort with run detection and a sorted-prefix merge (2-25x; only a sorted run with 1 % scattered outliers is 24 % slower); past 2·log2(n) levels of partitioning it finishes with heapsort, so its worst case is O(n log n), where 0.71.0's quicksort could be driven quadratic by crafted input; it is still not stable, and elements that compare equal may come out in a different order than under 0.71.0; base64 decode works by quartets (3.4 → 0.9 ns/byte); utf8_valid skips ASCII eight bytes at a time.
→ *T
function ended by storing its proof to the thread-local channel through an asm volatile with a memory clobber, which forced every caller to reload what it had cached: blake3 −6.0 % instructions, the blake2b bench −3.8 %. A dyn thunk over such a function publishes 0.
runtime's size-class cache, which was compiled out of every wasm32-wasi build; the wasi-threads build keeps its locked allocator and no cache. bench/json_parse as wasm on nwasm: 250M → 223M instructions (−10.8 %), 113M → 107M cycles (−5.3 %).
waiting on its own spills** (packages/nwasm/CHANGELOG.md). Core wasm has no 64×64→128 multiply, so a C, Rust or NURL 128-bit product calls compiler-rt's __multi3. A direct call to either of the two bodies today's toolchains link (LLVM's compiler-rt, Rust's compiler-builtins), recognised by its exact bytes and signature and never by name, runs as a multiply in every tier: Poly1305 C / Rust / NURL modules 794 / 877 / 771 M → 416 / 419 / 441 M cycles, X25519 1375 / 1447 / 1325 M → 724 / 731 / 715 M. Tier 8 reads a spill it has just stored from rax (ChaCha20 254 / 261 / 230 M → 203 / 206 / 178 M, BLAKE2b 447 / 521 / 284 M → 394 / 478 / 243 M). A 64-bit counter's i64.eqz; i32.eqz; br_if is one compare-and-branch, a delay-line copy (SHA-2's h = g) no longer outranks the words a round computes for a register, and a pointer parameter is zero-extended once, at entry: together sha512.c 302 → 260 M, bloom_filter.c 5.37 → 4.96 G cycles. All 60 bench/ modules print what they printed.
more instructions than before these changes, most of it ChaCha20's eight-block kernel in both clones. Fully unrolled SHA-2 and SHA-1, and an X25519 ladder with every field operation inlined, were measured and rejected for compile time (the X25519 one cost a TLS program three seconds).
12.59 G instructions, instructions:u, each compiler built by itself, same input) with the larger borrow walk and every call now asked of the right callee: the raw-provenance graph is integers, walk rows are cheaper, and…
Every s-returning call queried the runtime for an ownership proof (two opaque calls and a free(NULL)); a callee whose every path returns a borrow, a raw read or a literal now says so statically (__ret_unowned), and so does every call with a raw-pointer result, which nothing owns. Against main, every benchmark kernel retires the same instructions or fewer (blake2b −0.18 %, x25519 −0.03 %, chacha20 / poly1305 / sha512 −0.01 %, the rest within ±0.01 %) except json_parse, +0.22 % (+331 of 154 K instructions), which pays for the null-safe string length and the checked allocation growth (Fixed).
The ownership rules become the language: **every program accepted without an unsafe function of its own is memory-safe and leak-free** (docs/MEMORY.md §6), with one known exception, below. ASan and LSan remain CI checks on the compiler; a program's safety no longer rests on them. Rc cycles are collected and Arc cycles are ruled out at compile time, so counted handles no longer leak either. Library functions compile only when the program reaches them (a corpus-wide 40 % fewer compile instructions), the HTTP/1.1 server runs 29 % fewer instructions per request, and nwasm gains a register-allocating JIT tier that runs most wasmbench modules faster than wasmtime's Cranelift.
Code that works with raw pointers outside an unsafe function no longer compiles, and owned values move: a program the older rules accepted may need unsafe on its raw-memory functions, or a read moved before the value is handed on. The registry packages are republished for 0.71.0.
Slice of a Vec is not tracked as a view of it — the one known
exception to the guarantee. slice_from_vec, slice_sub and slice_from_raw (stdlib/core/slice.nu), and protobuf's ProtoReader, which holds one, return a Slice the compiler does not tie to its Vec the way it ties a vec_data pointer: freeing or growing the Vec while the Slice is still read compiles, and reads freed memory. It is kept as the open hole probe tools/fuzz/holes/h32; until it is closed, read a Slice only while its Vec is alive and unchanged.
container, sending it, returning it, handing it to a sink parameter or capturing it in a closure another thread runs moves it, and a later read of the old name is an error. Values with nothing to release are copied.
vec_get, field
reads, match payloads of a borrowed value, lent call results and views (string_data, vec_data) end when their source is moved, released, reassigned or reallocated; a read after that is an error. A stale view was a warning and is now an error.
handles (Channel, Mutex, Arc, HttpServer) are captured as a share of their own. HttpServer is now a shared handle.
false positive: the rules are conservative and each message gives a fix.
second binding** (= out tmp, : T b tmp) outside unsafe: the fields stay the original's and were freed with it, so a copy in an outer block or a by-reference capture read freed memory (accepted before). Use owning field types (String, Vec).
inout s parameter cannot be given a new value. The callee
cannot tell whether the caller's string is owned or borrowed, so the replaced value either leaked or would be freed under a borrower. Return the new string, or take a String.
counts are not collected, so a store that could close one is an error: a value kept in an existing thread-shared handle's state (a closure's captures, a channel queue, a handle) that can lead back to that handle, and arc_set / arc_ptr on an Arc whose payload could close a cycle — such an Arc is built first and shared whole, and arc_get hands out a copy (docs/MEMORY.md §7.7). The rule found a real cycle: swarm-mcp's kernel job handler captured its own Swarm.
cannot see what the body does with its arguments, so the contract is fixed: the caller keeps and drops what it passes, the body copies a parameter it keeps, and releasing one (a sink parameter) is a compile error. Every *_free_with lends each element to its hook.
--no-borrowck is deprecated. It still disables the checker, butwarns: a correct program the checker rejects is a false positive to report, not a build to force. Ownership summaries no longer depend on the checker running (under the flag, a temporary a callee handed back was dropped after the call).
docs/MEMORY.md describes the memory model and nothing else — ownershipand auto-drop, the rules, the guarantee and its trusted base, threads, release, panics and Rc cycles (111 KB → 45 KB; section numbers kept).
its own contents kept every count in the cycle above zero forever. Rc now has a per-execution-context Bacon–Rajan collector (trial deletion, iterative walks, collection at a threshold and when a fiber, thread or the program ends) that only cycle-capable types reach: the compiler walks the ownership graph and generates trace functions for exactly the types whose Rc payloads can close a cycle; every other type compiles to plain counting (docs/MEMORY.md §7.7). rc_collect runs it on demand.
Weak and ArcWeak. rc_downgrade / weak_upgrade and
arc_downgrade / arc_weak_upgrade (Rust's scheme: the strong handles together hold one weak count; an upgrade succeeds only while a strong handle lives).
bench/wasmbench.sh --scale N (and the wasm-bench workflow's scale
input): every benchmark does N times its work, set before compilation. Each NURL, C and Rust source defines BENCH_SCALE once and multiplies its iteration count by it, or repeats its kernel (fib, sieve, matmul); the harness rewrites that one number in a copy, so x1 is the published contract unchanged (the modules are instruction-identical) and a large N amortises start-up and module compilation. A xN run writes WASMRESULTS-xN.md beside the x1 report.
vec_at,
vec_put or vec_get executes the instructions of the same loop over a vec_data pointer (it was 3.6–7.6× more): the bounds check folds away against a vec_len bound and the loop vectorises. The Vec control block is read through new compiler built-ins whose load is not behind a branch, and control-block and element accesses carry TBAA tags that tell the optimiser they never overlap. stdlib_hotpath runs half the instructions. tools/vec_parity.sh holds it in CI.
unsafe functions (grammar v2.8, spec §3.3d). Raw pointer reads and
writes, pointer casts, the raw-memory primitives and foreign functions declared outside the standard library are allowed only in a function declared unsafe, which vouches for itself. nurlc --unsafe-report lists a program's own unsafe functions.
vec_at / vec_put: element access that panics on a bad index.tools/fuzz/holes/: one probe per proven soundness hole in safe code
(check.sh counts the holes; all are rejected), and seven new rejection cores in the inverse-oracle fuzzer.
generated, borrow-checked and emitted every function of every imported module and let the dead-function pass throw most of it away: an empty main beside $ stdlib/ext/json.nu cost 556 M instructions for 312 functions, 310 of them dropped. The signature prescan now records which names each body spells, and a library function nothing reaches from the project's own files is skipped (a function generated code still refers to compiles late, from its kept source). Compiling every test, example and benchmark (1,190 files): 1364 G → 815 G instructions, −40 % together with the symbol-table change below; that json import 556 M → 154 M. Off under --no-dce, --lint and -g.
work.** A name defined again in the scope that holds it takes the new value in place instead of appending a shadowing duplicate (the side channels did so ~60 times per call), and the hash reads at most three 8-byte windows. −5.2 % instructions on a fixed input set; IR byte-identical. Each brace block is lexed once to be stepped over by the later passes, and the prescan lexes each body once.
(bench/http_server.nu, 13 790 → 9 805): Vec growth is checked at the call site, header control bytes are found with one vector scan, field names are checked against two 64-bit tchar masks, digits are written straight into the buffer, and every header lookup measures the name once. The runtime journal's fast paths test the pointer before the thread-local counter, which lets every program fold those reads away.
stdlib/ext/json.nu finds the end of a string body in one pass. The
escape-free fast path took three: memchr for the closing quote, memchr for a backslash, then a byte walk for raw control bytes. One eight-bytes- a-step scan now finds the first of the three, and anything but the quote goes to the canonical decoder, which reports it at the same position. bench/json_parse.nu as wasm: 6.5 % fewer interpreted instructions, 5.5 % fewer under precompiled wasmtime; natively 2 % fewer. JSONTestSuite verdicts are unchanged (283/283; 31 of 35 implementation-defined accepted).
spilled value's reads take a free register where one is free across them, values a loop writes keep their registers before values it only reads, loop weights no longer compound per continue, and an i32 argument is sign-extended at the call instead of at its (hot) def. --scale 100 cycles against precompiled wasmtime: hash_join.nu 1.16 → 1.10, binary_search.rs 1.03 → 0.93, nbody.rs 1.11 → 1.05, json_parse.c 0.91 → 0.81. Fixed: i32 arguments through call_indirect could arrive zero-extended in tier 8 since 2.1.0.
i64 arithmetic whose high half nobody reads runs in 32 bits, unsigned compares feed carry-only cmovs, r9 is allocatable where globals stay out of loops, zero-extended addresses index memory directly, AVX three-operand floats on x86-64-v3, and no slow lea. --scale 100 cycles against precompiled wasmtime: sort_window 1.19–1.28 → 1.03–1.07, ring_write 0.87, histogram_bins 0.77, collatz 0.97.
Locally it runs 44 of the 45 wasmbench modules faster than wasmtime's Cranelift and ties the 45th, and the nurlc.wasm self-compile in 3.7 s against wasmtime's 3.95 s. bench/wasmbench.sh now shows the two runtimes side by side, the faster cell of each row in bold.
The space-delimited word helpers scan their once-measured ranges directly, and borrow-checker record extraction copies the span its delimiter walk found without asking strnlen to prove it again. This removes about 12 million redundant bounded scans per fixed-source self-compile: 3.3% fewer instructions (11.711 to 11.328 billion), 1.4% fewer cycles (8.843 to 8.723 billion), and about 1.2% less wall time (2.218 to 2.190 seconds; fifteen-run means).
every owned string a heap block of its own, the empty ones included, and empty copies (a lookup miss returning the empty literal, an empty slice or concatenation) were a third of a self-compile's allocations: 9.8 of 29.4 million, each with its free and its panic-journal registration. Every empty copy is now the runtime's one shared empty string, which nurl_free releases by doing nothing (docs/MEMORY.md §7.4). A fixed-source self-compile executes 8.8% fewer instructions (11.81 to 10.78 billion) and compiling the 46 package mains 7.1% fewer; the emitted IR is byte-identical for every tracked .nu file.
strcmp.
Comparing the first two bytes inline handles the common token/type-name mismatch while preserving strcmp for the remaining suffix. A fixed-source self-compile executes 3.0% fewer instructions (13.101 to 12.711 billion) and takes about 5.0% less wall time (2.443 to 2.320 seconds, ten-run means).
caches.** Zero is a valid cached length, not a missing-value sentinel, so the empty sideband values used throughout code generation no longer run strlen("") on every lookup, redefinition and append. A self-compile executes 0.8% fewer instructions (13.202 to 13.096 billion) and takes about 1.6% less wall time (2.454 to 2.415 seconds, five-run means).
(element swaps through copied handles, a ring read after release), tensor (a shape read after its tensor adopted it), toml cursors, lru/resolver maps keyed by views, and a Vec shared with a worker thread.
s binding that starts as a borrow no longer leaks what is
assigned over it.** : ~ s out st over a parameter had no drop slot, so every owned value assigned to it leaked; it now gets an empty owner slot. A return of such a binding also leaked when the last assignment handed the binding's own value back, or when an untracked value was assigned over an owned one. Found when the compiler's own new checker paths ran under LeakSanitizer.
unsafe @ name on one line (it split the prefix off as astatement of its own).
nurl_println / nurl_println_int print a line in one locked write, solines from fibers on several threads no longer interleave.
fiber_join freed a fiber its worker was still switching out of —
a finished fiber publishes its done flag before it swaps back, and a joiner on another thread that read it early freed it under nurl__rctx_swap. The joiner now waits until the worker is through.
runtime.** Their per-connection fibers run on several worker threads, but the servers were written for one: two forwarders wrote the same socket at once (swarm-mcp's tasks smoke stalled in one run of six), and tables were grown while other threads read them (a use-after-free under ASan). Tables are now guarded by a server mutex never held across I/O, and each relay connection has exactly one writer fiber draining its queue.
dropped and cloned as a boxed value: dropping @ Held { Shared rc } crashed.
= g # i v, handed out again) was stillowned by the binding that made it, so returning that binding freed what the global pointed to — a use-after-free in packages/anomaly and f5tts. Storing an owned handle's address in a global now hands it to manual management.
value; a raw string temporary a callee may hand back; and 13 examples that allocated raw memory and never freed it (each LSan-clean now, output unchanged — the particle arrays are Vecs).
time, quadratic in the line it sits on; it is linear.
A memory-safety and performance sweep. The compiler now rejects more programs that use memory unsafely, and fixes leaks and use-after-frees it used to accept: values freed on only some paths, a handle's second name, values stored into owners, functions that lend on some paths, and values that cannot be copied. --debug builds work at -O2 again. The panic journal, the symbol tables and string slicing each cost a fraction of what they did. agora becomes a signed-in web service with one agora per repository.
agora per git repository** (packages/agora, running at agora.homecloud.fi). - Sign-in. agora serve with [auth] mode = "oidc" needs a signed-in person on REST, MCP and the web page. Each organisation (the token's tenant) is admitted by the owner organisation. - Stateless calls. Every call names its repository (repo=, a git remote URL in any spelling) and its agent (as=). Everybody of an organisation working on that repository shares one agora. No session is kept, as in MCP 2026-07-28. - 0.5.1 stops leaking per request, opens one SQLite connection per call, and reads its inbox through an index: about 4–25× the throughput of 0.5.0 at a quarter of the CPU per call.
read as much as a second free (docs/MEMORY.md §2.1). `? c { ( string_free x ) } {} followed by ( string_len x ) or ( string_free x )` compiled clean and was a use-after-free / double free whenever c held; the second free was reported only under --strict-borrowck, the read never. The walk follows the paths that are real: a break / continue ends its path (its state goes to the loop exit / next iteration), a binding declared in a loop body or a block is gone after it, a closure frees what it captured when it RUNS (not when it is made), and a handle handed to a second name on some path stays readable (its second consume is still the strict-mode check). Measured against every first-party program (compiler, stdlib, tests, examples, packages): one stdlib function rewritten (env_cwd, below) and two real double frees in compiler/tests/ws_permessage_deflate.nu fixed; nothing else moved. borrow_strict_maybe_double_free / …_generic_… are default-mode tests now (borrow_maybe_double_free, borrow_generic_maybe_double_free).
? c { = z a } {} then ( string_free z ) left a naming freed memory on the path that aliased — ( string_len a ) read 24 for a 5-byte string, silently. The two names are alias partners on that path now: consuming either maybe-frees the other there, so the read (or a second free) is a default error. The relation is path-sensitive (a handover on one arm leaves the other alone; the = cur nxt = nxt tmp swap drops nothing) and ends when either name is rebound. Five borrow_strict_*_alias tests are default-mode tests now; the forward-declared returning call stays the strict check.
(it was 30). A toolchain release publishes about 60 packages in dependency order, and v0.69's package publish stopped half-way for an hour.
nurl_str_slice measures a string only as far as the slice. The
clamp asked strlen for the whole string on every call; it asks strnlen(str, start + n) now, which decides the same clamp. Cutting a 2 MB buffer into 200k lines by offset went from 6.4 s to 2.4 s; a slice near the front of a long string costs the slice. (strnlen joins the pre-registered libc surface.)
they cache.** nurl_sym_len / _len2 and the in-place appends ran strlen over the value, and the borrow checker asks that of its statement accumulator once per recorded statement — quadratic in a function's size; splitting the accumulator into rows sliced from the text's start, quadratic again. A self-compile executes 3.8% fewer instructions (12.97 → 12.47 billion; strlen 1.25 → 0.77 billion), and 182 temporaries the compiler leaked per self-compile — the origin analysis read the old clamp's strlen result as address-carrying — are freed.
recover extent re-checks the panic journal's tailonly when it removed an entry.** Most frees there are of buffers the journal never held; the tail check ran for each.
nurl_str_get measures a string only up to the byte it reads
(strnlen(str, idx + 1)), and 146 stdlib reads in loops use nurl_str_at with the length measured once — a byte loop that also writes (where the optimiser cannot hoist the measure out) was quadratic in the string's length: 0.39 s → 0.22 s per 200 KB through nurl_str_get, linear through nurl_str_at.
primitive, nurl_strdup_n, copies a string whose length the caller already holds. The compiler's tables measure a key and a value once per definition (they were measured three times) and copy a value out at its cached length; the borrow checker reads a statement row's numeric fields in place instead of slicing each one out to parse it. Together with a one-multiply hash in the panic journal (every allocation and free inside a recover extent hashes, and the compiler runs every declaration inside one), a self-compile takes about 5% less time. The journal's registration no longer carries its growth path inline: every registration saved and restored six registers for a branch taken once per doubling (−1.7% instructions compiling bench/json_parse.nu). nurl_free(NULL) — a slot released after its value moved on, most of a self-compile's 35M frees — returns before the body's register saves (−1% more).
frame, not per value.** Every owned string a binding received inside a recover extent was a hashed journal insert, and its free a hashed removal — a self-compile made 14M of each. A function now registers one table of its bindings' slots at entry and forgets it before each return; a binding joins with one store. The journal's instructions on a compile fell by half (−7.7% instructions compiling bench/json_parse.nu, a self-compile ~3.5% faster). A value that leaves through a struct is disowned from the table, and a drain frees each address once (docs/MEMORY.md §7.2).
docs/dev/V1_HARDENING.md, the v1.0 release-readiness worklist.sink parameter is reclaimed when the callee
panics before freeing it.** The caller drops its registration at the hand-off, and the callee never made one, so the buffer leaked on that unwind. The callee registers it at entry, under the flag that decides the hand-off (compiler/tests/recover_frame_strings.nu).
panicking path comes after the last return.** The elision pass judged a registration safe to drop when nothing could panic between it and its last forget_slot — but a path that ends in a panic (die, then unreachable) reaches no forget and is laid out after the last ret, so a String / Vec binding live on it leaked when it panicked. The check now runs to the end of the function.
stdlib/core/symtab.nu no longer grows without bound. Each
re-definition of a key pushed a new entry holding fresh copies that nothing could read or free again, the bucket array never grew past 4096, and there was no way to release a table, so a long-running user that re-defines keys (the language server re-indexes a document on every edit) leaked on every edit and slowed down as it went. A re-definition now replaces the value in place, the buckets grow with the table, and nurl_sym_free releases it.
@ Holder { a } then = . h v ( vec_new [i] ), or ( vec_push all a ) then ( vec_free all ), dropped a's buffer under the name a; a read of it compiled clean and read freed memory (24 for a 2-element Vec). The field assignment maybe-frees what was stored into the struct, a released container takes what was pushed into it, and a stdlib call that drops or hands out elements (vec_clear, vec_pop, vec_set, map_set, map_remove, …) maybe-frees what was pushed into it.
captures.** ^ @ Box { \ → i { ^ ( vec_len [i] a ) } } dropped the local a at the return under the closure the caller then ran (it read 24 for a 2-element Vec); the captures move into the env, as for a bare ^ \ → ….
free-then-invoke check followed a closure only by its name; once stored into a struct field (a literal's or an assignment's), freeing a capture and invoking the closure through the field ran it on freed memory.
( dyn Trait v ) no longer frees v twice. The %dyn object owns
its box and drops the boxed value through the vtable, but boxing copied the bytes and left v's own drop in place: every ( dyn Speaker d ) of a d owning a String was a heap-use-after-free at scope exit. Boxing is a store now — an owned local moves in (a later use is a use-after-move), a parameter or a field read is copied.
%Trait field is dropped. It got no drop at all, sothe box, the boxed value and every sibling field leaked (docs listed it as a limitation); it gets the field-by-field drop any struct holding a Drop value gets.
%dyn built right in a call's argument is dropped as a whole.
Only the box was freed after the call, leaking what the boxed value owned — and a call inside the operand (( f ( dyn T ( make ) ) )) lost the marker, so not even the box was.
?? arm does to an outer binding reaches the code after the
match.** The arms were walked in isolation with their exit discarded, so ?? x { T v → ( string_free s ) F → ( string_free s ) } followed by ( string_free s ) compiled clean and freed s twice. Arms now start from the match's state with their payload names bound for the arm only.
Freeing a field of a match payload (`?? o { T t → ( string_free . t name ) }) empties it in o; reading o` afterwards read an emptied handle (SEGV). It is a use of a moved value now.
env_cwd manages no memory by hand. Its retry loop freed a raw
buffer on one branch and reused it on another; each attempt's buffer is a ( Vec u ) the scope drops.
others no longer leaks the fresh ones.** Such a function already reported per call whether its caller owns the result (@.__nurl_retdyn). A : binding of a struct, or a ? join of two such calls, ignored that answer whenever the callee was summarised as lending, so the result counted as a borrow everywhere and every fresh value leaked. A store did the same: it copied the value and leaked the original. The signed-in agora leaked its request principal this way, about 150 B per call. The per-call answer now decides in all of these places. A join of two calls yielding an owning struct carries a phi of their answers, as a String or Vec join already did.
A sqlite Database or Statement, or any other dropped handle without a Clone, could be lent in two ways: - read out of a parameter's field (^ . h db); - taken as the payload of ?? . st db { T d → … }.
In both cases it was dropped by the caller or by the arm. That closed the owner's handle, and the owner's next use was a use-after-free, with no diagnostic. Such a value is now lent. A function that lends it on one path and opens a fresh one on another answers per call, like any other. Where a copy would be needed, compilation stops with an error naming the type.
?Database (an option of a value that cannot be copied)is dropped with its struct.** The struct's drop skipped it, and every connection such a field held leaked.
--debug builds at -O2 work again. The memory-model helpers
(__nurl_clone*, __nurl_cloneif*, __dropif*, __nurl_ret_own, …) are printed as literal IR with no debug location. Inlining a function that has a subprogram through one of them crashed clang in DwarfDebug::finalizeModuleInfo on any real program, which made ASan stacks with line numbers unavailable. Under --g, the written module now gives every location-less call a location and every subprogram-less definition an artificial one. tools/dwarf_test.sh asserts this invariant with tools/dwarf_location_census.py.
oauth 0.2.1 verifies access tokens as access tokens. It applied
the ID-token azp rule (azp must equal the audience), so a token that any other client requested was refused. That included one from the Azure CLI. New claims_check_access / oidc_verify_access_token_at.
strnlen to the wasm32 ABI. NURL passes
a 64-bit length and libc takes a 32-bit size_t, so wasm-ld replaced every call with a trapping stub. With this release's runtime, nurlc.wasm trapped on its first string slice.
(nurlc: error: cannot open '<path>' — no such file). Before, the runtime's file reader printed a bare cannot open and exited from inside, past every release the compiler makes.
nurlpkg publish names a registry rate limit and shows the HTTP status
of any other rejection.** A 429 (the registry's per-account publish cap) printed only publish failed (PubRejected); it now says the registry is rate-limiting publishes and to try again later, and other rejections end (PubRejected, HTTP <status>). New PubRateLimited and pkg_publish_last_status in stdlib/ext/pkg_publish.nu.
boundary.** 0.69.1 fixed the registry footer, but the declaration snippets (700 bytes), search hits (500), grep lines (200) and the per-module API cap cut at fixed byte counts too: nurl_api query=http on the live playground still answered invalid UTF-8 (a → cut in half). All use the same boundary-aware cut now (stdlib/ext/mcp_search.nu), and the playground's end-to-end suite fails on any reply that is not valid UTF-8 (it decoded with errors="replace", which hid this).
nurl upgrade to v0.69.1
failed on a GitHub 500 for the release archive that the same URL served moments later; install.sh / install.ps1 now try each fetch three times.
nurlapi/main.nu still called
query_pair_free, which 0.69.0 removed, so the v0.69.0 playground deploy failed at its image build. The gate that frontend-compiles the examples (tools/check_examples.sh, run in CI) now covers nurlapi/ too.
missed it: 638 release calls removed, and the PTT-Chat relay's member record is a self-releasing handle shared by its connection and the registry (its write mutex and channel name had been freed by hand). The end-to-end suite gives the same results before and after, in the container and under AddressSanitizer + LeakSanitizer.
nurl_api / package search replies stay valid UTF-8. The registry
footer cut a package's description at a fixed byte count, which landed inside a multi-byte character (—) for http, so the whole tool reply was invalid UTF-8 and a JSON client rejected it. Cuts now end on a character boundary (stdlib/ext/mcp_search.nu). The playground's end-to-end suite expected a section title docs/MEMORY.md had renamed in 0.67.0; updated.
Memory is never released by hand. The compiler drops every value where its owner's scope ends — Strings, Vecs, options and results, closures, structs holding them, and library handles (Mutex, Channel, Regex, TlsConn, File, …, now self-releasing rcbox handles) — and the standard library, the tools and every package in the repository were rewritten to free nothing explicitly: *_free / *_close remain only as optional early releases. Along the way dozens of ownership defects were fixed at their source in the compiler (leaks, double frees, use-after-free shapes), and new compile errors catch a pointer or closure outliving the value it borrows. See docs/MEMORY.md for the model.
% Drop impl returns, the
compiler drops the fields it manages (String, Vec, library handles, Drop values), so a destructor only releases what the language cannot see. Fields the impl freed by hand, and a value handed to a disposer, are skipped. compiler/tests/drop_impl_semantics.nu.
(nurl_sym_has_word, nurl_sym_word_index): 131 `( str_contains_word ( nurl_sym_get … ) w ) / str_word_index` sites copied a whole list — a scope's drop list, a function's parameter names — to test one word. Self-compile −0.4 % instructions (shipped build).
ownership statically when that call cannot lend** (`: String s ( string_from … ) … ^ s`): it was marked per-call (a runtime flag read at every caller), decided before the callee's summary was final; now settled at module end. bench pq −0.6 %, json_parse −0.4 % instructions.
File, BufReader, Progress, Heartbeat, Zip / ZipArchive,
DChannel, CSVWriter / CSVDictWriter / CSVDictReader release themselves.** Each is a library handle over an rcbox: every copy is the same object, and the last owner releases it — a File's or a writer's last owner closes its file (a File dropped without file_close leaked its FILE*), a Heartbeat's stops and joins its thread. file_close, bufreader_close, csv_writer_close, heartbeat_stop act now and are optional; zip_close, dchan_free, csv_dict_reader_free are early releases. File gained file_raw / file_from_raw (code that read . f raw uses file_raw); Progress gained progress_cur / progress_tty; progress_done no longer frees. Write the types without * (heartbeat_start → Heartbeat, progress_new → Progress, csv_writer_new → CSVWriter).
nat_gather returns ( Vec Candidate ) (values instead of raw
*Candidate blocks); a Ring keeps its points as values (ring_owner / ring_owners still hand out *RingPoint pointers into it).
Drop implementations (core / data formats / compression / filesystems / network stack / dist / package tooling): decoder cursors and scratch states (zstd, deflate, msgpack, CBOR, YAML, regex parser, FAT directory cursor, job / replicator / rendezvous decoders) are locals advanced in place (inout) instead of nurl_alloc'd blocks; queues are drained in place (new bytes_drop_front). The *_free functions of owning values (manifest, lockfile, semver, registry index, URL, path, swim, tar entries, …) are early releases; crc32_ctx_free, tar_entry_free, lock_pkg_free, idxdep_free, idxversion_free, stun_request_free and nat_candidates_free (no callers) are gone. zstd level-3 round trip −22 % instructions, level 19 −2 %, deflate+gzip −5 %, zip −5 %, fmt2 −36 % (fixed-arity fmt* / log_*fN no longer copy their arguments; a suppressed log_*fN formats nothing).
nothing by hand* (940 → 1 release call in `stdlib/ext/http`, mcp*, websocket, mqtt, smtp, xml, serde, cookies, credentials, anthropic; the one kept, in the WebSocket reader, is marked with the compiler finding it waits for). Raw state became library handles over an rcbox: HttpStreamState (was *HttpStreamState; new hp_stream_body accessor; the last owner closes a transport still held, hp_stream_close closes it early), HttpConn (a handle; the last owner closes it, hp_conn_close is idempotent), HttpStream holds the state (http_stream_close optional), H2Client (its 16-word peek/poke state block became fields; h2_client_close an optional early release, h2_client_disconnect closes the TcpConn; new h2_client_tcp), the HTTP/3 stream tables, MCP tasks, and the HTTP server's DoS state (the server's last copy releases it, no longer server_stop). New h2_conn_finish (the final flush h2_conn_free did). Consumed arguments are sink: HTTP/2 client bodies, h2_conn_new_buffered's carry, mcp_server_add_* schemas, MCP task Json. Every *_free of these modules is an optional early release; unused url_split_free, query_pair_free, ws_frame_free, hpack_string_free were removed. HTTP server CPU per request (instructions:u, bench/http_server.nu, oha 100k keep-alive): HTTP/1.1 −3.1 %, HTTP/2 −2.1 %.
sink parameter placed in a literal moves in instead of being
copied.** rcbox_new [T] @ T { a b } in a library-handle constructor copied every Vec / String it was handed and then dropped the original (9 → 5 allocations per construction in compiler/tests/sink_param_into_literal.nu). A literal passed to a call that only reads it leaves the parameter its owner, as for a local.
sink closure parameter takes the closure over. A temporary handed
to one moves in, a binding is handed a copy, and the callee drops what it took unless it returns it or captures it in a closure it returns. The iterator combinators take their source and function by sink: a tiny three-stage pipeline builds with 36 % fewer instructions (it copied the chain at every stage), and iter_free releases early. compiler/tests/sink_closure_param.nu.
Mutex, Cond, Semaphore and Channel release themselves. Each
is now a reference-counted library handle: every copy — a thread's or a fiber's closure capture, a struct field, a Vec element, Mutex_share / Channel_share — is the same object, and the last owner destroys it (a channel with whatever is still queued). mutex_free / cond_free / sem_free / chan_free remain as early releases of one owner. Storing one owned value into two owners is the usual compile error; store a Mutex_share. The handles are one word now (Mutex { s p }); code that reached into Mutex.c uses mutex_raw. compiler/tests/sync_handles_autodrop.nu.
DStore (dchannel) releases itself, its queues with it: an rcbox
handle (the registered handlers' captures share it) whose entries hold rcbox DQ queue handles instead of raw pointers.
McpTaskStore is an rcbox handle whose drop frees its tasks; the server holds a McpTaskStore_share instead of a hand-managed view cell, so the store outlives neither side.
Regex, Rng, Bitset, Arena, Supervisor, CircuitBreaker, the
cluster Registry and process Output release themselves.** Each is a library handle over state in an rcbox (stdlib/core/rcbox.nu: one block, [ owners ][ T ], the last owner drops T — its managed fields and its own % Drop): every copy is the same state, as a copied pointer was, and no one frees it by hand. Their *_free functions remain as early releases. compiler/tests/opaque_handles_autodrop.nu.
Cell releases itself. Its block carries an owner count in front of
the bytes: Cell_share is another owner (a thread's capture, a struct field), and the last owner frees it. cell_free remains as an early release of one owner.
Cell, Bitset, Channel and the sync handles read a count of 1 as "no one else holds this" and share or release without a locked read-modify-write (nurl_rc_share / nurl_rc_release); a loop creating, sharing and dropping handles ran 7–23 % fewer cycles.
Thread releases itself. A handle whose last owner detaches the
thread unless it was joined or detached, and frees it; a discarded ( thread_spawn … ) leaked its pthread_t and never detached. The first thread_join / thread_detach through any copy settles the thread; a second join returns -1 (it was a use after free). @ Thread { # s 0 } is the null handle. compiler/tests/thread_handle_autodrop.nu.
struct whose module defines S_drop sink S x (and S_share / S_clone) is dropped and copied like HashMap.
Sha256 and Blake3 streams release themselves. sha256_init /
blake3_init return a library handle (Sha256 / Blake3, was *Sha256 / *Blake3); sha256_final / blake3_final leave the stream spent instead of freeing it, and the last owner releases it (sha256_free / blake3_free are early releases). Code naming the type drops the *. A SHA-256 stream is now one block of words (state, schedule, partial block and a snapshot area) — 5 allocations per stream instead of 6–7, and sha256_snapshot no longer clones the stream — and hmac_sha256_pure streams key block and message through one hasher instead of building ipad ‖ msg: a SHA-256 / HMAC / HKDF / PBKDF2 mix runs 15 % fewer instructions.
TlsConn releases itself. The TLS client and server entry points
(tls_connect*, tls_attach*, tls_accept*) return a TlsConn library handle (was *TlsConn); every copy is the same connection and its last owner releases it, closing the socket if nobody called tls_close. tls_close / tls_server_close still say close_notify and close the socket at a point of the caller's choosing, but free nothing (safe to repeat). Code that read the connection's fields uses accessors: tls_socket (the runtime socket handle), tls_cert_msg, tls_cv_sig, tls_th_cert (what the verifier was given, lent). Write the type without *; # *TlsConn 0 becomes @ TlsConn { # s 0 }. The handshake machines' transcript hashes, keys and buffers are replaced through inout and go with their owners; std/net.nu's TcpConn keeps one owner of the connection as a word (_tls_word) and tcp_close_conn hands it back.
SecureNode (net/securedgram) releases itself. securedgram_open
returns a SecureNode handle (was *SecureNode); securedgram_close closes the socket and frees nothing, and the last owner releases the keys and peers (closing the socket if nobody did). A partial message is one buffer at its chunks' places instead of a box per chunk. noise_keys_free and recvdata_free are optional early releases.
that binding's own** (`: ~ Rec r ?? ( vec_get rs 0 ) … = . r name ( string_from … )`, or a copy of a by-value parameter): it leaked per call. The field now has an owner flag of its own — dropped with the binding, replaced on the next store, copied when the binding is handed back to a caller that would not own it, and handed to the slot when the binding is written back (= . p k r, or a helper that puts it back), which then drops the value it replaced unless the program took it out first. compiler/tests/fresh_into_borrowed_copy.nu.
error** (= p ( string_data x ) / ( vec_push [s] args ( string_data x ) ) with x, or the vector x reads, dropped at the end of its block): a silent use-after-free before (packages/wasmbuilder's link argv). An owner moved on into a container that outlives the pointer is fine. compiler/tests/diag_pointer_outlives_owner.nu.
copy** (^ ( string_data x ) leaked x per call; ^ ( view_of x ) was a use-after-free). A view of a parameter stays the caller's own. compiler/tests/return_raw_view_of_local.nu.
( mem_dup c ) is a fresh value to the call it is passed to
(( vec_push out ( mem_dup c ) ), c a vec_get payload): the push took it for lent and copied the copy (anomaly meta_clone_versions leaked per fork). compiler/tests/mem_dup_payload_into_push.nu.
from alive** (^ ( sane o ), sane handing back a cursor over its parameter): o is dropped and the copy is published as owned (anomaly prep.nu leaked a String per version patch). compiler/tests/return_lent_call_on_local.nu.
(^ @ Ser { y . kd kind }): kd is dropped with its Vec (anomaly forecast leaked per fit). compiler/tests/number_field_into_returned_literal.nu.
the call says so** (= x ( at p 0 ), at lending a table element on one path): the binding kept its old flag and dropped the table's element (packages/yoloe-demo crashed on every second /detect). compiler/tests/reassign_lent_result.nu.
(^ # ( Vec T ) . e inits_ref), as a library handle already was: every caller released the holder's Vec (packages/onnx — a use-after-free in every model with a Split node). compiler/tests/return_cast_word_vec_is_view.nu.
is lent, not kept** (: P q @ P { p … } ^ . q n): a temporary argument — ( regex_compile ( nurl_argv_get 2 ) ) — was never freed. The parameter is kept once the literal's binding leaves whole (stored, returned, passed on). compiler/tests/raw_param_in_local_literal.nu.
not reported stale** (( vec_push out . op k ) reads op before the push). compiler/tests/stale_borrow_read_in_mutating_call.nu.
the decoded RPC envelope leaked per call in stdlib cluster. compiler/tests/value_arm_recursive_enum.nu.
value on another answers per call** (? short { ^ src } {} ^ ( substr … )): taken for a lender throughout, its fresh results leaked, and a caller returning one over its own local read freed memory. A lent result returned past the local it lends from is copied. compiler/tests/cond_param_return.nu.
ends in another ?? (stdlib noise handshakes leaked per handshake). compiler/tests/nested_arm_numbers_drops_locals.nu.
it still holds it** (: ~ V sk body … ? c { = sk … } {} … ^ @ R { sk } leaked body — stdlib pkey). compiler/tests/cursor_reassigned_returned.nu.
by that binding** (recover \ → v { = resp ( f req ) }): the old value was never dropped and the new one never owned — a response leaked per request in the HTTP keep-alive loop. The env carries the binding's drop flag. compiler/tests/closure_assigns_captured_owner.nu.
sink parameter handed back through a ?? arm moves out
(^ ?? o { T v → v F → dflt } dropped dflt on return and the caller got freed memory — opt_unwrap_or's shape). compiler/tests/sink_param_from_join.nu.
: H h ( mk )
with mk → i reinterpreted the number as the handle's pointer and owned nothing). Now a compile error naming the literal to write. compiler/tests/diag_number_into_handle.nu.
?? arm that matched it drops
the old value** (or hands it to the payload cursor still reading it): ?? cur { T c → { = cur ( next ) } } leaked the old value every iteration (the pooled keep-alive client loop). compiler/tests/option_reassign_in_own_arm.nu.
call no longer makes the function a view** (( flush @ Fh { . w fh } )): every caller took its fresh error String for a borrow and never dropped it (packages/gguf). compiler/tests/call_arg_literal_not_view.nu.
leaves the struct's other fields to be dropped** (^ @ ?V { T . x a } leaked x's other fields once per call — stdlib zstd). compiler/tests/field_into_returned_wrap.nu.
its own, is copied** (= . a f v, a a local), as the literal @ A { v } copies it. Taken over, the caller handed its value in and the local freed it at return while the caller still read it. compiler/tests/field_store_param_copied.nu.
too** (: ~ E e e0 … : V out . e f ( mem_take out )): the payload still dropped the field and the Vec handed on was freed. compiler/tests/take_field_through_cursor.nu.
sink parameter assigned to an inout parameter moves into the
caller's slot** (@ put inout T slot sink T v { = slot v }): it was still dropped at the callee's exit and the caller's slot read freed memory. compiler/tests/sink_param_into_inout.nu.
? arm ending in a closure assignment yields that binding's closure.
? on { = w ( wrap base ) = base w } {}: the arm's value was copied for a join no statement consumes, and the copy leaked on every run (packages/http's middleware layers). compiler/tests/closure_arm_tail_assign.nu.
the call.** ( vec_get [i] ( mk ) 0 ) (a ?i holds no address) and ( copy_of ( string_data ( make ) ) ) (a String the callee builds rather than hands back) kept the temporary for a consumer that never came — it leaked. compiler/tests/temp_args_after_views.nu.
values.** ?? ( lsm_put db ( key_of k ) v ) { … } kept the key for a consumer of the !T E (packages/lsmdb leaked one key per put). compiler/tests/temp_arg_into_result_call.nu. The same for a call returning a struct of plain numbers (( arima_update ( model_at t j ) y ) kept the model's share — packages/anomaly).
?? arm drops the payload it bound.
^ ?? ( read_file p ) { T text → { : ( Vec i ) v ( f text ) v } … }: the arm's drops waited for the join's verdict, and a consumed value kept them all (it might point into an arm-local). An owned value whose type can hold no address cannot, so the arm's locals are dropped under its ownership bit (packages/nurl-cov leaked each source file it read). compiler/tests/value_arm_drops_binding.nu.
^ @ H { # s ( rcbox_new [T] @ T { a b } ) } skipped b's drop: the cast field counted as "the binding it casts" (the last name parsed), and the inner literal's fields counted as the outer one's (packages/vindex). compiler/tests/nested_literal_cast_field.nu.
when a later call on the same line reads it (`: i n ( give a ) ^ + n ( vec_len [i] a )` said "consumed by vec_len"). compiler/tests/borrow_moved_cause_same_line.nu.
: Hold t @ Hold { a } then ( keep t ) then ( vec_len [i] a ) read freed memory and compiled clean; so did a read after = t … replaced the holder, or after a literal went to a call consuming it (`( keep @ Hold { a } )`). Each is now a use-after-move error naming what took the value; reading a while the holder still holds it stays legal. The holder may be the struct a field store wrote into (= . s v a), a holder of the holder, or the name it was handed on to (: Hold u t). compiler/tests/borrow_stored_owner_gone.nu.
^ # H w) is a view
of whatever holds the word, as binding it (: H m # H w) is. A cast from an integer counted as a value made from a number, so every caller released the table's handle (packages/anomaly's forecast model table: a use-after-free). An owner is built with a literal (@ H { # s w }) or a share. compiler/tests/return_cast_word_is_view.nu.
in a literal returned bare. ^ @ ?B { T @ B { shape v } } lent shape back like a whole payload, so the caller took the result for borrowed and the fresh v leaked (packages/tensor). compiler/tests/wrap_nested_param_taken.nu.
drops it.** A closure capturing a parameter its function does not take over only views the caller's value (docs/MEMORY.md §4); returned on past the caller's own local (^ ( first v ), a binding of it, a helper that lends on, an implicit return) it read freed memory with no diagnostic — stdlib's mcp_server_http_dispatch has this shape. Now a compile error that names the value and the cure (sink). compiler/tests/diag_returned_closure_lends_local.nu.
% Drop impl runs wherever its value lives. A Drop type used as a
Vec element or a struct field had its impl replaced by a generated field-by-field drop of the same name, so the program's destructor never ran — anywhere in the program. A struct holding a Drop value was not dropped at all; it is now move-only and dropped field by field. A raw s field of a Drop type was freed by the compiler and by the impl (a double free; nested, invalid IR).
released with the binding that built the struct (a per-binding field list), so a struct moved into a Vec or returned held a dangling env, and the struct's own drop never touched it. Closure fields are now part of the struct's drop and copy (nurl_closure_drop / _clone).
(( keep v @ S { \ → … } )) was released after the call as if it were the call's own temporary argument; the kept S read freed memory. One level down (@ Outer { ( keep v @ S { … } ) 7 }) the inner literal's owned fields were read as paths into Outer (invalid IR).
Vec elements release
their payloads.** Only a : binding of one did (through its %__opt.<T> twin); S { ?String a … }, ( Vec ?T ) and a result field leaked the payload. They are now part of the drop and copy graphs.
vec_clone, vec_extend, vec_extend_range copy what the elements
own** (mem_dup; the same loop as before for plain elements). They copied bitwise, so with an owning element type both Vecs released the same Strings — vec_clone of a Vec of owning structs crashed. vec_append still moves. compiler/tests/wrapped_and_copied_elements.nu.
written.** A strict module's private Impl in rcbox_new [Impl] was rejected inside the generic instance's body, so state structs had to be pub. compiler/tests/private_type_argument.nu.
they hold.** A sink ?T parameter was never dropped (the literal option type had no drop; bindings register under the %__opt.<T> twin and now parameters do too), and returning its payload was taken for a lend. A ?? / ? arm written as a block ending in a literal yielded a borrow (only an arm that IS a literal counted as owned) — the value leaked. A % Drop impl on a one-field struct never ran its drop glue (no slot for the receiver). A requested drop__Vec__T could be skipped (marked done without being emitted). A non-owning option (?i) handed to a sink no longer reads as moved. opt_unwrap_or consumes both arguments, so the unused default is released (it leaked when the option was present). compiler/tests/owned_params_and_arm_tails.nu.
( attach srv m \ → v {} )
keeps m, but the keep was stashed before the closure argument was compiled and the closure body (its own function) drained it into its own statement list, so freeing m afterwards — a double free — compiled clean. The suggested fix in that diagnostic is now ( mem_dup x ). compiler/tests/borrow_keep_beside_closure_arg.nu.
^ no longer makes the result an
alias of it.** ^ did not clear the join channel that : and = clear, so = . c max ? > max 0 max 64 … ^ @ S { … } read as "may return argument 0" and the caller's binding never dropped the value. compiler/tests/return_after_param_ternary.nu.
option releases the payload only when present, so `@ ?S { F @ S { ( string_new ) … } }` leaked whatever the payload owned (unless someone read a None's payload and freed it by hand — parse_basic_auth's early exits needed exactly that). compiler/tests/option_none_payload.nu.
= . p f ( … ) releases the value the field held when the binding
owns its struct: a String / Vec / handle / closure field leaked on reassignment. compiler/tests/struct_field_ownership.nu.
@ S { . h p } inside S's
own module (a share reading the pointer out of the handle it was given) was classified as a view of the parameter, so S_share's result was copied again when stored and one reference leaked. arc_clone escaped only because it spelled the read through a local.
! T E with an Err path like F # E Closed hands its Ok payload
over.** The cast reads the variant's global, which the "builds a view of something else" rule took for a pointer read: every such function counted as returning a view, and every Ok payload leaked. Only a pointer field makes a view now. compiler/tests/result_err_variant_owns_ok.nu.
toml_get / toml_get_path shape). A field taken out of a cursor over a bound vec_get element (`: ?E e ( vec_get … ) ?? e { T ev → ^ @ ?V { T . ev value } }`) counted as moved out of an owned struct, so callers dropped the table's own value; a returned option now owns its payload exactly when the cursor did (answered per call), and another literal copies what was lent. An auto-dropped enum parameter's registration no longer reads as ownership unless the parameter is a sink. Both had been hidden by the view rule above. compiler/tests/lend_through_enum_param.nu.
defines that symbol for the whole program (how the unikernel supplies nurl_tcp_*), so a program's own @ round i x i q → i beside std/float.nu's & m @ round f x → f replaced libm's round for float_round too, and the first error was an arity complaint inside std/float.nu. Now the definition (or the declaration, whichever comes second) is reported with both signatures. compiler/tests/diag_ffi_defined_other_sig.nu.
= c ( prune c ), prune handing back a cursor over its parameter, made c a borrower of its own value, which then had no owner (_h2_prune_closed). A field taken out of a slot copy that is replaced and written back (take_data) is still the caller's: only a payload of an option binding answers per call whether it held its field. compiler/tests/lend_back_to_same_binding.nu.
inout parameter releases what it replaces.
= . h item … and = s ( string_from … ) through an inout parameter left the caller's old value with no owner — every call leaked it; `= s t` from a local also left t dropping the value under the caller (a use after free): t's value now moves over. A field the callee hands to a consumer first, or takes with mem_take, is emptied in the caller's struct, so the store after it releases nothing twice; mem_take of a binding that has since been given another value leaves the field in place. compiler/tests/inout_replaces_owned.nu.
ResolveConflict … ^ @ !( Vec LockPkg ) ResolveErr { F failure }` read the variant like a global, so the binding borrowed it and resolve_registry counted as lending on every path — no caller dropped its lock list. compiler/tests/unit_variant_local_owns.nu.
returns.** The snapshot borrows the env's value; a fresh String / Vec assigned over it (a discarded write, already warned about) leaked on every call. compiler/tests/closure_snapshot_assign_released.nu.
down polled waitpid 50 times back to back, so a child that did not exit at once was SIGKILLed within microseconds; the polls are now 10 ms apart.
done with.** : String url … per iteration captured by `thread_spawn → v { … url … }` was dropped at the end of the iteration while the thread still read it — a use after free unless the body released the capture by hand. An owning String / Vec / handle binding the rest of the function never names again (and that no loop around the closure captures again) now moves into the env; one still named later stays shared, as before. thread_spawn_owned is checked as a detaching spawn (Send) too. compiler/tests/thread_closure_takes_dead_captures.nu.
( vec_push v ? c ( string_from a ) ( string_from b ) ) — an argument or a literal field built from a ? / ?? join was always taken for a lend: the keeper got a copy and the original leaked on every call. The join's per-arm ownership now decides. compiler/tests/join_into_keeper_moves.nu.
( use @ P { ( string_from x ) 1 } ), ( api @ ?Json { T a } ): the literal's fresh fields and copies of borrowed ones had no owner once the call returned — every call leaked them. They are dropped after the call unless the callee keeps or consumes the argument; a field that IS a binding's value stays that binding's. compiler/tests/literal_args_release.nu.
value.** ?? ( vec_get v 0 ) { T l → { ^ ( string_from … ) } … } left the scrutinee's borrow on the "last value" channel, and the implicit- return rule (for a body that falls off its end) marked the function a lender though no path fell off — callers never dropped its results. compiler/tests/returns_inside_borrowed_match.nu.
A payload of a call's ?T (borrowed when the callee lent it) returned as ^ @ ?T { T v } was copied always while the function counted as a lender; the copy leaked. Now the caller owns the result exactly when the function did. compiler/tests/wrap_of_maybe_lent_payload.nu.
--lint reports release calls the memory model made redundant
([redundant-free], docs/MEMORY.md "Temporaries, and the release calls you no longer write"). ( string_free x ), ( vec_free [T] x ), ( json_free j ) … at the tail of a block — only more such calls after it, then ^ or the } of the block that declared x — on a local the compiler drops at that same point. Only a pure release qualifies (one sink parameter and an empty body; a hand-written destructor that also frees what its fields point to does not), and only a value the scope provably owns (not a parameter, alias or capture, and not a value from a call that may hand back something it keeps). The editor shows it through the LSP. tools/fix_redundant_free.py FILE… deletes every reported call. string_free's body is now empty, like vec_free's.
compiler/tests/run_san_tests.sh defaults to detect_leaks=1 (use_stacks=0; LSAN_DETECT_LEAKS=0 opts out), and CI's corpus step replaced the curated leak-pinned list: every test must now be leak-clean, the compiler's own compile of it included (docs/MEMORY.md §6.6).
compiler does itself: 569 removed with the new lint (515 stdlib, 54 examples), no behaviour change.
manually-managed handle that is never released - Vec and String are not auto-dropped" and "this allocation is owned by nothing". Both asked for exactly the manual frees above.
recover. The compilercomputes which functions can run inside a recover extent and drops the journal calls everywhere else; handle-slot registrations live on their own stack. Self-compile 10.84e9 → 8.34e9 instructions; the test suite runs in 2m28 instead of 2m50.
extent** (docs/MEMORY.md §7.2). The recover-frame chain and the panic journal were per thread; fibers interleaving on a worker stacked their extents, and a panic drained the other fiber's live values and longjmped into its frame from the wrong stack (a segfault — one HTTP handler panicking while another connection's handler waits on the same worker). Each fiber now carries its own, on the M:N runtime and on the unikernel/nolibc scheduler alike.
LTO-inlined runtime accessors had the thread pointer loaded once at a function's entry and reused after a yield, so a fiber resumed on another worker used the old thread's journal and return-ownership channel (a double free in a panic drain). Runtime functions that touch thread-local state are kept out of line, and the hot ones use a single-instruction TLS access on x86_64 / aarch64. This costs about 8% more instructions per request in the HTTP hello-world benchmark; winning it back is planned.
spawned just as it went idle (a runtime_init 1 program slept forever), and runtime_shutdown destroyed a worker's run-queue lock while another worker could still steal from it (the join never returned).
zstd_decode returns a buffer the caller owns. It handed back a fieldof its hand-managed decoder state as read — a borrow — so a caller that let the result go out of scope (the memory model's normal way) leaked the whole decompressed output on every call. The zstd gate now checks the exact live-allocation count across round trips; its resident-size reading, which one retained window buffer could swing by megabytes on a CI runner, is reported but no longer judged.
copy when two threads build them at once; the winner is published through the runtime's publish-once slots.
?? ( rw ( sh ) ),
( string_len ( fid ( sh ) ) )) is owned per call site instead of leaking; ^ ( fa t ) returning a field of the local t no longer returns a dangling handle; named-argument calls drop their handle temporaries.
binding whose value came from a call that answers ownership per call (the compiler's own gen_ret leaked this way); an owned raw string placed in a returned struct literal; and a parameter a function keeps on one path and returns inside a wrapped struct on another.
f5tts
0.2.0 and the first release of nurl-cov 0.1.0 (#1145).
String, Vec and the structs that hold them are dropped by the
compiler** (docs/MEMORY.md §7.6). A program no longer needs string_free / vec_free: every binding, parameter and temporary that owns one releases it exactly once, tracked by a drop flag. Storing a value into a struct, a field or a container moves it; a borrowed value stored into an owner is copied; a : cur root alias is a cursor that borrows; a fresh ? T / ! T E payload is owned by the arm that binds it. vec_free / string_free remain as an explicit early release, and ( mem_forget x ) hands a value over for good (a table kept in a global). See docs/LIMITATIONS.md "Ownership" for the shapes that still borrow.
one by hand** (docs/MEMORY.md §7.5). Until now a closure that left the frame that created it — returned, stored into a struct field, spawned onto a fiber or a thread, captured by another closure — took its heap env with it and left releasing it to whoever ended up holding it, which in practice meant a hand-written ( nurl_free # s # *u f 1 ) or a leak. Now:
- a function returning a closure hands its caller an env the caller owns, on every return path (a borrowed one is copied on the way out); - a struct field, a slice of closures, another closure's captures and a ? / ?? join own the closures stored into them and drop them with their holder; - spawn, thread_spawn, signal_install and sqlite_set_authorizer keep their own copy of the closure and drop it when done, so the spawner's closure can be reused or go out of scope immediately (the _owned spawn variants are now the same as the plain ones); - a closure temporary passed to a call is dropped right after the call, and a discarded one at the end of its statement — whatever the callee does with it, because a callee that keeps a closure keeps a copy; - an env records its size and how to drop and copy the closures it captured, so dropping an env releases the whole tree.
Migration: a hand-written free of a closure's env is now a compile error when the closure owns it or is a parameter — delete the call. recover no longer frees its argument's env. Heap structures that store closures behind raw pointers release them with nurl_closure_drop instead of nurl_free.
% Drop values are dropped exactly once, however they move
(docs/MEMORY.md §7.6). : T b a over a Drop value used to drop it twice (a double free); a sink parameter could not take a Drop value at all; = a ( make … ) leaked the value it replaced. Every auto-dropped binding now carries a drop flag that a move clears on its own path, a sink parameter owns and drops what it is given, and the function a Drop impl delegates to (its disposer) is recognised so it never drops its parameter a second time.
call or literal (= s ( make … )) now drops the fields it replaces; they leaked before, for string fields as much as closure ones.
model v1.0** (docs/MEMORY.md §7.6). vec_free, vec_clear and vec_set drop the elements they release or replace; vec_append moves one Vec's elements onto another (the bitwise vec_extend is only for elements that own nothing). An enum whose payloads own memory — Json, TomlValue — is dropped, copied and moved like a String. A generic struct whose module defines S_drop (and S_clone for a deep copy or S_share for a shared one) is a library handle, owned like a Vec: HashMap, Set, Deque, BTree, Box, Rc and Arc are dropped when their binding goes out of scope. An element consumed through vec_get, a foreach binding or a field read through a pointer is emptied in its slot, so the container's drop skips it.
Migration: vec_extend followed by vec_free of a Vec whose elements own memory now frees them twice — use vec_append. A loop that frees each element and then the Vec keeps working.
( vec_push v s ) ( string_free s ) used to compile and free s twice (once by hand, once when v dropped it); the checker now reports the second release. Reading the value after the store is still fine; to hand a value on and keep one, store a copy.
String, Vec, owning struct or libraryhandle drops it**, through the same drop flag its scope exit uses. The journal entry costs nothing in a function no panic can reach: the module writer computes which functions may panic over the finished IR and leaves the others out.
mid-module are folded when the module is written, and a drop flag that is constant for a whole function disappears with its loads and stores; the in-build checks run in parallel. ./build.sh 56 → 39 s; the self-compile 11.8e9 → 10.0e9 instructions.
( mem_dup x ) — an owned copy of any value (deep for a String, Vec,
handle enum, library handle or owning struct); ( mem_take x ) — a value read out of a container by hand owns it from here on; ( mem_put_back x ) — the next store of x through a pointer is a write-back, neither copied nor taken over. vec_append and vec_truncate.
template package at 96.3% line coverage using
nurl-cov and retains its HTML report for seven days, including when coverage falls below the floor.
packages/nurl-cov 0.1.0 — a test-coverage mapper for NURL. The
toolchain has been able to produce coverage data since 0.53.0, but reading it meant llvm-cov, and nothing turned it into an answer about a package's test suite. nurl-cov does both: it builds every tests/*.nu with the compiler's GCOV instrumentation, runs them, merges what each one leaves behind, and reports line, branch and function coverage per source file — as a table, the uncovered line ranges, an LCOV tracefile, one self-contained HTML page, JSON, or a --fail-under gate. The .gcno and .gcda readers are pure NURL; nothing outside the toolchain is needed.
A coverage report is a claim nobody can check by hand, so it is checked against an independent implementation: nurl-cov gcov prints the annotated listing in gcov's own format, and the package's test suite diffs it byte for byte against llvm-cov gcov -b -c -p over programs from the compiler's own test corpus — every line count, every branch outcome, every percentage.
Two things it took getting wrong to learn. **A line is not the sum of its blocks**: a condition and the two arms it guards all carry the same source line, and adding them reports a line running three times as often as its function was entered. The count is the traffic entering the line's blocks from outside them, plus what goes round in circles inside them — the second half is what makes a one-line loop report its iterations rather than its single entry. And **an equivalent solver is not the same solver**: an iterative fixed point over gcov's equations agrees on ordinary functions and disagrees on anything that forks or exits abnormally, where it surfaces as a percentage quietly a few points wrong. nurl-cov implements gcov's own edge propagation, synthetic exit-to-entry arc included.
0.65.0's sink hardening were republished with patch bumps (#1134); seven of them again because their hand-written --version literal had not moved with the manifest.
nurlbox 0.2.1: the shell no longer imports the program's entry point,so the package can be published.
f5tts 0.2.0: the quality gate retries and splits lines the way thereference service does, and short lines get the duration they need.
nurl-cov 0.1.0 (above).after this release, against it.
sweep of every package under the new drop rules found legacy spellings that double-freed or read freed memory; each is now given the meaning the code was written for (docs/MEMORY.md §7.6, "Memory managed by hand"). A parameter stored only through a pointer is a view the callee never drops, on any path (arima's job batches freed their own list); a field of such a Vec element or pointer struct handed to that view is left in place (a fit's series was emptied after the first job read it); an owned local's field stored through a pointer is copied, a parameter's field is taken over (grad's tape freed every value it recorded); a binding over a local's field stored through a pointer takes that field over (anomaly's trained autoencoder was freed with its training result); freeing each element's field through a vec_get payload empties it in the slot; a global handle cast back is lent; a parameter handed back inside a struct inside ? T is not dropped on that path when another path releases it (tensor_reshape); and a closure body that frees a capture itself takes it over at creation (a thread read its capture after the spawning loop dropped it). Storing a struct of plain words (a device buffer's address and length) is no longer reported as a second release, nor is ( nurl_free # s # *u . t host ) as freeing t.
nurlpkg publish no longer fails when a file vanishes mid-walk. The
packer lists a directory and then reads each name; a file removed in between (an editor's swap file, a second publish staging beside it) was reported as PackReadFailed about 5 % of the time under concurrent writes. A file that is gone was never part of the package and is skipped; one that is present and unreadable is still fatal.
package (NURLBOX_VERSION) wherever it is printed; nurlbox's bump had passed it unchecked.
-O0. A simd clone built for AVX2 passed
<4 x i64> values to baseline helpers in ymm registers while the helpers read them from memory; -O2 inlined the helpers and hid it. Functions with a vector signature are now always inlined. Every nurlpkg test compiles at -O0, so pki-server's post-quantum CA could not verify its own certificates there.
packages/nurl-cov is bounded by the file it is reading, not by thenumbers inside it.** A fuzz sweep over truncations and byte flips of a real coverage pair found a hang and twelve segfaults, all the same mistake: a count taken from the file and used without a bound. One flipped byte named source line 1970155382, and because the per-line tables are indexed by line number the reader sized a table from it; another turned a string's word count into 738 million and the read walked that far past the buffer; a third named a block the function does not have, and an arc pointing outside the block table left the walk that solves the flow unable to mark where it had been, so it looped. String spans are now bounded by their own record, a line number past sixteen million is a malformed file, block numbers are checked at parse time as gcov checks them, and a function's own checksums are checked against the notes — the check that catches a notes file corrupted after its build stamp was written. 645 cases per seed over five seeds, no crash and no hang, and the package's test suite runs a deterministic sweep of its own.
args_values — a repeated option's earlier values were recorded and
unreachable.** std/args already kept the whole history of a value option (val_idx / val_str are append-only, and the header said so), and args_value answered with the last one — right for --output, wrong for an option that means "again": --include a --include b is two filters, not a correction of the first. args_count would say 2 and nothing could reach the first value. The accessor now exists; the storage always did.
--no-dce and --keep=a,b to nurlc.
Both flags existed in the compiler and neither could be reached through nurl.sh or nurl.bat: an unrecognised flag was read as the name of the source file. For most builds that is an inconvenience. For a coverage build it is a wrong answer, because dead-code elimination removes functions nothing calls — exactly the code a coverage report exists to find — so the report omitted the gap instead of showing it as never executed. On a four-function sample the same suite scored 88.9% without the flag and an honest 72.7% with it. The omission did not look like a missing flag; it looked like a good score. docs/BUILDING.md now says so where the coverage recipe is.
text-to-speech model, running from the checkpoints Hugging Face ships, with no Python, no PyTorch and no ONNX export: a 22-block diffusion transformer with adaLN-zero modulation, the conditional-flow-matching sampler with classifier-free guidance and sway sampling, and the vocos vocoder. Verified against the reference implementation with fixed inputs at every stage — the text front-end id for id over 3039 lines, the text encoder at 2.6e-6, the whole forward at 2.3e-6, 32 guided ODE steps at 7.4e-6, the waveform at 2.9e-4. f5tts serve answers POST /tts and POST /dialogue with a wav, an mp3 or raw PCM, switches models per request, and gives the card back after --unload-after idle seconds. An utterance the reference takes three minutes over on one machine's CPU takes 1.6 s here.
at all nine sample rates the format defines, mono or stereo, constant bitrate, in pure NURL: the polyphase analysis filterbank, the 18-point MDCT with its alias-reduction butterflies, the quantiser's step-size search, and Huffman coding over the big-values regions and the count1 quadruples. audio mp3 in.wav out.mp3 --bitrate 128, or mp3_encode from any package. There is no psychoacoustic model, so there are no scalefactors and every band gets the same step size — the shape of the fixed-point encoder shine, and enough for speech. The test walks every frame header and checks that they tile the file exactly, at all nine rates in both channel configurations, then decodes one back with ffmpeg and compares waveforms.
output_format: "mp3". The synthesis service
encodes the mp3 itself rather than shelling out, so a request that asks for one gets audio/mpeg instead of a 400; mp3_bitrate picks the rate (128 kbit/s by default). f5tts synth -o out.mp3 does the same from the command line, chosen by the file name. ogg is still refused by name: there is no Vorbis encoder in this ecosystem yet.
--model and --vocoder are now
required and the built-in catalogue of "known" checkpoints is gone: a speech model is a choice about a language, a voice and a licence, and none of those are a library's to make. A name is a path, a directory under ~/.f5tts/models, or a owner/repo/path/to/file reference the hub fetches once; the vocabulary is vocab.txt beside the checkpoint unless --vocab says otherwise. The web interface is English throughout and starts with an empty text box, the reference-dump scripts take their paths from the environment, and --lang now actually reaches the transcriber (it was collected and never sent) as a multipart field, defaulting to letting it detect one.
fft_irfft and fft_irfft_plan (std/fft.nu), rng_normal
(std/rng.nu).** A vocoder's head predicts a magnitude and a phase per frame and bin, not a waveform, so the inverse real transform is the step that makes it audio; rng_normal is Box–Muller over the existing generator, for a diffusion model's starting noise.
istft_center, wav_encode, and the HTK mel. The
inverse short-time transform divides by the overlap-added window envelope, which is what stops a steady tone coming back amplitude-modulated at the frame rate; log_mel_vocos is the vocos-family mel (HTK scale, unnormalised triangles, magnitude rather than power) beside whisper's.
posix_const knows MAP_POPULATE and MADV_WILLNEED. A loader that
mmaps a multi-gigabyte model file can ask the kernel to map every page in one pass (MAP_POPULATE, Linux) or to read a range ahead (MADV_WILLNEED) instead of taking one page fault at a time. Both come back as -1 where the platform lacks them, so a caller ORs them in only when they are known — the mapping is merely lazier without them.
Channels, mail, a task board and shared notes over one SQLite file, served as an MCP server and a REST API from one operation catalog. brief hands an agent everything new exactly once (a cursor per agent and channel, moved in the transaction that reads it); wait blocks until something arrives, so waiting for another agent costs no tokens; notes take a project= namespace and a 0.1.0 file is migrated on open.
whisper serve --unload-after S and embed serve --unload-after S.
The model becomes something the server holds rather than something it is: a reaper closes it after S idle seconds with nothing in flight and the next request reloads it. Idle, whisper gives back 4.8 GB of device memory for large-v3 and keeps ~60 MB of process. /health reports loads, unloads, last_load_ms and idle_s, and says idle — healthy — for the unloaded state. Default 0 keeps the old behaviour.
gpu_upload_batch and a staging pair gpu_close frees (gpu 0.13.0).Loading large-v3 is 3x faster and keeps no host copy of the weights.
touch.** Without core isolation and CPU time every cell measured the server and the generator together, and req/s and latency both move when the generator pushes harder — enough to invent a peer difference that does not exist. It had: a remembered 20.0 µs/request against a peer's 13.9 was two confounds stacked.
; defer body nested in a ? arm, a ?? arm, a ~ body or a foreach
body is borrow-checked.** A defer body is replayed at function exit against the ownership holding there, and only sites the exit state showed as definitely armed were replayed — but a ? join weakens the arming to maybe, a ?? arm discards it, and a loop drops it as loop-local, so those bodies were never analysed at all. A double free or use-after-move among the body's own bindings compiled clean and corrupted the heap. Arming stays conditional; the body does not, so any site the exit replay did not reach is now swept from an empty state — the ??-arm rule — tracking only bindings the body itself declares. Found by the weekly inverse-oracle fuzzer (FUZZ_GEN=reject), 12 of 400 seeds.
was superlinear in the size of the import closure — 71.7 ms/kline at 56k lines against 28.7 at 4k — in two spellings of one defect on the parked implication lists: an append that copied the whole accumulated list three times per record, and a replay that walked it word by word.
std/hash_sha3 absorbs whole lanes without a signed divide per lane.
pos is always in [0, rate) but is typed i, and NURL's / and % are signed, so dividing by a power of two lowered to lea/test/cmov/and/sub — about ten instructions per 8-byte lane where the sponge needs two. 8 MB one-shot absorb: 346 MB/s to ~365, instructions −7.1 %.
pipe with the console's OEM code page, so nurlc's diagnostics and the test programs' output arrived mojibake'd and were compared against correct UTF-8 goldens. The compiler's stdout is the .ll handed to clang, so the round trip corrupted every non-ASCII string literal in the compiled program, not only the diagnostics.
HttpRequest per connection. The keep-alive path allocatesnothing per request on the request side — −0.9 µs/req, −3.7 %, and parity with the Rust peer at 14 B.
nurlpkg compares dependency path links after normalising them.
../gpu and ../gpukit/../gpu are the same directory; comparing them by spelling refused the link and blocked nurlpkg test.
gk_close drops the process-global pool's record of the device itclosed** (gpukit 0.8.1). Reopening after a close handed back buffers belonging to a context that no longer existed. A program that opens a kit once never reached it; one that closes and reopens — a service releasing the card while idle — reached it on the second load.
pk_free takes a sink, from the ownership hardeningin 0.65.0. Four packages were republished for that at the time and this one was missed, so the registry had been serving 0.1.1 with different source ever since.
republished**, because whisper and embed could not pass the publish gate: their path dependencies differed from the registry by exactly the sink keyword added in 0.65.0. Three modules also leaned on the program's import order rather than their own $ lines, which the gate's per-module typecheck caught.
-g keeps it. The emptyprogram linked to 1,126,353 bytes of which 1,121,271 were debug info — 99.6 % of an artefact nobody had asked to debug. Stripped it is 3,889.
package's own decoder bounded no length prefix and knew four wire types out of six, so a group tag advanced the cursor by nothing and a malformed model was decoded rather than rejected.
packages/gRPC client and server with unary, client streaming,server streaming and bidirectional calls, TLS/ALPN, deadlines, cancellation, metadata, rich status details and bounded identity/gzip message codecs. Interoperability fixtures exercise the official gRPC runtime and independent HTTP/2 peers, including concurrent streams, malformed messages and cleanup.
trailers. Serialized frame queues preserve partial writes and service reads while writes are blocked, allowing simultaneous large requests and responses.
combined read/write readiness across native, fiber, WASI and freestanding networking providers.
package.nurl-version minimum toolchain in manifests. Packageinstallation validates local and authenticated registry manifests against the selected compiler before linking or extracting incompatible dependencies.
fs_tempdir creates an exclusive temporary directory, with private POSIXpermissions and an owned path for explicit cleanup.
queues: queued writes no longer consume unrelated incoming frames or truncate responses when send windows are exhausted. Explicit server ENABLE_PUSH=0 is accepted as required by RFC 9113.
malformed Huffman trees and bounded expansion. HTTP clients propagate corrupt or oversized decoding failures instead of returning encoded bytes as success.
allowing use before deferred release while detecting duplicate or late frees. Try-expression diagnostics retain the operator's source location.
calls, including conditional and forward calls. Typed pointer elements and named pointee fields can be passed directly to inout parameters with exact pointee-type checking and single evaluation of the index.
installed toolchain, including libraries without main.nu. Reinstalling existing path links rechecks their transitive dependencies.
including root and nested source modules, and rejects missing or yanked versions, unreadable sources and failed downloads or signatures. Manifest read failures return typed errors without terminating the caller.
path and refuses existing directories that would substitute different local dependency contents; registry CLI controls now enforce leak detection.
parsing and SemVer numeric/range overflow checks prevent wrapped values from bypassing toolchain requirements.
invocation snapshots its source files so concurrent saves cannot change a module between signature scanning, emission and diagnostics.
responses in the same ordered ciphertext queue as application data.
atomically publish golden records, including concurrent runs of the same test.
private temporary directories. A matching output golden cannot hide a nonzero test exit, and failed runs include the program's diagnostic output.
preserves the previous destination on failure on POSIX and Windows; installed tools are copied and made executable before replacing the live binary.
that allocated a buffer, including arguments, bindings and returned values.
names such as open and strlen to coexist with libc. Explicit C linkage, retained external roots and library-module ABI remain stable.
on failure, including coverage notes and macOS debug bundles. Coverage emits real line/branch counters with imported-source attribution; saved LLVM IR remains usable after the driver's temporary staging directory is removed.
without expanding literal path characters. Driver flags, installed shims and default batch lookup on PATH work through the same argument contract. Explicit shell execution uses the system cmd.exe; debug executable publication preserves its PDB alongside the binary.
10×10 streams: 213k → 136k req/s on the bench box) with the bounded frame writer the incremental APIs above introduced: it flushed after every queued frame — two sends per response instead of the one sendmsg 0.64.0 issued — and copied each frame three times (serialize, prepare, append) before it reached the socket. Frames are now sealed straight onto the writer's queue (header via a scratch buffer, payload read in place) and the queue is flushed once per read attempt, so every frame produced while handling one batch of peer frames leaves in one send: 0.1 sends per request at ten streams, h2c 213k → 275k req/s and TLS h2 165k → 226k, p99 4.5 → 0.9 ms — ahead of 0.64.0, not merely level with it. A refused queue (no room) now marks the writer starved, and the control event that invites the application to refill is raised only when a flush frees room it was denied. GOAWAY still flushes itself; h2_conn_free flushes what a backed-up socket had refused, best effort.
dirty unconditionally, so a pool-served HTTP/2 connection — which brackets every frame read with set/restore — paid two setsockopt() per blocking read. The idle timeout is re-pushed only when a deadline remainder actually replaced it in the kernel.
stdlib/ext/protobuf.nu. Typed scalar
readers/writers preserve all 64 bits, with bounded borrowed submessages, packed fields, validated UTF-8 strings, unknown fields and matching groups. Failed operations preserve cursors/output; lengths, tags, varints and nesting are checked. Differential and sanitizer tests cover official protobuf interoperability and malformed input. ONNX adopts it after the next toolchain release; see docs/stdlib/protobuf.md.
packages/anomaly 0.33.0 — a data source can be a file, and a model can
require agreement.** kind: "csv" fetches a URL and reads the answer as a file: a header row, then one row per record. It is not a parser of its own but the reader the import route and analyze_data already use, so a file that imports cleanly fetches cleanly — same delimiter sniffing, quoted fields, missing-value rules and cell typing. The clock is a column, named or detected; a file with no readable stamp is a snapshot of the moment it was fetched, and a rolling feed that republishes the same window (the USGS earthquake summaries) can be polled as often as you like, since each run keeps only rows newer than the newest already stored. Separately, votes is a model setting beside the versions it counts. One — the default, and every model that has never touched it — keeps the rule this package has always had: any enabled version flagging is enough, because a single reading at ten sigma is an anomaly whether or not the forests concur. Above one the versions must agree, and the number decides everywhere the aggregate is used: a detect's answer, the ring scan's stored verdict, calibration's count, fine-tune's target. Fine-tune understands the rule — at one vote rate stays each version's own share; above one it becomes the model's share and the knob is shared, every tunable version placed at the same quantile of its own scores by bisection (sound because the consensus count is monotone in the quantile). Measured over seven versions and 1500 rows asking for 5 %: at two votes each version sits at 3.9 % and the model flags 4.6 %; at five votes each sits at 14.6 % and the model flags 5.0 %. Both numbers come back, a consensus the window cannot reach is said out loud rather than approximated in silence, and votes past the number of enabled versions is refused when it is set.
packages/anomaly 0.32.0 — every version answers for its own column, and
every answer says what it is worth.** The flatline guard learns each column instead of the model. Scoring a run of identical readings against one model-wide window made two things impossible at once: a column whose own reference run passed half that window could never reach the margin at all (the run is counted over the window, so its fraction is capped below 1), and every other column needed 54 identical rows in a 60-row window before it counted — a coarse temperature and the smooth flow beside it cannot both be served by one number. The reference is read per column now, run / max(2·reference, 20 rows), and the look-back grows so no watched column has an unreachable bar. The reference also no longer learns the fault: it was the longest run in training, so one freeze inside the ring taught the guard that freezing was normal. Every maximal run now votes min(length, 1 % of the ring) times and the reference is the 0.9 quantile of that, so a column's reference is set by what recurs and one stretch cannot outvote the rest of the ring however long it lasts. analyze_data's separation was one ratio at the target rate, so it could only see a fault smaller than 1 % of the file; it is now the largest step anywhere in the sorted scores, with the block size that produced it. train_forecast measures its own margin instead of assuming four of the model's standard errors — which a backtest routinely finds cover 75 % of what follows, not 95 — forecast_backtest reads its own numbers per feature and gives the model a verdict, and season says how many features' fits actually use it. A version name the model does not have is refused instead of silently created with default geometry, a margin moved by an edit reaches the audit log the tool promises, and what the config sanity rules changed is reported rather than applied in silence. describe_model and edit_model answer with one shape, wall-clock times are ISO-8601 in every tool, a window reports both bounds, by_version counts the zeros, false_positive needs a verdict to dispute, an impossible min_votes is refused rather than answered "0 anomalies", and import_data says why the margins were or were not set and takes the same rate knob fork_model has. Errors carry a next step. analyze_data and import_data take a file the organisation's folder already holds, list_models is an orientation rather than four thousand tokens of column names, and forecast is readable on a wide model.
packages/anomaly 0.31.0 — a reading that cannot be a measurement is
flagged, stored, and left out of every fit.** One reading of 1e200 used to make its feature stop being watched: 0.29.0 stopped it crashing the service, but the scaler's std became 9e198, every real reading standardised to nought, and the feature was blind until the reading left the ring — fourteen weeks at a minute's step and the default cap. The point is now flagged HARDER (the range guard sees it against a scale it did not move) and marked absent for fitting only, so the scaler, the forests, the flatline reference, the autoencoder and the forecast all skip it through the "absent reading" path 0.29.0 already built. Judged against the feature's own median and MAD, which one reading cannot move. The metadata reports what was left out, per feature. Also: a forecast fit whose innovation variance is not finite is no longer "converged" (`inf > 0` is true, so the finiteness had to be asked for), a feature the forecast cannot score is absent rather than 0.0 (which read as "it landed exactly on the forecast"), and the forecast z-score is capped like every other standardised value so an extreme reading gives a number instead of null.
inout calls use the correct ABI. Thesignature prepass records parameter positions before bodies are compiled, removing the definition-order restriction while retaining mutability and exact parameter-type checks.
of \ no longer triggers a false dead-value warning. Discarded closure literals still warn, including closures containing a try expression.
Borrow analysis represents a terminated path separately from a live ownership state and excludes it from joins. Conditional and match arm boundaries cover bare returns as well as blocks. Strict checking now accepts release-and-return error paths while retaining diagnostics for live conditional and loop-carried double frees. Adjacent bare return arms no longer trigger the XOR warning.
Stable binding identities preserve pointer-to-integer origins through casts, assignments, loop backedges and forward calls. A worklist closes conditional call dependencies without a round or parameter-count cap. Named and positional string temporaries share the final ownership decision, including forward results passed directly as arguments. Root buffers and embedded references remain distinct, and lifted closures have separate parameter domains.
registration sequences across deletion and compaction; pointer-indexed removal avoids scanning the live allocation set on each free. Reassigned owned strings/slices and known caller-owned argument temporaries retain their unwind registrations. Compiler diagnostics use ordinary journalled recovery and common cleanup; the internal nurl_recover_nojournal bypass has been removed. Forward string ownership remains under investigation.
src/main.nu can no longer publish when the target toolchain or compiler is absent. Compiler launch failures and compilation failures retain their actual cause instead of blaming a missing stdlib API. The compiler is invoked with literal arguments and --check, removing shell interpretation of toolchain paths. Both publication and dry-run stop before upload on these failures.
!RegIndex RegistryFetchErr. HTTP/transport errors and invalid indexes abort resolution instead of masquerading as missing packages and silently selecting an older version. Genuine HTTP 404 remains retryable. CLI failures include the requested index URL and preserve existing lock/manifest files.
declare their consuming positions with sink; function-name suffixes and return types no longer imply ownership. Custom destructors that release fields or raw memory must declare their consuming parameters. Inference reaches a fixed point across forward calls and generic instances at every argument position. Local callbacks no longer inherit same-named global ownership contracts.
Caller and callee owner slots account for conditional consumption and return transfer. Named payload construction, matching and dropping use the same storage classification, including nested tag-only enums and multi-field pointer-leading structs. Forward payload layouts are available before ownership decisions, and drop definitions follow type emission.
Valid diamonds, cycles and fallback versions no longer fail because a greedy pass selected an incompatible latest version. Explicit decision and constraint stacks remove the 256-round cutoff. Conflict causes let the solver skip unrelated choices, including redundant wildcard edges. An indexed candidate heap and cached indexes, parsed versions and requirements avoid whole-graph rescans. Selection is deterministic under root, index and dependency ordering. Invalid root names/ranges report ResolveBadPackage/ResolveBadRequirement; malformed version metadata and duplicate versions report ResolveBadIndex. ResolveUnstable is retired. CI compares finite graphs against an independent exhaustive oracle and exercises the resulting signed installation.
Explicit dependency registries now select their own indexes and archives, including transitive dependencies. Per-registry user key configuration replaces the global cross-registry override; missing keys/signatures, changed bytes and mismatched archive manifests fail closed. Index shape and full text lengths are checked before projecting metadata; embedded NUL cannot truncate identities, requirements, checksums or trust config. Lock refresh retains origins/checksums, escapes strings and rejects version drift. Failed installs preserve the prior lock and no longer print success. Equal-name source collisions are reported before archive downloads until the installation layout supports coexistence. Signed tests exercise real CLI failures and five installed ecosystem programs; CI runs both suites.
releases source slices while preserving the borrowed EOF sentinel. Every CLI path frees arguments, source and output, and the reusable formatter can run repeatedly without leaking. --check validates stdin without emitting source; incompatible/unknown flags, unreadable input and embedded NUL bytes return errors. Canonical files are not rewritten.
read_file, read_file_bytes and the new fallible read_stdin preserve actual byte lengths, grow geometrically and distinguish read errors from EOF. Interrupted reads retain their prefix and retry. Buffered stdin stays coherent after read_line; infallible convenience readers panic on failure. The previous mmap/seek/reopen fallbacks are removed; regular-file sizes are capacity hints. Streaming chunk reads also free buffers on error.
configurable, installed companion binaries and stdlib are discovered, and execution failures are visible. The new compiler --stdin mode preserves the logical filename and unsaved source across imports and deferred borrow diagnostics; --check discards generated IR without emitting output files. The LSP no longer writes shared temporary source files. Its index reads open buffers, imported definitions use escaped file URIs, and diagnostics and formatter ranges use UTF-16 positions. Relocated-toolchain protocol tests cover concurrent servers, missing tools and real source errors.
runtime reads stdin, regular files and pipes through one checked reader; only regular-file sizes are allocation hints. It no longer trusts a failed seek, exposes unread bytes after a short read, or allocates from a directory's meaningless end offset. Sanitized controls cover growth boundaries and read failures.
now emits ASan attributes through one function-writing path, shared by normal, library and split output. The driver, all bootstrap stages, corpus and fuzz harnesses request it. Deliberate memory faults and valid controls calibrate detection in CI; documentation distinguishes ASan coverage from C-only UBSan checks and the remaining lexical stack-lifetime gap.
Two consecutive cleanup conditions could read the stream after the first freed it; the instrumented client/server regression exposed the use-after-free.
emitters use borrowed integer counters instead of heap cells. Name mangling and lexer lookahead return uniformly owned strings, removing a lexer-specific ownership exception. The compiler leak gate now exercises both paths in addition to the self-compile.
round-trip and package-manager failures retain the build log and return failure. Tool builders share the driver's sanitizer/link configuration and remove stale executables before rebuilding. Isolated full-build fault controls run in CI.
Imported helper fixtures now declare their intent, so parser rejection tests without main run. Both POSIX runners and the Windows runner bound compiler invocations, distinguish rejection from crashes/tool failures, and require one recognized verdict per selected test. Sanitized rejection tests fail if the compiler accepts them; failed workers cannot disappear from a green summary. Fault-injection controls cover the runner itself.
Associated-type checks and default-method registration now run after the whole program's signature scan, before body or vtable emission. An impl preceding its trait (including a later nested import) gets the same defaults and binding checks as one following it. Deferred diagnostics retain the source snapshot and point at the impl; repeated imports remain idempotent.
Defaults previously registered only their return type and arity: an inout default passed a value where the callee expected an address and could crash, while incompatible argument types could bypass the dispatched-call check. Both paths now register parameter types, inout and sink conventions before callers are emitted, including forward calls and non-receiver parameters.
Import replay previously identified a method by file and line only, allowing distinct conflicting definitions on one line to evade the duplicate check. The identity now includes the source column.
for emitted function bodies instead of assuming every successful compile produces at least 100 KB of IR; sanitizer findings still fail unconditionally.
Z NoSuchType was
the one position with no such check and emitted a getelementptr over a type nothing declares; %Trait in a type position never checked the trait exists, because the object-safety check runs only once the trait is known and the declared-type check skipped %dyn.<Trait> outright; and an impl's subject was unchecked, so % NotSend Db { } on a misspelled subject asserted a danger about nothing while the real type kept crossing the boundary. A generic struct applied to the wrong number of type arguments is now counted as a generic function call has been for years: too few left a type parameter unsubstituted in the emitted type, too many mangled the definition and every reference differently, so the reference named a type nothing defines.
a wrong arity, wrong non-receiver parameter types and a wrong return type all compiled. The last is a type confusion rather than a cosmetic mismatch: dyn builds its thunk from the declared signature, so a trait promising → i implemented with → s handed the caller a pointer to read as an integer. The comparison substitutes Self and the associated types and then compares lowered types, which is what makes inout receivers, associated-type returns and type aliases compare correctly. An impl of an undeclared trait is still accepted, because the built-in protocol traits (Drop, Ord, Show, the marker traits) have no declaration.
\ requires a return type that can carry the failure. The error-type
check beside it has compared error types for years, but only when the enclosing function returns a result; otherwise there was nothing to compare and the fail path fell through to zeroinitializer. → i returned 0, indistinguishable from a real answer, and → s returned a null pointer that printing dereferenced. An option tried inside a !T E function is rejected too: both shapes start { i1,, so the check accepted either and then zeroed the other one, returning an Err with a payload no callee produced. stdlib/core/result.nu had already written the rule in prose — at a site that cannot \-propagate, use res_expect / res_unwrap.
match on ?T / !T E is rejected. One rule, three
scrutinee kinds, and only the user-enum spelling was enforced, because exhaustiveness looked variants up in a table only a user enum has. The option and result spellings yielded undef.
... marker
is now required to be last and to appear once: a parameter after it emitted declare i64 @xpf(i8*, ..., i64) and a second marker (i8*, ..., ...), both of which llvm-as rejects. A leading marker was worse — the first parameter overwrote the accumulated declaration string and destroyed the marker there while the call-site signature kept it, so the module declared a non-variadic symbol and called it variadically. That one exits 0, clang accepts it, and it is a real ABI difference on every target that passes variadic arguments differently. A variadic call's fixed prefix is counted as well; ... makes the tail optional, never the parameters ahead of it, and a missing fixed argument read an unset ABI register. An empty FFI library name now fails its sentinel gate instead of skipping it and turning a missing dev package back into a link-time surprise.
carried the taxonomy for years and refuses a name that is neither a binding, a global nor a parameter; call generation had no such guard. The dangerous spelling is a constant: ( MAX ) emitted call i64 @MAX() against @MAX = global i64 10, which clang accepts because the global has an address, and which jumps into the constant — a clean compile, a clean link and a segfault. The "is this binding callable" test was also too loose, since it said yes to any struct-shaped type and so let a slice or option binding shadow a function of the same name; it now asks whether the struct's first field is a function pointer at the struct's own nesting depth.
inout. The
positional-fill path never compared a default against its parameter, so @ show f x = 1 → v called as ( show ) emitted an integer argument against a double parameter and the callee printed 0; one helper now answers for all three spellings. A default on an inout parameter compiled and put the literal in the callee's pointer slot — a clean compile, a clean link and a segfault — and is rejected at the declaration like the grammar's other three cases.
break and continue inside a ; defer body are rejected. The chain
runs during return, after the loop has exited, so the jump branched into the loop exit, which fell straight back into the chain with the armed flag still set: ; { break } inside a loop exited 0 and printed its epilogue forever. ^ was already rejected there; these are the other two block terminators.
name ever was, with a comment explaining exactly this miscompile — an unknown name emits a load of a global nothing defines — so Red | Nope checked Red and emitted the load for the other.
# between two named struct types is rejected, and so is an aggregatevalue bound to a pointer binding. The final fallthrough in cast generation handed the operand back wearing the target's type, which is a reinterpret, not a cast.
spellings of the same arity trap: a binding whose initialiser terminated left a basic block whose last instruction is a store, because the dead-block parking runs between statements and the binding was the block's last; an operand that produces no value emitted an empty one (%r8 = icmp eq i64 3,), the third spelling of "no value" after the v type and a void call's undef; and a foreach whose body ends in break left the returned flag set, so the enclosing loop believed its own body had terminated and emitted two labels back to back. LLVM rejects each, with no NURL source location. The last needs no mutation to reach — it is ordinary code that nothing in 1,815 tracked files happened to be written as.
said so for years; the five index-store paths never asked, so = . xs 1.5 7 emitted getelementptr i64, i64* %p, double 1.5. An option or result literal's tag is checked as well: field 0 is the tag, five diagnostics covered the ways a payload can disagree with its declared type and the tag had none, so a missing tag shifted every value one slot left.
pub on a $ import is a diagnostic rather than read and discarded in
silence, so a file whose only pub sat on its import no longer stays out of strict visibility without a word; the alias slot takes a plain identifier, as the grammar says. A trait method header with no return arrow is rejected at the declaration, keeping the ASCII-arrow hint. A select arm's diagnostics name the real file and line instead of generated text (<select>:4:127), which is the cure <dynsig> already had.
of the file.** A trait body whose } is missing let the method-header scan advance to "the first {", which is the next declaration's brace, while the emit pass skips balanced braces and ate everything after it: the two passes disagreed about where the trait ended, main was inside the difference, and the compiler exited 0 with nothing on stderr. A generic template with a trait bound and a missing { did the same. Both scans now stop at the → and consume exactly the return type, and a trait or impl body rejects any token that is neither a method nor an associated type instead of skipping it.
nurlpkg's package version-string gate resolves a constant. anomaly
0.32.0 shipped reporting 0.31.0: the number lives in one constant that the manifest bump did not reach, and every gate rule read the call site, which in that spelling names the constant rather than the number. The gate also had a way to be silently right — under pipefail, a grep -q that matches exits at once, the writer upstream takes SIGPIPE, the pipeline reports failure, and || continue then skipped the check that had just succeeded. Both call-site rules use a here-string now.
tools/get-nurl.sh used to delete the
toolchain and let tar write over the hole, so any failure after that point left a prefix with no compiler. It now unpacks into a staging directory, checks it, and swaps.
eighty-seven windows-tests runs were red on one line, and every re-run was green. The cause was the test writing bytes the server will never read: it flooded 1100 RST_STREAMs at a ceiling of 1000, the server stops reading the instant the ceiling trips, and closing a socket over unread data is a TCP reset rather than a FIN. Linux still hands the application the bytes queued ahead of that reset, so the client read its GOAWAY anyway; Winsock discards them. Both flood tests now drive the counter to the ceiling and exactly one frame past it, prove with a PING ACK that the ceiling did not trip early, require a real GOAWAY with the right error code, and assert a clean EOF after it, so a future overshoot fails on Linux too. Every exit names its own step. The two ceilings lose an underscore (_h2_max_resets, _h2_max_header_block_bytes) so the tests can read them from the stdlib and cannot drift from it.
compiler's 311 die/warn sites some test makes it print was a by-hand tool, which is how fourteen diagnostics added in one hardening round reached main with nothing printing any of them. Sixteen fixtures now pin their wording (coverage 80% → 84%, never-fired 32 → 18; the one gap left is unreachable behind its own lookahead and is baselined as such), and the gate went from three and a half minutes of one process per file to thirty-four seconds under xargs -P, which is what lets both gates join the compiler job. That job's budget goes from 15 to 20 minutes: it measured 13m28s before the gates joined, and a run cancelled at the cap reads as infrastructure trouble rather than as a gate saying no.
bytes ship inside the published toolchain archive, the wasmtime and rustup fetches two curl | bash benchmark runtimes use, and the unikernel job's cloud-hypervisor. tools/check_pinned_downloads.py keeps it that way in CI. Only ci.yml runs on a pull request, so the pinning in release.yml, fuzz.yml and the seven bench workflows is verified by that gate and by inspection rather than by having run.
nurl-lang.org is served from webdocs/. The landing page was
replaced and the old nurlweb/ deployment retired; the site redeploys when the project version changes.
stdlib/ext/sqlite: Database and Statement are % NotSend /
% NotSync.** s spells both a String and every opaque C pointer, so a sqlite3* derived as Send and crossed a thread boundary without a word — docs/LIMITATIONS.md names it as the example of the gap and prescribes exactly this. SQLite's rule is one connection per thread; the markers make that the compiler's rule, and a struct holding a handle, a Vec of them or a closure capturing one are all refused at the boundary.
packages/anomaly 0.30.0 — a model belongs to an organisation, and an
organisation is a database.** Every model used to be a directory under one flat root shared by every tenant, with ownership recorded on the side, so a model NAME was global: the first organisation to use boiler took it and the second got a 403 that disclosed the name exists. Metadata, the ring of raw points, the forests, the autoencoder, the forecast, labels and the audit trail now live in <root>/orgs/<org>.db, the same SQLite file that already held the members and the keys. Names are per organisation; a name another tenant uses answers 404. Evicting the oldest point at the cap is one range delete instead of a rewrite of the whole log (15 ms at 14 700 points, linear in the cap), and the point, the eviction and the counter that says how many points there are go in as one transaction. The store holds no connection — one per operation, closed with it — so several threads may use one store over WAL with BEGIN IMMEDIATE writes; a new suite runs four of them at once. A leftover flat store migrates on the first run of any command and its directories are moved aside, not deleted. anomaly --org <id> picks which organisation the CLI works in.
ext/xml: the parser leaked a placeholder node per element. A
child loop ends on a </ that __xml_parse_node answers with an empty node (two Strings, two Vecs) and ok = 0; the element parser read the flag and dropped the node. ~2 KB per WFS response in anomaly's source scheduler, forever. The placeholder is freed where the flag is read.
packages/anomaly 0.29.0, packages/iforest 0.1.3 — one absurd
reading no longer takes the service down.** A point of 1e200 once overflowed the scaler's sum of squares to infinity, the persisted std became a JSON null, the metadata no longer parsed, the model reopened EMPTY over it and the old forests walked a point of no columns off address zero. Fixed at every link: the scaler's arithmetic cannot overflow on finite input and leaves absent readings out; a reading that is not a finite number is refused with its name; a metadata file that does not parse is set aside as metadata.json.corrupt-<time> and said so, never overwritten; a forest whose width is not the metadata's is not loaded; iforest_score judges a point of the wrong length as 0.5 instead of reading past it; standardised values are capped at a million sigma so every score serialises as a number. A column the point leaves out is now scored at its training mean — the value no version blames — instead of a raw 0 that the range guard blamed as a value nobody sent. A patch key the service does not read (schedule.forecast, a typo in a version field) is refused with its name at every level instead of vanishing.
packages/arima 0.4.0 — a linear drift among the regressors
(arima_fit_regress / arima_auto_regress with trend). packages/anomaly 0.28.0 — drift candidates and simplest-first selection, an exact forecast that is wrong scores as an infinite surprise, a point may carry its own timestamp on /detect and /forecast, the forecast from any stored row (?origin=), the visualiser zooms by drag and forecasts from a slider-chosen row over what followed.
packages/anomaly 0.27.0 — the forecast verdict has a floor, andfeatures that are not readings are not forecast.** A reading is judged against the larger of the forecast's standard error and 2 % of the feature's own spread; flags, counters and signals the model reproduces exactly are skipped with their reason.
packages/anomaly 0.26.0 — an agent's findings, fixed at the root.
Margins someone set stay set (a fork inherits tuned_at; the first-train calibration never overwrites a hand-set margin); an audit log of margin changes with actor and action (GET …/audit, MCP audit); fine-tune warns when the scores' ties keep it from the rate asked and never writes a margin of 0; a source moved to another model starts its span over; backtest cells with nothing to compare are null; season: -1 is no season; train_forecast names the features it skipped and why; the persistence forecast is a selection candidate. packages/anomaly/CHANGELOG.md.
packages/anomaly 0.25.0 — each feature's forecast form is chosen ona holdout** among a plain ARIMA, the seasonal polynomial, Fourier terms with two to six harmonics and the week added; the metadata records the form, its holdout error and the skill against the naive forecast.
packages/arima 0.3.0 — Fourier terms for long and several seasons
(arima_fit_harmonic, arima_auto_harmonic, arima_restart_at): K harmonics per period by least squares, the ARIMA on the residuals, the terms riding with the model's updates, forecasts and JSON. packages/anomaly 0.24.0 routes a season past 168 rows to them and adds the week as Fourier terms when the fit window holds three — the fix for a minute's step whose 1 440-row SARIMA state ate the service.
packages/anomaly 0.23.0 — proper forecasts. POST /forecast/<m>
stores a point and answers with its verdict and the forecast from it (intervals, times), fitting the forecast version on first use; the backtest route, CLI command and MCP tool measure the forecasts against naive and seasonal-naive baselines (MAE, MAPE, coverage, skill); MCP forecast_point; the visualiser draws the forecast bands. packages/anomaly/CHANGELOG.md.
packages/anomaly 0.22.0 — the first train calibrates; nothing from
the future.** A model fed by a data source or an imported file is fine-tuned to 1 % of its ring (a source's finetune_rate, the import route's ?finetune=) the first time it trains, its forecast season set from the points' step, and left alone after; a feature type's or an http source's window stops at the fetch time (allow_future opens it) and honours history_hours (now a week by default); a second date column is not taken as a feature; only credential headers are masked; the preview marks constant and sparse columns; the MCP catalogue is compact and filterable. packages/anomaly/CHANGELOG.md.
packages/anomaly 0.21.0 — data sources through MCP. An agent
acting for an administrator adds a data source (create_source: a WFS stored query or feature type, or a URL answering JSON, with its kind and every setting the record takes), changes any field (update_source), removes one (delete_source, confirmed), fetches now or backfills (run_source), and browses a service's catalogue and a query's columns first (source_catalog, source_preview); every member reads them (sources, source, header values masked). The tools call the HTTP routes in process under the caller's principal, so the rights are the API's own.
packages/anomaly 0.20.0 — the forecast version: a seasonal ARIMA
per feature.** The forests see a point as a whole and the guards one reading at a time; a temperature that reads an ordinary trough value at the top of its daily cycle passed every one of them. The new forecast version fits one SARIMA per numeric feature at each retrain (the arima package's stepwise order search, on the machine's threads), keeps each model's state current point by point, and judges every reading by how many standard errors of its own forecast it landed from it, naming the feature; a missing reading is a gap, not a zero. States persist with the ring position they stand at and are caught up from the stored rows when a request opens the model. Off by default; the CLI (train-fc, forecast), the HTTP routes (POST /train/forecast/<m>, GET /models/dynamic/<m>/forecast), the dashboard's Forecast section and the MCP tools train_forecast / forecast — under the autoencoder's rights — fit and read it. packages/anomaly/CHANGELOG.md.
stdlib/std/float.nu: float_nan and float_inf. The stdlib could
test for NaN and infinity but not make them; a stream of doubles needs "no value", and ( bits_to_f64 0x7ff8… ) is not how anyone should spell it.
packages/arima 0.2.0 — the exact likelihood by the Chandrasekhar
recursions.** With a time-invariant model started at its stationary covariance the filter's covariance increment has rank one and its own recursion, so a step costs O(r) instead of O(r²) and the start needs only the first column of the covariance: a weekly-season fit went from 10 s to 0.34 s and the order search on a daily season from 42 s to 0.5 s, the innovations and variances the covariance filter's (pinned by test). The search screens by CSS past 150 points or a season over 12 (R's rule) with every candidate conditioned on the largest order in play — R's own-order conditioning let the higher orders win on the points they drop — and always refits the winner exactly. The full model's streaming step freezes its gain once the covariance has converged (O(r) per update, persisted so a reload steps on bit for bit); arima_update with NaN is a gap; arima_restart and arima_clone for replays and scans.
packages/arima 0.1.0 — seasonal ARIMA forecasting: exact, fast,
streaming.** SARIMA(p,d,q)(P,D,Q)_s estimated CSS-ML — exact Gaussian maximum likelihood by the Kalman filter with the model's own stationary covariance as the start, solved in closed form — matching statsmodels to 1e-5 on eight recorded fixtures and fitting 10 000 points of ARMA(2,1) in 16 ms where statsmodels takes 460. Forecasts and one-step updates run on the full state-space model, so a live detector can keep a model current point by point and judge each point by its innovation. The optimizer is a request/absorb state machine, so K fits share one batch of likelihoods per round — on the machine's threads, or on a CUDA device bit-identically. Stepwise order selection, standard errors, bit-exact JSON persistence, a CLI. packages/arima/README.md.
stdlib/ext/json.nu: json_float writes null for a NaN or an
infinity.** It wrote nan, which no parser accepts: one model's NaN standard error made the anomaly service's model listing an invalid document and the dashboard could open nothing. JSON has no such number; null is what JavaScript's JSON.stringify writes. compiler/tests/json_float_nonfinite.nu.
diagnosed at the use.** : Later x … above : Later { … } passed the type check (the pre-scan knows every type name) and failed later with no source location — clang's "Cannot allocate unsized type %Later" from the .ll, or, when the body read a field, "type 'Later' has no field 'a'" pointing at the access rather than the cause. The binding, parameter, return and global sites now say which type is declared later and where to move it; a pointer to a later type stays fine. compiler/tests/diag_forward_value_type.nu.
packages/gpu 0.11.3: the striped upload copy leaked its closures.
__gpu_par_memcpy spawned its stripe threads with the borrowing thread_spawn and never freed the closures' environments — three blocks per large upload, found by LeakSanitizer while sanitizing arima. The stripes are thread_spawn_owned now, and a spawn that fails runs the stripe inline and frees the env by hand. The rule, for the record: a closure handed to thread_spawn is borrowed, and the spawner frees its env after the join (( nurl_free # s # *u f 1 )) — or spawns it owned.
packages/anomaly 0.14.0 → 0.19.0 — data sources. An administrator
points the service at something that publishes data and the server fetches it on a schedule into a model: an OGC WFS 2.0 endpoint's stored queries (the Finnish Meteorological Institute) or feature types (a GeoServer's or MapServer's — Helsinki, Väylä, SYKE, DWD), or any URL answering JSON, polled with the headers it needs. The service's catalogue is browsed and picked in the dashboard, the answer previewed, the columns tapped as features — as numbers, or as categories so a place or a station is an identity the anomaly is judged against — and each run asks only for what it has not seen. Before that, 0.14–0.16 brought a range guard, events, labels and votes, GPU scoring bound to the calling thread, CSV/JSONL export and a dashboard polish; packages/anomaly/CHANGELOG.md has the full text. pqc 0.2.2 and swarm 0.2.1 were republished after the print-family rule.
stdlib/ext/xml.nu: a comment before a closing tag ended the element
inside the comment.** The child loop checked for </ before skipping comments, and when the node parser skipped one and reported "no node" the scan position was not advanced; the > then found was the comment's own, so the element closed there and every later sibling was handed to the parent's parent and lost. A MapServer GetCapabilities (comments after empty <ows:Voice/> elements) parsed to a root with one child. Regression in compiler/tests/xml_basic.nu.
stdlib/std/tls.nu: the ALPN a TLS 1.2 ServerHello selects was never
read.** ALPN was taken only from EncryptedExtensions (TLS 1.3); on the 1.2 fallback alpn_sel stayed empty, so packages/http-client spoke HTTP/1.1 to a load balancer that had chosen h2 (avoinapi.vaylapilvi.fi) and every request to it failed. __sh_alpn reads extension 0x0010 from the ServerHello; the selection must be one we offered, as in 1.3.
packages/oauth 0.1.0 — identify the user, read the claims. An
OpenID Connect relying party and a resource-server guard, with no C anywhere in the chain: the TLS is std/tls.nu, the JSON is ext/json.nu, and the RSA, ECDSA, Ed25519 and SHA-2 that decide whether a token is real are std/rsa.nu, std/ecdsa_p256.nu, std/ed25519.nu and std/hash_sha*.nu.
The client half runs the authorization-code flow with PKCE (S256 only), OIDC discovery over RFC 8414 metadata with the document's issuer cross-checked against the one that was asked for, the token endpoint (authorization_code, refresh_token, client_credentials; client_secret_post or client_secret_basic) and UserInfo.
The verifying half is the part that decides who someone is. It fetches the provider's JWKS, picks the key the token's kid/alg names, and re-fetches on an unseen kid so a key rotation needs no restart — rate-limited, so a flood of forged kids cannot be aimed at the provider through us. The signature is checked in pure NURL: RS256/RS384/RS512, PS256, ES256/ES384, EdDSA, and HS256 only for a deliberately configured shared secret. Then the claims — iss, aud with azp, exp/nbf/iat with leeway, nonce, sub and max_age — each named when it fails. alg: none and an unrecognised crit are refused, not ignored.
The result is an OidcIdentity: subject, issuer, email, name, picture and the full claim set. On the server side with_oidc_bearer / with_oidc_scope wrap an HttpApp route, so a handler is entered only for a caller the package could name. Everything else — no token, an expired one, one from another issuer, one minted for another audience, one signed with a key the provider never published, one that says alg: none — is a 401 with an RFC 6750 challenge naming which of those it was, while a caller who is who they say but lacks the scope gets 403 insufficient_scope, because that is a different answer. What the challenge quotes back is sanitized first: part of that text is copied from the token, and a kid carrying a CR LF is response splitting, not a diagnostic.
tests/provider.nu is a signing test provider — it mints a real ES256 token and every deliberately broken one, and its authorization code carries an optional subject (c.<challenge>.<nonce>[.<sub>]) so a relying party with roles can exercise its second role and not only its first. packages/anomaly's sign-in is built on this package, and its authflow_test.sh drives the whole path over a socket against that provider.
packages/anomaly 0.8.0 → 0.13.0 — the detector grew an identity, a
tenant, and an API for agents.** Six releases; the package's own CHANGELOG.md has the full text.
0.8.0 — the joint detector stops being muted. Every forest version's decision_margin is an absolute offset on a decision_function whose scale sklearn fixes, so 0.06 travels between models. The autoencoder's score is reconstruction_threshold − mse, and MSE has no fixed scale — it is the mean squared error of MinMax-scaled features and lands wherever the data puts it (~1e-3 on one model here, ~2e-4 on another). The Python reference applies its absolute rule to the autoencoder anyway: model_training.py sets is_anomaly = mse > reconstruction_threshold inside the autoencoder branch and then, thirty lines later at the same indentation as the branch itself and so unconditionally, overwrites it with the margin rule. Against a threshold of ~5e-4 that demands a reconstruction error a hundred times the p95 of the training errors. The one version that models the joint distribution was, in practice, off — and the reference ships it "enabled": False, which is why nobody hit it. We had ported the arithmetic faithfully, bug included. The margin is now a fraction of that model's reconstruction threshold (mse >= threshold · (1 + margin)), so the stored default 0.05 reads "5 % above the p95 training error" — the documented intent — and no migration is needed: the value that was mute becomes the value that works. Measured on a 32-feature home-sensor model, on points whose every reading is real but whose clock was rotated 12 h so that only the relations are wrong, the autoencoder went from 1/150 to 150/150; the forests, which cannot see relations at all, stayed at 15/150 either way.
**0.9.0/0.9.1 — sign-in, organizations, roles, ownership and API keys** (src/authz.nu). The service was single-user by construction: every route reached every model in one flat store. It now has an identity, a tenant and an owner, without moving a single stored model — and it is off by default, so a deployment upgrades the binary first and turns authentication on when its provider is configured, not at the same instant. An organization is an OIDC tenant with one SQLite database of its own (<store>/orgs/<org>.db), so the org is implicit in the file and no query can forget an org column. Two roles: an admin manages the organization's models, users and keys, a viewer reads. The first subject to authenticate from an organization becomes its admin — nobody else could have granted it — and the last admin cannot be demoted. A model belongs to the organization rather than to a person, so a colleague leaving does not take a production model with them. API keys carry an ingest capability of their own, off the role axis entirely: a producer credential can feed the models its organization already has and can do nothing else, because one that could delete a production model is a hazard and one that has to be an admin to send a number is a blunt instrument. DELETE /api/me is the right to be forgotten, and it deletes an organization's models before its database — a crash between the two leaves models nobody claims, which an admin can adopt, while the reverse leaves a database pointing at models that are gone. Multi-tenant sign-in reads the issuer template a multi-tenant authority publishes (https://login.microsoftonline.com/{tenantid}/v2.0 — there is nothing to pin, and oidc_discover correctly refuses it) and verifies each token against that template with its own tid substituted; both claims sit inside the same signature, so a token cannot be moved between tenants. Verification throughout is packages/oauth.
Three bugs the new tests found. **An admin of one organization could see every organization's models** — the tenancy layer worked, and then the listing treated admin as "no filter", which in a single-tenant deployment is the same thing and in a multi-tenant one is every other tenant's data. An admin's world is now the set its organization has claimed, never the store's directory listing. **The read/write split was applied by a positional pass over the file** and the order was not the one assumed, so force_train was a READ and anomalies a WRITE — it is keyed on the handler's name now, which is a mistake a name-keyed table cannot make. And **allow_create short-circuited the role check**, so an ingest key could invent models; the flag says a missing model may be brought into being here, not that anyone may bring it.
0.9.1 then closed a trap on the exact deployment path the README recommends. public is the organization that credential-less points land in while the migration window is open, and on a fresh deployment that window is open before anybody has signed in — so the first producer to send a point created orgs/public.db and the home marker, "the first organization this store ever created", was written to public. The marker is written once and never revised, adoption is reserved to the home organization, and public is not an organization anyone can sign in to: one anonymous point, and the store was unadministrable. public is now never home, and the home organization can adopt a model public holds.
0.10.0 — margins you can read. `GET /models/dynamic/<m>/calibration` says, per version, what the current margin flags over a window of stored data and which margin would flag 0.1 / 0.5 / 1 / 2 / 5 / 10 %, plus a 110-point rate→margin curve so a dashboard can estimate the alert rate of a typed margin without a round trip. Fine-tuning now sets a rate instead of following one outlier: the old rule moved the margin until the ring's most anomalous point was just inside the band and wrote eight-decimal values with no more meaning than their first two digits. severity (`−score / margin`, per version and as the aggregate maximum) is the one unit-free number an operator — or an agent — can compare across versions and models. Fixed: the retrain loop treated the autoencoder version config as a forest and stored a zero-tree version_autoencoder.forest, which the loader then scored as a second "autoencoder" verdict reading the AE's relative margin as an absolute one.
0.11.0/0.11.1 — the importer finds the time. It reads the columns before a row lands: a column whose values parse as a stamp under any name (ISO 8601 / RFC 3339, Postgres and MySQL TIMESTAMP and TIMESTAMPTZ, compact YYYYMMDDTHHMMSS, Unix s/ms/µs/ns, a bare date), or year / month / day plus clock parts under English or Finnish names — an FMI export (Vuosi, Kuukausi, Päivä, Aika) imports as is. ?inspect=1 returns the proposal with a confidence and a sample without creating anything. Unstamped rows make a count-clock model: the n-th point is #n and every window is a number of points. No time is ever invented. On the dashboard a contribution now carries the value the point had and the one the net reconstructed from the other features, charts zoom by dragging, and a click on a point or a row opens the stored record in full. Fixed: the service served one connection at a time and kept it alive, so a browser's second parallel request waited out the whole 5 s idle timeout — every page load stalled — and string query parameters were taken verbatim, so a feature name with a space or a non-ASCII character (every FMI column) could not be asked for from a browser at all.
0.12.0 — analyse a file in one call. POST /api/analyze takes a CSV / JSON / JSONL body, trains a throwaway model on it — every forest version over the whole file, the autoencoder as 64-16-64, every margin fine-tuned to a 1 % alert rate — and answers with the anomalies: index, stamp, score, which versions flagged it, its top contributors with value and expectation, and the full record. A call that has not finished within wait= seconds becomes a task (GET /api/org/tasks[/<id>]) rather than holding the response open, and the result file lands in the organization's folder with a signed download_url — HMAC'd against a per-store secret and bound to the organization, the file and the expiry — that anyone holding it can fetch. Analyses run in a child process, so a crash in one cannot take the service down and the service's GPU and models stay untouched, and the service now runs four worker threads behind one service lock so a waiting analysis no longer holds up live /detect traffic.
0.13.0 — an MCP endpoint for agents. POST /mcp (Streamable HTTP, JSON-RPC) is the whole API for a language model, authenticated exactly like the REST routes and acting with that user's rights: a viewer's client sees the read tools, an administrator's the whole surface, and a tool the caller may not use is not listed and is "unknown" when called — an agent is never shown a door it cannot open. A client with no token gets the RFC 9728 challenge and GET /.well-known/oauth-protected-resource[/mcp] names the authorization server, so Claude Code, the MCP inspector and any spec-following agent discover the login on their own. Twenty-six tools, described for an agent and sized for a context window; times are ISO-8601 UTC, numbers are rounded to what a reader can use, and every error says what to do instead. Resetting and deleting a model take confirm: true, and there is no tool that creates or revokes an API key: a secret must not land in a model's context. **Scratch models** (llm_…) let every member — a viewer too — create, train, tune and delete a model of their own, so an agent can test a hypothesis on a slice of history without touching a production model or needing an administrator; the rule lives in the authorization gate, so the REST routes and the MCP tools agree by construction. Fixed: a viewer's model listing was empty, because it filtered by ownership while az_may_see grants every member of the organization read access to its models.
ext/mcp_server.nu
gains a caller context. mcp_server_dispatch_as r req ctx and mcp_server_envelope_as are the plain dispatch/envelope with one more argument: a Json the HOST built from what it authenticated (principal, role, organization — whatever its tools need) and the client never gets to assert. Every handler reads it through mcp_call_context c (borrowed; JSON null under the plain dispatch), so a tool learns the authenticated principal the way it learns its arguments — through the McpCall accessor, never a new parameter. mcp_server_add_tool_gated registers a tool whose EXISTENCE depends on that context: a visible predicate evaluated per dispatch decides whether the tool appears in tools/list, and a tools/call naming an invisible tool gets the same "unknown tool" envelope an unregistered name gets — a caller who may not use a tool is not told it exists. One server, built once, serves an admin the full surface and a viewer the read-only half; the per-request work is authenticating and building ctx, not registering tools. Tests: mcp_server_gated.
ext/mcp_auth.nu — OAuth resource-server plumbing for an HTTP MCP
server.** The MCP Authorization spec makes such a server an OAuth 2.1 resource server: it verifies tokens and tells a client that has none where to get one. The two protocol-shaped halves of that live here — RFC 9728 Protected Resource Metadata (mcp_auth_metadata_path, mcp_auth_metadata_url, mcp_auth_resource_metadata, mcp_auth_metadata_response) and the 401 challenge that points at it (mcp_auth_challenge → `WWW-Authenticate: Bearer resource_metadata="…"), plus mcp_auth_base_url` (the public origin, honouring X-Forwarded-Proto/Host behind a proxy) and mcp_auth_bearer_token. Token VERIFICATION stays the server's own business (packages/oauth for JWTs, an API-key table, a shared secret) — which one it is decides what "authenticated" means for its tools. Tests: mcp_auth.
thread_spawn_owned (std/thread.nu) — the fire-and-forget
thread. thread_spawn BORROWS its closure env, and for an inline closure that is spawned, detached and never referred to again the spawner has no correct moment to free it: the body is still running and nothing says when it stops. So every such spawn leaked one env. thread_spawn_owned hands the env to the thread and the runtime frees it right after the body returns (nurl_pthread_create_owned, a trampoline in runtime_ffi.c). What the body captured is still the body's to free; only the env block changes hands. The thread twin of spawn_owned for fibers.
% Drop value in a match or cond arm whose value the join throws
away was never dropped.** ?? x { T db → { = out ( label db ) } … } — the arm's tail is an assignment, so the arm carries a %String type (assignment publishes its LHS type), mem_arm_drop_safe refused to drop inside a non-scalar-typed arm (the value might BE the thing being dropped), and every arm-local % Drop value and owned struct field leaked — even though the match was a statement and nobody read the value. The same shape in ?. Ownership was correct; the compiler just could not tell, at the arm's closing brace, whether the join would keep the value, so it played safe and leaked.
The arm no longer has to know. When an unsafe arm has drops pending, the compiler parks them in an EXIT BLOCK (arm_exit_N) the arm branches to instead of the join; the block is emitted at the join, once the verdict is in — typed and used as an expression → the exit block is a bare br to the join; a statement match, or an untyped one → the drops go in it. A ?/?? that is the LAST statement of its block cannot judge alone — its value is the block's value — so it hands the exit records up and the construct that knows the answer emits them: an enclosing arm folds them into its own join (the anomaly service's `? see_all {} { ?? ( db_open … ) { T db → { = owner … } } }` shape), a loop or bare block body drops them, a function or closure body drops them iff it returns nothing, a block in expression position keeps the value alive. The phi predecessor is redirected to the exit block (the arm dominates it, so every value is still in scope), no IR is buffered or rewritten, and the drop lands exactly where a scalar arm would have put it. A `?? … { T db → { : *u q . db buf = . db buf q q } … }` whose arm value IS derived from the droppable still keeps it alive, as before. arm_join_verdict_drop pins all of it, in the CI leak gate. On the way, the compiler stopped leaking its own loop-snapshot lookups on every break/continue it compiled.
mcp_server_envelope_as / mcp_server_envelope leaked the requestid** — the envelope extracted the id once for the error paths and the success path cloned it again into the response, freeing neither. One Json per request.
nurl upgrade on Windows — it could never have worked. The
upgrade spawns the installer bundled at <prefix>\libexec\get-nurl.ps1 and streams its output, and the duplex-stdio spawn backend it uses (process_spawn / proc_read_line / proc_wait) was a Win32 stub that set err_kind = ProcessOther before it tried anything. Every Windows user got the same three lines — `resolving the latest release…, upgrading v0.5x.0 → v0.60.0, could not run the installer: ProcessOther` — with the toolchain untouched. So Windows had a nurl upgrade command that only ever printed how far it got.
The backend is now real: CreateProcess, an anonymous pipe for the child's stdin, and — the part that decides the design — an OVERLAPPED NAMED pipe for the child's stdout. An anonymous pipe cannot be read with a timeout on Windows at all (ReadFile blocks; anonymous pipes reject FILE_FLAG_OVERLAPPED), and proc_read_line timeout_ms is a contract this API has to keep, so one read is kept in flight across calls: a timeout leaves it pending instead of cancelling it, and the next call waits on the same read. Bytes cannot be lost by a timeout, and nothing is cancelled on the hot path. CRLF is stripped alongside LF, proc_kill p sig terminates with the status POSIX would report for that signal (128 + sig), so proc_wait agrees across platforms, and proc_free closes stdin, gives the child 500 ms, then terminates — the Win32 twin of the POSIX close/SIGTERM/SIGKILL sequence. proc_read_chunk stays POSIX-only, as documented.
The stub had a green test corpus. process_spawn_basic, process_pipes and mcp_stdio_basic drive cat, sort and false, and their Windows goldens RECORDED the stub — cat-spawn-failed=ProcessOther and cat_spawn_err=McpStdioOther were the EXPECTED outputs. Three permanently green tests for a backend that did not exist. Two of them now produce their POSIX golden on Windows unchanged, MCP JSON-RPC round-trip included; process_pipes says why it stops (proc_read_chunk is POSIX-only). And the new process_spawn_self covers the duplex API on every platform with a child that needs no platform tools at all: the test binary re-executed with a marker argument. One golden, all hosts, no cat.
setenv now writes the process environment block too
(SetEnvironmentVariableA alongside _putenv_s). CreateProcess hands a child the process block, not the CRT's copy of it, and nurl upgrade exports NURL_NO_MODIFY_PATH=1 precisely so the installer it spawns does not stop to ask about PATH.
nurlapi is on both facades — the playground/API's MCP surface
moves to ext/mcp_server.nu and its serving path to packages/http. 760 lines of nurlapi/main.nu become 419: the fourth hand-written copy of the JSON-RPC protocol (a nineteen-arm dispatch chain, the handshake, server/discover, the version gate, the _meta decorations, a private schema-property helper) and the forty lines of bind / router / shutdown / worker-pool glue that packages/http exists to remove. The one thing that kept it hand-wired — a semaphore bounding concurrent clang invocations so a wide worker pool cannot OOM the container — now goes in through http_app_use.
Two bugs fixed in what that server tells the world about itself. /mcp-info advertises nurl://stdlib/{path}, nurl://example/{name} and nurl://test/{name}; the read path compared the URI against six exact strings and nothing else, so a templated read returned {"contents":[]} — an empty success, which a client reads as "exists, and is empty" — and resources/templates/list, the method a client would discover them through, was not implemented at all. And every result was cached 60 seconds privately, on a public server whose surface is fixed at build time; it is an hour and public now.
Verified in the container: nurlapi/e2e_test.sh, 237/237, including the native / wasm / windows / macos / cross-target / unikernel builds and the aarch64 boot, plus the WebSocket upgrade and every HTML, JSON and raw-file endpoint by hand.
packages/http 0.6.0: http_app_use — the facade had a fixed
middleware chain (CORS, access log, Alt-Svc) and no seam for one of your own, so a server needing anything else — a concurrency gate in front of an expensive route, a per-IP budget, an auth check — had to abandon the facade and hand-wire router + server + shutdown itself, which is the ~40 lines the facade exists to remove. nurlapi did exactly that, for one semaphore. http_app_use takes a function from the app's dispatch to the handler served; the built-in layers stay outside it, so preflights and the access log still see the requests a gate holds.
ext/mcp_server.nu: resource templates —
mcp_server_add_resource_template srv \nurl://stdlib/{path}` …` registers a FAMILY of resources and answers the spec's resources/templates/list, which the module did not implement at all. A template is deliberately absent from resources/list (a client would try to read the literal {path}), so without that method a templated URI is unreachable through the protocol however well it is served — which is exactly the state nurlapi was in. resources/read falls through to the longest matching template, hands the handler the URI and the part that filled the variable, and a handler that does not serve that particular one returns a non-object, which becomes -32002 rather than an empty success.
ext/mcp_server.nu: mcp_server_set_cache_policy — the
CacheableResult TTLs and scope were hardcoded at 60 s / 5 s / private. Those suit a server that may sit behind auth and whose resources are live; they are wrong for the other common shape, a public server whose tool surface is fixed at build time, where a 60-second private TTL costs every client a re-listing a minute for a list that cannot change. nurlapi sets an hour, public.
lost** — = . p field v inside @ f T p → R compiles, runs, and writes to the callee's own copy; the caller never sees it. Value semantics are intentional (compiler/tests/function_param_mut.nu pins them) but invisible, and the same struct propagates mutations made through its handle-typed fields — ( vec_push . p items x ) IS seen by the caller — so a scalar field sitting beside a Vec field looks like it behaves the same way and does not. This is the parameter-context sibling of the by-value closure capture warning nurlc already emitted, and generalising it that one context over found three live bugs in ext/mqtt.nu (below) on the first sweep. Not raised for the modify-a-copy-and-return-it idiom (@ f T x → T, → !T E, → ?T), where the write reaches the caller through the return value, nor where the store is already a hard error for want of storage. compiler/tests/should_warn_byval_param_field_store.nu.
compiler/tests/mcp_server_contract.nu: the MCP wire, frozen —
the other MCP tests assert that a field holds the value they expect; this one prints the exact bytes of every response the server can produce and its golden freezes them. A refactor that renames a key, reorders an object, drops resultType, changes a cache TTL or moves an error code fails in CI rather than in someone's client. It pins the handshake and server/discover, the version gate's -32022 with data.supported, _meta serverInfo present on a modern request and absent on a legacy one, tools/list with annotations and built schemas, tools/call including a PANICKING handler (which must become one error envelope, not a dead process), prompts, resources, completion, every error code the dispatcher emits, and a notification producing no response at all.
ext/mcp_server.nu grows the four things the hand-rolled servers
had and it did not** — which is a large part of why they were hand-rolled. mcp_server_add_tool_full carries ToolAnnotations (an ABSENT destructiveHint defaults to TRUE in the spec, so an unannotated read-only tool is presented as if it could destroy state); mcp_server_set_instructions fills the instructions channel on server/discover and the handshake result; mcp_server_set_task_store turns on tasks/get, tasks/update and tasks/cancel and is what declares the extension — a server without a store answers method-not-found, which is the honest answer — with a pre-dispatch hook for a server whose task state is only current after it polls something; and mcp_server_add_tool_ctx hands a handler an McpCall beside its arguments, so it can see what the client declared on THIS request without widening the frozen handler type. mcp_server_dispatch accordingly takes the whole request. mcp_server_serve_http srv host port token is the HTTP main() in one line, bearer auth included. compiler/tests/mcp_server_capabilities.nu.
mcp_schema_obj / _prop / _of1 / _empty in ext/mcp.nu —
a tool's inputSchema ran seven lines of json_obj_set per property, and every MCP server in the tree had grown its own private prop helper doing exactly that.
examples/mcp_echo_server.nu and …_http.nu are written on the
facade** — 209 and 199 lines of hand-rolled JSON-RPC become 75 and 69 of mostly comments, and the two differ only in their last call. This matters more than it looks: the example is what gets copied, and packages/nurl-mcp's dispatch still carries a `// shape from examples/mcp_echo_server_http.nu` comment. It was teaching the wrong thing, including to servers that then drifted from it.
packages/http-client speaks it —
ext/http3_client.nu is the client half of RFC 9114 over the QUIC client (h3_client_connect / h3_client_request: control and QPACK streams, a request per bidirectional stream, 1xx skipped, trailers accepted, GOAWAY and stream refusals reported by code). The facade moves an origin onto QUIC once a response advertises Alt-Svc: h3=":port" (parsed and cached per origin with its ma), or straight away with http_client_set_h3 c 1; a QUIC attempt that fails falls back to TCP for that origin, and http_client_last_proto reports 3. packages/http-client 0.2.0; examples/fetch.nu --h3. fetch --h3 https://cloudflare-quic.com/, google and quic.nginx.org answer HTTP/3 (post-quantum) — status 200. The package suite grows to 25 checks (Alt-Svc switch, QUIC-first, cookies / gzip / redirects / POST over h3, fall-back when no UDP listener answers); compiler/tests/http3_client_server.nu drives the client against ext/http3_server.nu in-process.
mirror of packages/http.** One HttpClient fetches over whichever protocol the server offers — HTTP/2 or HTTP/1.1 chosen by ALPN — with post-quantum TLS (X25519MLKEM768 + ML-DSA chains) used automatically, per-origin connection pooling and TLS-session resumption, redirect following (303 / POST→GET downgrade, RFC 3986 §5.2 relative resolution), a wired-in RFC 6265 cookie jar, and transparent gzip/deflate decoding with a decompression-bomb cap. http_client_get / _post / _request, a unified HttpResponse + HttpClientErr, and http_client_last_proto / _last_pq evidence. The stdlib gained the primitives a package cannot: an ALPN preference list in the TLS client (tls_connect_full / tcp_connect_tls_full, the negotiated protocol validated to be one offered — RFC 7301 §3.2), and HTTP/1.1 keep-alive with honoured read/write deadlines, a body cap, interim-1xx skipping, chunked-trailer consumption and Connection-aware pooling in http_pure.nu (hp_stream_open_on / hp_stream_release).
ext/mcp_server.nu —
packages/nurl-mcp 0.13.0, packages/swarm-mcp 0.29.0 and packages/mermaid-server 0.2.0. Three hand-rolled JSON-RPC dispatchers (866 lines) become registrations. They were one design copied three times: nurl-mcp's and swarm-mcp's handle_ping and handle_tools_list were byte-identical, and nurl-mcp's own comment said its shape came from examples/mcp_echo_server_http.nu. Each copy had drifted somewhere the others had not — mermaid-server implemented neither server/discover (mandatory since 2026-07-28) nor the version gate, nurl-mcp compared its bearer token with a byte-at-a-time nurl_str_eq where the stdlib's is constant-time, swarm-mcp's handshake version had frozen at 0.20.0, and none of the three isolated a panicking tool handler, which over stdio is the whole server. All fifteen swarm tools, eleven nurl-mcp tools and three mermaid tools now carry annotations; every server has instructions; swarm-mcp's task eligibility is expressed by which tools register with mcp_server_add_tool_ctx rather than a name list kept in sync by hand.
ext/mcp_registry.nu is now ext/mcp_server.nu — the module you
reach for to write an MCP server was named after an implementation detail, and "registry" is already the package registry in this repo, so the name read as "a registry of MCP servers". Nobody found it: it had both transports, dual-era dispatch, recover around every handler and constant-time bearer auth, yet packages/nurl-mcp, packages/swarm-mcp and packages/mermaid-server each hand-rolled their own JSON-RPC dispatch instead, and the copies had already drifted apart. Renamed with its API — `mcp_server_new / _add_tool / _add_prompt / _add_resource / _add_completion / _dispatch / _envelope / _free, mcp_server_serve_stdio, mcp_server_http_dispatch`, McpServer, McpServerErr, mcp_server_err_name — so that a grep for "mcp server" lands on it. The module header now carries the map of the whole mcp*.nu family (which module is client, which is server, which is transport) and a STABLE SURFACE section stating what is public and what may move. Also fixed: the header pointed at ext/mcp_stdio_server.nu, a file that has never existed, sending anyone who followed it to mcp_stdio.nu — the stdio client.
CliHs
(std/tls.nu)** — the split the server got in SrvHs for HTTP/3: whole handshake messages go in (_cli_hs_server_hello, _cli_hs_message), the ClientHello and client Finished come out (out_ch, out_fin), the traffic secrets are read straight off the machine, and a driver may add a ClientHello extension and require one back in EncryptedExtensions (_cli_hs_set_ext — how QUIC will carry quic_transport_parameters). __tls_handshake is now the record layer around it: same bytes on the wire (h2spec 146/146, every tls_* and http2 test unchanged, packages/http-client 14/14, example.org over HTTP/2 + X25519MLKEM768), with the transcript hashed incrementally and snapshotted at the checkpoints instead of re-hashed from scratch five times. Handshake failures the client detects are now said with the matching alert (illegal_parameter, decrypt_error, missing_extension, …) before the close, not only no_application_protocol; a server flight carrying a hello, ticket, EndOfEarlyData or KeyUpdate before the Finished is refused as unexpected_message. The TLS 1.2 fallback is unchanged (hands over after the ServerHello, verified against openssl s_server -tls1_2).
ext/mcp_server.nu: dispatch failures carry their own code —
the dispatcher signalled failure in-band, as an __error__ key on the result object, which shared a namespace with whatever a handler returned and had no room for a code, so the envelope layer mapped every failure to -32601 "method not found". A prompts/get missing its name told the client the method did not exist, and a client that believes that stops calling it. mcp_server_dispatch now returns !Json McpRpcErr: -32602 for a malformed prompts/get / resources/read, -32602 for an unknown prompt, the spec's -32002 (new mcp_err_resource_not_found) for an unknown resource, -32603 for a panicking handler, -32601 only for a method that really is not there — and each message now names what was missing. A failed notification is logged and dropped rather than answered (JSON-RPC 2.0 §4.1).
ext/mcp_server.nu: duplicate and late registration fail loudly —
registering two tools under one name silently shadowed: tools/list advertised both, tools/call only ever reached the first, and the one that did nothing was the one just written. Registering after the first dispatch was worse than untidy — tools/list results carry ttlMs: 60000, so a client may legitimately cache the list and never see the late arrival. Both now abort at the add, naming the registration. compiler/tests/mcp_server_guards.nu.
method-not-found** — notifications/initialized is the notification every client sends immediately after initialize, and the dispatcher treated it as an unknown method, logging notification failed once per session for a client doing exactly what the lifecycle requires. The spec says a receiver ignores a notification it does not recognise; all notifications/* now dispatch to an empty result, which the envelope layer drops (no id, no response).
ext/mcp_server.nu: prompts/list advertises the spec's argument
ARRAY** — it emitted whatever was registered as the prompt's arguments, and every caller registers a JSON Schema, so the field went out as a schema object where the spec has an array of {name, description, required} — a shape no client can read. A registered schema is now converted (it holds exactly those three facts); an array passes through untouched. Found by writing the wire-contract test.
at jobs=12, and 18915 (tls_resume / tls_cert_select), 18942 and 18941 (http2_in_http_server / http_response_binary / http_binary_body) and 18799 (http_server_pool / websocket_client) were hardcoded in two tests apiece. Whichever binds second loses, so the failure appears only in a full run and vanishes when either test is run alone. Each second claimant moves to a free port, with a line saying why the number must stay unique.
struct that holds it** — nurlc emitted a forward reference, LLVM's parser makes a forward-referenced named type opaque, and an opaque struct is neither a valid function argument nor a valid return type. The module was rejected at link time at a column offset into a generated = type line with no NURL source location — and not everywhere: clang 18 on x86-64 Linux assembled it while the macOS-arm64 and Windows toolchains did not, so it passed locally and failed on another platform. Now a compile error at the declaration, naming the declaration to move. A forward reference as a plain FIELD stays legal (LLVM resolves that one), as do generic instantiations, which are emitted ahead of every user struct. compiler/tests/should_fail_fnty_forward_type.nu.
parameter's type went into the inner scope, but the function's call metadata (declared parameter roster, FFI roster, arity) lived under the same key in an outer scope and the lookup walked out and found it. A call that resolved to the parameter was then checked against a signature it had nothing to do with, and reported at the callee's line: `argument 1 to 'handler': value of type 'i64' passed where parameter expects 'i8*'`. The cross-file form is the one that bites — ext/mcp_http.nu stopped compiling because a program importing it happened to declare @ dispatch with a different arity, a name in one file breaking an unrelated function in another. compiler/tests/param_shadows_fn.nu.
shape. Both helpers that read a function type's parameter list took the FIRST ( in the LLVM string, which is the parameter list's until the return type is itself a function type and brings its own. So a ( @ ( @ i i ) ( @ i i ) ) had its arity and its parameter types read off its own return value, and calling it was rejected as passing a closure where an i was declared — a correct program, refused with a message describing a type nobody wrote. compiler/tests/closure_type_nested.nu.
parse_type spelled an
annotated closure type { R (i8*, P…)*, i8* } and the closure-literal codegen spelled the identical type { R(i8*, P…)*, i8* }. nurlc compares types as strings, so a conditional whose arms were a returned closure and a literal one was rejected with the two spellings printed side by side and no hint that the only difference was a space; extract_fn_ptr_return_type, which scans for that space, silently missed the unspaced form too. compiler/tests/closure_type_spelling.nu.
ext/mqtt.nu: three writes that never left the method — every
MqttClient method takes the client by value, so the stores into its i fields landed in the method's own copy. Surfaced by the new by-value-parameter warning; all three are protocol-visible: `__mqtt_next_pid` returned 1 forever*. Every QoS 1/2 PUBLISH, SUBSCRIBE and UNSUBSCRIBE went on the wire carrying packet identifier 1 — the one field MQTT 5 §2.2.1 requires to differ between packets in flight. * the keep-alive deadline never moved, in mqtt_ping, __mqtt_send_pingreq and mqtt_reconnect alike. After it first expired, every mqtt_keepalive_tick sent another PINGREQ: an idle loop became a ping flood at loop speed. * mqtt_reconnect closed the caller's socket, dialled a fresh one, completed the CONNECT handshake over it and returned Ok — leaving the caller holding the CLOSED conn. Every later publish wrote to it.
The two counters move into ctl, a two-slot ( Vec i ) whose control block is shared across copies exactly as rxbuf and qos2_rx always were, which is what MqttClient's 29-method API always implied it was. mqtt_reconnect takes inout MqttClient cl, because it replaces the TcpConn itself and TcpConn is a plain three-scalar struct with no control block to share — call it as ( mqtt_reconnect inout cl host port cfg ) over a : ~ binding. compiler/tests/mqtt_client_state.nu.
ext/http3_qpack.nu): a field
whose name is in the static table but whose value is not was encoded with index 2 − k instead of −(k + 2) (a prefix-arity slip), so :authority went on the wire as age, accept as :status, and the HTTP/3 server's date as age. The codec test's "name-only" case was an exact hit and never exercised the path; it now round-trips :authority, :path, date and accept with foreign values. Found by the first HTTP/3 request between two NURL ends.
std/quic_conn.nu now plays either side
(quic_conn_new_client, quic_conn_open_bidi, quic_conn_is_pq, quic_conn_confirmed, quic_conn_new_token, the peer's close code and reason), std/quic_tls.nu gains the CRYPTO-frame driver of the client TLS machine (QuicTlsCli over CliHs, with certificate verification through std/tls_verify.nu), and std/quic_client.nu is the socket + loop around one client connection (quic_client_connect / _step / _wait_readable / _close). The client honours Retry (integrity tag checked, new DCID and Initial keys, the token in every Initial, the ClientHello sent again with the packet number continuing), Version Negotiation (ignored when it lists v1 or arrives after any processed packet, otherwise the attempt ends), NEW_TOKEN, HANDSHAKE_DONE (confirmation, Handshake keys dropped), pads every datagram that carries an Initial to 1200 bytes and drops Initial keys on its first Handshake packet; it sends an empty legacy_session_id (RFC 9001 §8.4 — Google's and Cloudflare's servers refuse the TCP-style 32-byte one with UNEXPECTED_COMPATIBILITY_MODE). compiler/tests/quic_client_server.nu is the first QUIC handshake in the repo that completes with NURL on both ends — X25519MLKEM768 asserted on both, a bidirectional stream echoed with FIN, a clean close seen by the server, and the self-signed server refused with CRYPTO_ERROR(bad_certificate) when verification is on; quic_client_retry.nu pins the Retry / VN behaviour sans-IO. Against cloudflare-quic.com, www.google.com, quic.nginx.org and www.facebook.com (verification on, system roots) the client completes the handshake with a post-quantum key exchange and sees HANDSHAKE_DONE.
decoding rather than after: inflate_max, zlib_decompress_max and gzip_decompress_max stop with an error the moment the output would pass the caller's byte limit — the guard an HTTP client needs before it trusts a Content-Encoding body (1 KB of DEFLATE can expand to a megabyte, so a cap checked only after the fact is no cap at all).
matrix — every fix at the stdlib root, so the http facade inherits it.** The changes are in stdlib/ext/http_request.nu, http_server.nu, http_proxy.nu, http2_conn.nu, http2_hpack.nu, http3_conn.nu, stdlib/std/quic_conn.nu and std/mlkem.nu.
- HPACK integer-overflow remote crash (HTTP/2). hpack_decode_int accumulated continuation bytes with only a shift > 63 guard, so 0xFF + nine 0xFF wrapped the value negative; the negative length then reached the string decoder and was dereferenced at a wild offset (SIGSEGV), reachable pre-auth from an ~11-byte HEADERS payload. The accumulator is now capped at hpack_max_int before each byte, and hpack_decode_string rejects a negative / out-of-range length as a backstop. Regression: http2_hpack_int_overflow.nu. - **HTTP/2 flood budgets (RFC 9113 §10.5 — the CVE-2023-44487 Rapid Reset class).** Three per-connection counters — total streams opened, RST_STREAMs received, and no-progress control frames (PING / SETTINGS / PRIORITY / WINDOW_UPDATE / empty DATA / surplus CONTINUATION) — each have an absolute ceiling; crossing one is a connection error answered with GOAWAY(ENHANCE_YOUR_CALM). Rapid Reset, PING/SETTINGS/PRIORITY floods and the zero-length-frame CPU floods are all now bounded. Regression: http2_flood_budget.nu. - HTTP/2 flow-control overflow on the response path. The in-response WINDOW_UPDATE pump and the SETTINGS_INITIAL_WINDOW_SIZE delta now reject a window past 2^31-1 (FLOW_CONTROL_ERROR) and a zero increment (PROTOCOL_ERROR), matching the main loop. - HTTP/2 header injection. Received request headers with a CR / LF / NUL in a value, or an empty / malformed name, are now rejected (RFC 9113 §8.2.1) so they cannot be forged into a split on an h2→h1 downgrade. - HTTP/1.1 request smuggling & header injection. The header parser now rejects whitespace before the colon (the TE.CL gadget), obs-fold continuation lines, non-token / control / non-ASCII field names, and CR/LF/NUL in values; duplicate Host / Content-Length / Transfer-Encoding singletons and a Content-Length that is not 1*DIGIT (a sign or non-digit) are rejected; a missing Host on HTTP/1.1 is a 400; the chunked decoders verify the CRLF after each chunk's data and cap the trailer section; and the reverse proxy never forwards a header whose name or value carries CR/LF. Regression: http_request_smuggling.nu. - QUIC NEW_CONNECTION_ID retire loop (DoS). A frame with retire_prior_to = sequence = 2^60 drove ~4.6e18 loop iterations (CPU hang + OOM). retire_prior_to > sequence is now a FRAME_ENCODING_ERROR (RFC 9000 §19.15) and the retire loop iterates only the CIDs actually held. - QUIC off-path connection migration. A peer-address change is now committed only after a packet from the new address authenticates, so an off-path attacker who has only seen the cleartext DCID can no longer redirect a connection with one spoofed datagram (RFC 9000 §9). - HTTP/3 ignored-stream buffering. An unknown unidirectional stream (STOP_SENDING already sent) no longer accumulates its bytes, so a peer ignoring STOP_SENDING cannot grow per-connection memory. - **ML-KEM implicit-rejection constant-time selection (FIPS 203 §7.3).** The final K' / K̄ choice used a ? same 255 0 ternary that -O2 rewrote into a cmov on the source pointer, leaking the secret through the load address. It is now the address-oblivious conditional-swap idiom out = K̄ ^ (mask & (K' ^ K̄)) with an arithmetic mask — the emitted mlkem_decaps carries zero cmov instructions and reads both buffers unconditionally.
signature_algorithms
(RFC 8446 §4.4.2.2) — a post-quantum certificate beside a classical one.** The pure-NURL TLS 1.3 server can now hold two identities: the EC P-256 / RSA leaf it always had and an ML-DSA (FIPS 204) leaf, and shows each ClientHello the one it can verify — the ML-DSA leaf to a client listing mldsaNN, the classical leaf to everything else — so deploying a post-quantum certificate never turns a client away. The choice is made once, on the shared handshake state, so it covers TCP TLS and the QUIC handshake (HTTP/3) alike. tls_accept_dual_alpn (std/tls_server.nu); `tcp_listen_tls_dual host port backlog cert key pq_cert pq_key alpn (std/net.nu`), which refuses a non-ML-DSA key in the PQ slot with NetTlsKeyLoad; quic_creds_set_pq / http3_creds_add_pq for QUIC; tls_cv_scheme conn / tcp_tls_sig_scheme conn report the scheme a connection was signed with, on both ends. _load_tls_creds auto-detects an ML-DSA PKCS#8 key (keytype 2), so tcp_listen_tls with an ML-DSA pair alone serves it directly. Test: tls_cert_select.nu (offline selection against the RFC 9001 A.2 ClientHello with and without mldsa65, level mismatch, no second identity; end to end from PEM files).
http_app_set_pq_cert a cert key — the second
identity for http_app_listen_tls, applied to HTTP/1.1, HTTP/2 and HTTP/3 on that listener; the README gains a Capabilities table (protocols, key exchange, authentication, how each is chosen).
stdlib/ext/http_server.nu — server_run, server_run_pool, server_run_async, and so the packages/http HttpApp facade — now serves HTTP/2 alongside HTTP/1.1 on the same listener, under the same DoS gate, idle timeout, body limit and handler contract. Two ways in: a TLS client that negotiated h2 over ALPN (http_app_listen_tls advertises h2 http/1.1; tcp_alpn_is decides per connection without allocating), and RFC 9113 §3.4 prior knowledge on any connection — the keep-alive loop recognises the 24-byte PRI * HTTP/2.0 preface in the first bytes and hands the connection, bytes and all, to the HTTP/2 state machine (h2_conn_new_buffered). curl --http2, `curl --http2-prior-knowledge, browsers and oha --http2` get HTTP/2 from an unchanged HttpApp; h2spec 2.6.0 passes 146/146 (147/147 strict) against the HttpApp TLS listener and against examples/h2c_server.nu; on a shared plaintext port h2spec's §3.5/2 ("invalid connection preface") gets HTTP/1.1's 400 instead of a GOAWAY, by design — see the comment in _serve_keepalive_loop. - HTTP/2 frames are read through a connection-level receive buffer (h2_read_frame_buf): one recv per burst instead of a 9-byte header read plus a payload read per frame. - Response headers are HPACK-indexed (hpack_encode_headers_dyn): static-table indices, a dynamic table mirroring the peer's, the mandatory size update when the peer shrinks SETTINGS_HEADER_TABLE_SIZE; a repeated response's header block goes from 46 bytes to 4. The old encoder emitted every field as an unindexed literal. - HEADERS and DATA leave in one write when the body fits the first frame (tcp_write_all2: frame headers + the response body, no copy, one sendmsg); larger bodies go out as frame header + borrowed body slice per chunk instead of a byte-by-byte copy. Measured on the bench server (ABAB, 5 s cells): +24 % req/s at 1 connection x 1 stream, +35 % at 10 x 10, +57 % at 1 x 100, p99 roughly halved; HTTP/1.1 unchanged. - An idle HTTP/2 connection whose receive deadline fires now ends with GOAWAY(NO_ERROR) and a close (H2ConnReadTimeout), not an error. - server_run_h2_capable / server_run_once_h2_capable (a second, blocking accept loop with none of the server's safeguards, never called) are gone; http2_serve stays for programs with their own accept loop. - tools/leakcheck/run.sh now drives HTTP/2 requests through the same server and demands zero leaks from them too. - HTTP/2 benchmark alongside the HTTP/1.1 one: bench/run_http2.sh → bench/HTTP2_RESULTS.md (h2c and h2-over-TLS, C connections x P streams cells, NURL vs hyper http2 vs node:http2, plus NURL HTTP/2-vs-HTTP/1.1 on the same listener), with its own manual workflow http2-bench.yml. bench/run_http.sh, HTTP_RESULTS.md and the torture harness are untouched. - HTTP/2 conformance is a CI gate: tools/h2spec_gate.sh runs h2spec 2.6.0 (pinned, checksum-verified) against the HttpApp TLS listener, the HTTP/2-only example and the shared plaintext port, plus curl protocol checks, and fails on any deviation from the expected totals. compiler/tests/http2_in_http_server.nu proves the same with the in-repo HTTP/2 client and no external tools.
ext/http_pure.nu
keeps the ticket a server sends in a process-wide per-host cache when the transport closes and offers it on the next https request to the same host:port — the abbreviated handshake, no certificate, no signature, with nothing in the calling code. Bounded (64 hosts), mutex-guarded, never shared across hosts; a declined or expired entry is a full handshake. Test: compiler/tests/http_client_resume.nu (the server asserts tcp_tls_resumed).
sealing key is HKDF-Expand-Label(master, "ticket key", epoch) with a 6-hour epoch; a ticket records its epoch and the previous one still opens (ticket lifetime 2 h). Every worker derives the same key with nothing to swap or retire. The 32-byte master is drawn once, at listener setup or on first use through the new runtime primitive nurl_once_slot(id, candidate) — publish-once compare-and-swap slots for stdlib singletons, since NURL cannot take a global's address.
http-torture.yml — a manual GitHub Action that runs the
open-loop torture harness on ubuntu-latest and commits bench/http_torture/TORTURE_RESULTS.md back to main, the same contract as http-bench.yml / pq-bench.yml. The harness now derives its core split from nproc, prints BENCH_HOST_LABEL / BENCH_RUN_URL and the commit in the report header, and takes SOAK_SEC from the environment.
connection's wire buffer.** __write_response serialises only the head (status line, headers, blank line) into wire and writes head and body as two segments of one message with the new tcp_write_all2 — a single sendmsg(2) with a two-entry iovec (nurl_tcp_write2 in the runtime; WSASend on Windows), so small responses keep their one-syscall cost. That concat was the second full copy of every response body (the first is response_set_body_*); the open-loop torture harness (bench/http_torture) had put it at roughly half of the 1 MB per-request CPU gap to hyper. On TLS the record layer now cuts records straight from the head/body pair (tls_server_write2, tls_write2) with the same record boundaries as before. response_serialize_to / response_serialize are unchanged for other callers (proxy, WebSocket handshake); the new response_serialize_head_to is the head-only half.
tcp_write_all, tcp_write_str and tcp_write_all_async now share
one send loop each (blocking / fiber) with tcp_write_all2 instead of three hand-copied ones; the short-count / EAGAIN / SO_SNDTIMEO rules are unchanged and live in one place in the runtime too (nurl__tcp_short_send).
ec_p256_priv_from_pem / rsa_priv_from_pem accepted any PKCS#8key.** Neither checked the AlgorithmIdentifier: an ML-DSA PKCS#8 key was sliced into a 32-byte "P-256 scalar" and accepted, so the TLS listener's key auto-detection could never reach the ML-DSA parser. Both now require their own OID (id-ecPublicKey / rsaEncryption).
every TLS listener.** http_app_listen_tls now also binds its host:port over UDP and serves HTTP/3 there through the same routes and handler, on a QUIC loop of its own thread; HTTP/1.1 and HTTP/2 responses from that listener carry Alt-Svc: h3=":port"; ma=86400 so clients move over. http_app_set_http3 a 0 keeps a listener TCP-only; a UDP bind or credential failure logs "HTTP/3 off" and the TCP side keeps serving. New modules: std/quic_rxbuf.nu, std/quic_recovery.nu (RTT, ACK ranges, loss detection, PTO, NewReno), std/quic_conn.nu (the connection: packet protection per space, key update, every RFC 9000 frame with the §12.4 allow list, stream states, both flow-control levels, connection IDs, close/drain, idle, anti-amplification), std/quic_server.nu (UDP listener, connection table, Version Negotiation), ext/http3_qpack.nu (RFC 9204: static table, field sections, encoder/decoder streams), ext/http3_frame.nu, ext/http3_conn.nu (control/QPACK streams, request streams → HttpRequest, every §4/§6/§7 refusal), ext/http3_server.nu, and examples/h3_server.nu. Conformance is a gate, not a claim: tools/h3spec_gate.sh runs h3spec 0.1.13 (34 QUIC-transport + 15 HTTP/3 + QPACK error cases) against the HttpApp listener — 49/49 — and checks Alt-Svc on the TCP side; it runs in CI with a pinned, checksummed h3spec. Performance is measured beside the HTTP/1.1 and HTTP/2 reports, which are untouched: bench/run_http3.sh → bench/HTTP3_RESULTS.md (NURL vs Rust quinn+h3, plus the same listener's HTTP/2 through the same generator, plus connection-setup rates), driven by h2load --h3 from an nghttp2 image built with ngtcp2 and nghttp3 — .github/workflows/http3-bench.yml builds it and refreshes the report on demand. Out of scope (stated in the code): 0-RTT, Retry tokens, preferred_address, stateless reset emission, ECN, path migration, server push, extended CONNECT.
std/tls_server.nu now holds the handshake as SrvHs (_srv_hs_new, _srv_hs_client_hello, _srv_hs_client_finished, _srv_hs_free): ClientHello in, ServerHello and the EncryptedExtensions‖Certificate‖CertificateVerify‖Finished flight out, client Finished in, NewSessionTicket out, traffic secrets exposed per step, refusals returned as TLS alert descriptions. The TCP path (tls_accept*) is a record wrapper around it and puts byte-identical flights on the wire; QUIC (RFC 9001 §4) drives the same machine from CRYPTO frames through std/quic_tls.nu, which reassembles per encryption level, refuses what QUIC forbids (KeyUpdate, EndOfEarlyData, a ClientHello without quic_transport_parameters, no ALPN) with the matching CRYPTO_ERROR code, and carries the transport-parameters extension both ways. compiler/tests/quic_tls_server.nu drives it with the RFC 9001 Appendix A.2 ClientHello delivered out of order.
std/quic_varint.nu
(variable-length integers, packet-number encode/decode), std/quic_packet.nu (Initial secrets, per-level keys with the quic key / quic iv / quic hp / quic ku labels, AES-128-GCM and ChaCha20-Poly1305 packet protection, header protection, long and short header parse/build, Retry integrity tag, Version Negotiation), std/quic_frame.nu (every RFC 9000 §19 frame, the §12.4 per-packet-type allow list) and std/quic_tp.nu (transport parameters with the §7.4 / §18.2 checks a server owes a client). Byte-exact against RFC 9001 Appendix A in compiler/tests/quic_packet_vectors.nu; frame and parameter codecs plus a 20 000-input structural fuzz in quic_frame_codec.nu, quic_tp_codec.nu, quic_frame_fuzz.nu. The transport that uses them is the next step; nothing is wired into the HTTP stack yet.
aes_gcm_key_new / aes_gcm_seal /
aes_gcm_open / aes_block_encrypt / aes_gcm_key_free in std/aes_gcm.nu: the key expansion, bitsliced round keys and GHASH subkey are computed once per key instead of on every call, and a raw single-block encrypt is available (QUIC header protection is AES-ECB of a 16-byte sample). The one-shot aes128_gcm_* / aes256_gcm_* API is unchanged and now built on the context; the first CTR batch also carries J0 with three real counters instead of two idle slots.
std/udp.nu gains udp_recv_into / udp_recv_into_deadline (receive into a caller-owned buffer, peer reported as a 24-byte address; the deadline form is "datagram or timer" in one reactor park inside a fiber and a scoped SO_RCVTIMEO on a thread), udp_send_addr (send to such an address, no resolver), udp_addr_resolve / udp_addr_format / udp_addr_family / udp_addr_port / udp_addr_eq, and udp_setsockopt_int with the socket-option names posix_const now answers (SOL_SOCKET, SO_REUSEPORT, IPPROTO_UDP, UDP_SEGMENT, UDP_GRO, IP_TOS, IPV6_TCLASS, IP_PKTINFO, …). The existing udp_recv_from / udp_send_to paid a heap Vec + getnameinfo string per received datagram and a getaddrinfo per sent one — fine for STUN and gossip, impossible for QUIC. The address encoding is NURL's own (family, port, 16 address bytes, scope), normalised so an IPv4 peer of a dual-stack socket is family 4 rather than ::ffff:…, and the hosted runtime, the wasm host bridge and the in-process unikernel stack all produce it. monotonic_ns is now answered by the runtime (nurl_monotonic_ns) instead of a heap timespec per read. Locked by compiler/tests/async_udp.nu (the first golden for the fiber-parking UDP paths, which had none) and a new section of udp_basic.nu; both pass under the nolibc corpus too.
tcp_listen_tls_with_alpn accepted its protocol list and ignored it: std/tls_server.nu never parsed the ClientHello's application_layer_protocol_negotiation extension and always sent an empty EncryptedExtensions, so every HTTP/2-capable client (curl, oha, browsers) silently fell back to HTTP/1.1 and tcp_alpn_protocol on an accepted connection was always "". The server now picks the first protocol in ITS list that the client offered (§3.2 — server preference), announces it in EncryptedExtensions, and refuses a client whose list has nothing in common with a fatal no_application_protocol alert (120) before the ServerHello. Listener made with plain tcp_listen_tls still negotiates nothing. New tls_accept_alpn / tls_accept_rsa_alpn / tls_accept_mldsa_alpn + tls_alpn_pack for callers of the raw handshake. Locked by compiler/tests/tls_alpn_server.nu (NURL client both ways, OpenSSL client offering ["http/1.1","h2"] gets h2, OpenSSL client offering spdy/3 sees alert 120).
§4.2.9 forbids a server from sending a NewSessionTicket to a client that did not send psk_key_exchange_modes; the NURL client sent that extension only when it was already offering a ticket, so rustls — which obeys the rule — never issued one and a NURL client never resumed against a Rust server (openssl and the NURL server were lenient, which hid it). The client now sends psk_key_exchange_modes (psk_dhe_ke) in every ClientHello, and the NURL server issues a ticket only to a client that sent it. Verified in all four directions (NURL/openssl client × NURL/rustls/openssl server).
a diagnosed error.** @ S { ( vec_new [u] ) 1 } against `: S { i a ( Vec u ) b }` used to pass the front end and fail in LLVM with insertvalue operand and field disagree in type and a .ll line number. gen_agg_lit's field battery gains the case (a named struct by value into an int/float/bool slot; enum values, which lower to tags, stay legal) and names the usual cause — a positional literal written against an older field order. Found when H2Connection grew fields and a test's hand-built literal kept the old layout.
drain loop asked nurl_reactor_wait_read about . tcp raw, which is 0 for a pure-TLS connection (the handle lives in the TlsConn). On the hosted runtime that null handle was simply never readable and the client fell through to its blocking read; on the pure in-process socket stack (the nolibc corpus, the unikernel) handle 0 is another socket, reported readable, and the client then read its own empty socket and failed. New tcp_conn_fd returns the handle behind any TcpConn.
__h2_dispatch built afresh 500 fallback response for every stream and, when the handler's response replaced it, never freed it. The fallback now lives on the connection (built once, rebuilt only after a panic consumed it), as the HTTP/1.1 keep-alive loop has done since 2026-06-10. Found by the extended leak gate — the HTTP/2 tests had never run under LeakSanitizer.
^ sits inside a ? arm was never
marked as returning an owned string, so its callers leaked the result.** The return site recorded the function-level ownership flags (__fn_ret_str_owned__, the mixed poison, __fn_ret_owned__, __fn_ret_borrow__) with a scope-local define; a ? arm is a scope of its own, so the flag died with the arm and only a tail ^ ever reached the function's summary. The flags are written where they live now (nurl_sym_set_deep), and the mixed poison is pre-declared at function scope so a borrow-returning arm still vetoes the marker. This is the shape behind the volatile_load leak fixed by hand in #1054; compiler/tests/owned_return_in_arms.nu pins it under LSan. Making the poison persist exposed a second gap: ^ r where r was bound from a forward (or recursive) call carries its ownership in the run-time guard slot, so the return site could prove nothing and the function lost its marker — __thr_check's recursion leaked three empty strings per self-compile. The return site now keeps the promise the way the tail already did: a null slot (callee proved nothing) copies, otherwise the buffer is handed on and the slot's drop skipped, so every path returns something the caller may free. The bootstrap snapshot is refreshed (the new compiler frees more of its own strings).
online CPU count, not by what the process may run on.** A server pinned with taskset to 6 of 12 hardware threads got 12 workers on 6 cores; measured at 20 connections that was 0.75 context switches and 0.2 CPU migrations per request (124k req/s, p50 0.135 ms) against 153k at p50 0.115 ms with six workers. The default (NURL_WORKERS unset or 0, http_app_async a 0) now comes from nurl_available_parallelism(): the affinity mask (sched_getaffinity / GetProcessAffinityMask) capped by the cgroup v1/v2 CPU quota — what Rust's available_parallelism() returns and what the tokio peer sizes itself by. sys_available_parallelism exposes it; sys_cpu_count still reports the hardware count.
fcntl(2) per connection (the
normalise-to-blocking check, then the first async wrapper's GET+SET to flip it back) — a third of everything the async server asked the kernel for under connection churn. It accepts with accept4(SOCK_NONBLOCK | SOCK_CLOEXEC) now and records the state in the handle's memo, so the wrappers' request is free: 8.5 → 5.5 syscalls per connection (hyper: 12.7). Where accept4 does not exist the flag is set once, still ahead of the memo.
every handshake (__net_vecview); they are borrowed views now.
volatile_load / volatile_store leaked 4 bytes per use in the
compiler: __ptr_pointee returned its argument on the no-star path, so the result was never owned by the caller and the slice made on the other path was never freed.
server issues a NewSessionTicket after every handshake — stateless: the ticket is the PSK sealed under a process-wide key drawn at listener setup, so any worker takes it back without a session table — and accepts a returning pre_shared_key offer (binder verified over the truncated ClientHello, psk_dhe_ke only, no early data) with the abbreviated handshake: no Certificate, no CertificateVerify, no signature. The client keeps the ticket when it arrives in the application stream (tls_read), derives its PSK from the connection's resumption_master_secret, and offers it on the next connection: tls_session_export c → tls_connect_resume / tls_attach_resume (tls_connect_insecure_resume for the unverified variant); tls_is_resumed / tcp_tls_resumed say which handshake happened. A declined or expired ticket falls back to the full handshake. Not yet: ticket-key rotation on a timer (the key lives for the process; a restart invalidates outstanding tickets, which is a full handshake, not a failure). Test: compiler/tests/tls_resume.nu.
vec_borrow_raw [A] p n makes a Vec handle over the caller's buffer: reads see it in place, the first growth detaches into an owned copy (copy-on-write), and vec_free releases only the handle (cap == -1 marks a view; grow / set_len / shrink_to_fit / free / free_with honour it); vec_borrow_into [A] v p n re-points an existing handle — the form a struct field needs, since struct values travel by copy and only the shared control block reaches the caller. response_set_body_borrowed r p n and response_set_body_borrowed_bytes r v put a precomputed buffer on a response without copying it — the remaining full copy of every large response body after the wire-buffer copy was removed. The contract is the caller's: the buffer outlives the write. Test: compiler/tests/vec_borrowed.nu.
on the global run queue.** nurl__rq_take_global_batch takes len / worker_count + 1 fibers; with one worker that is len + 1, the walk stepped past the last fiber and dereferenced NULL. Every NURL_WORKERS=1 run — and every one-CPU container using the per-core default — hit it under the first real load. The share is now capped at the queue length.
nurl_mac_lo/_hi,
nurl_addc_lo/_hi, nurl_subb_lo/_hi) now share one i128-returning core each, closing the ~1.5× codegen constant on every 4×64 carry-chain kernel.** The pairs were designed so the two inlined copies CSE into one i128 chain, but -O2 runs a function-level InstCombine before the inliner: a self-contained _lo body is a single-use trunc of the wide chain, which that pass narrows to 64-bit mul/add inside the definition — so the inliner pasted a 64-bit lo next to a 128-bit hi that no later pass could merge, and every limb step paid its adds twice. Each pair is now two thin wrappers over a core whose return type is i128 (nurl_mac_w / nurl_addc_w / nurl_subb_w) — nothing to narrow pre-inline, and the post-inline copies are identical wide chains EarlyCSE folds into the textbook mul/add/adc schedule. _p256_mul_d drops from 388 instructions to 255 (the equivalent __int128 C CIOS under clang -O2: 238; movs 153 → 96, setb 12 → 5). Measured single-core (ns/op before → after): P-256 ECDH keygen 55.6k → 41.9k (−25%), ECDSA P-256 sign 90.2k → 67.1k (−26%), P-256 verify 396k → 278k (−30%), P-384 verify 1.61M → 1.32M (−18%); X25519 and the AEADs are unchanged (their fields don't ride the mac pairs). TLS handshakes +3–4% end to end (loopback, client sharing the cores). No language or stdlib surface change; emitted-IR change, bootstrap refreshed.
nothing — public-exponent inversions no longer pretend to be secret, squarings no longer run as general multiplies, and per-call table builds became process globals.** Measured on the handshake profile (single-core µs/op before → after): X25519 keygen 43 → 30, ECDSA P-256 sign 99 → 85, P-256 fixed-base comb 60 → 52, X25519 shared secret 65 → 61; end to end the server answers ~9% more TLS handshakes per second (loopback, oha -c 20 --disable-keepalive). Five root fixes, each verified against the old path and the corpus KATs (RFC 7748 vectors, ECDSA/TLS tests, 500-element differential sqr/mul/inv sweeps):
`std/p256_field` gains a dedicated Montgomery squaring* (_p256_sqr_d, 10 products against the multiply's 16); the point doubling, mixed addition and affine conversions that spelled mul x x now use it — 37% of a fixed-base comb's field ops are squarings, and a Fermat inversion is ~95%. `_p256_inv_d` is a fixed p−2 addition chain* (255 S + 13 M). The old ladder ran 256 S + 256 M with a constant-time select per bit — but the exponent is a public curve constant, so half of those multiplies were computed to be discarded. The schedule depends only on p−2; the data path stays branch-free either way. * std/p256_scalar's p256n_inv_be (the ECDSA k⁻¹) is a fixed 4-bit window over the public n−2 — one multiply per non-zero nibble instead of per set bit, and no per-bit limb copies. * std/x25519's _inv25519 is the standard ref10 chain (254 S + 11 M; the tweetnacl-style ladder it replaces multiplied on 252 of 254 steps). Two inversions per handshake. The X25519 keygen comb walks a niels-form table* — cached, one build per process — with a dedicated extended-coordinate doubling (4M+4S) and ref10 ge_madd mixed addition (7M) in place of two generic 9-M complete additions per iteration, and a 15-limb constant-time scan in place of 20 (Z = 1 is implicit). The old path re-materialised the 320-limb table on every public key. * std/hash_sha256 shares one process-global round-constant table; it was rebuilt and heap-allocated on every sha256_init, one of which runs per HMAC leg of the TLS key schedule and per RFC 6979 nonce step.
Constant-time discipline is unchanged where it matters: every value derived from secret data still moves through branch-free, mask-merged arithmetic; the only schedules that became data-dependent depend on public curve constants alone.
and workers drain the epoll instance on a fixed cadence — the p99 an HTTP service pays under saturation drops by a third (TLS: 40%) at no throughput cost.** Under load the poller handed every ready fiber to its own local run queue. Work stealing never fires while the thief's queue is non-empty, so at saturation a 100-event epoll_wait burst drained at one core's speed and the fibers at its back waited multiple milliseconds — measured directly (runtime instrumentation, one 6 s c=200 run: ~25 000 of 1.4 M dispatches waited >2 ms in-queue while the median in-queue wait was ~200 µs). Three coordinated changes, one policy (the Go scheduler's globrunqget shape):
* nurl__np_hand_out keeps only the oldest NP_LOCAL_SHARE (8) surplus fibers on the polling worker — small batches, the c≤50 case, never leave the warm core — and appends the rest to the global queue as one pre-linked chain in event order, with wakes batched under a single idle_lock acquisition (nurl__wake_n). Workers take fair batches* from the global queue — len / workers + 1, capped at 16, one lock acquisition (nurl__rq_take_global_batch) — instead of popping one fiber per global-lock round trip. * Every 16th dispatch runs a zero-timeout poll pass even while queued work exists. Without it a saturated pool never polls until the queues run dry, then stalls on one huge batch — the feast-famine cycle was visible as 13× fewer epoll_wait calls and +0.7 ms p99.
Both extremes were measured and lost: everything-global with a wake apiece is the stampede the first netpoller cut already hit (c=50 −15% throughput), everything-local is the tail above. Local 12-core medians of 3 (oha -z 5s, vs the Rust hyper/tokio peer): plaintext c=200 p99 3.09 ms → 2.17 ms and p99.9 5.16 → 3.85 at unchanged throughput — NURL now leads req/s, p50, p95 and p99 at every measured concurrency; TLS c=200 req/s +7.5%, p99 5.21 → 3.12 ms, p99.9 9.09 → 5.37 ms. Non-Linux keeps the poll(2) reactor; it gains the batched global take unchanged.
(37 ms → 0.4 ms), P-384 50× (85 ms → 1.7 ms).** Verification is the public-data half of ECDSA — one per certificate in a TLS chain, one per JWT-authenticated request — and it ran on the generic BigInt Jacobian ladder: a branchless always-add walk (256 doublings + 256 additions + 256 clones per scalar, twice per verify) over a field whose every multiply ends in schoolbook trial division. Constant-time discipline it never needed, at a cost the callers felt: ~111 ms of CPU per TLS handshake against a typical ECDSA chain, a ~27 req/s/core ceiling on ES256 bearer auth.
Both curves now compute u1·G + u2·Q on fixed-limb Montgomery fields:
P-256* reuses the secret path's KAT-tested machinery end to end — the Lim–Lee comb for u1·G, the 4-bit window ladder for u2·Q, the fixed-width GF(n) for u1/u2, joined by ONE complete (RCB) projective addition. New code is ~90 lines of glue. P-384* gets stdlib/std/p384_field.nu: a generic 6-limb Montgomery CIOS engine (nurl_mac carry chains, looped rather than unrolled — verification is not the steady-state path), instantiated for both GF(p) and GF(n), with the same RCB complete-addition register schedule and a Straus double-scalar walk. The Let's Encrypt ECDSA chain signs with a P-384 intermediate, so this curve sits on the default web-PKI path.
The complete formula is load-bearing for correctness, not timing: an adversarially crafted (r, s, Q) can steer the two halves of the sum into any relation — equal, negated, either at infinity — exactly where the classic Jacobian addition degenerates. The BigInt Jacobian ladder moved into compiler/tests/p256_ct_field.nu, which used it as its differential oracle and still does; bench/crypto_hotpath.nu now reports sign/verify per curve next to the AEAD and key-exchange rows, since chain validation is what a short-lived connection is made of.
outruns its tokio/hyper peer at every measured concurrency.**
The old design ran a dedicated poll(2) thread: every I/O park cost a pipe poke to the reactor, an O(waiters) rebuild of the pollfd array, a global-queue push and a cond-var wake of some worker — measured at ~4.5 futex calls, ~1.5 wake-pipe reads/writes, ~1 epoll_ctl-equivalent and 0.44 CPU migrations per keep-alive HTTP request, because the woken fiber resumed on whichever worker won the global queue. Closed-loop throughput at c=10 sat 22–35 % behind the Rust peer.
On Linux there is no reactor thread any more. An **idle worker becomes the poller** (the Go netpoller / tokio driver shape): it takes the poll lease and blocks in epoll_wait itself; a ready fiber resumes directly on that worker — no queue hop, no cross-thread wake chain, no migration — and surplus ready fibers go to the poller's own local queue, fanned out through the existing steal path with one wake per doubling (a first cut that pushed each surplus fiber to the global queue with a wake apiece measurably made c=50 worse than the old reactor; the stampede is the failure mode, and the fix is the discipline, so it is written into the code). A zero-timeout poll pass in the scheduler loop keeps events flowing while every worker is busy. Sockets are registered **once per fd lifetime** (EPOLLIN|EPOLLOUT|EPOLLET, persistent — the oneshot re-arm was ~1 epoll_ctl per request); readiness edges that arrive while the fiber is computing are banked in per-fd ready bits, and the next wait consumes the bit and skips the park entirely — under load this is the common case. Deadlines live in a min-heap; an eventfd replaces the wake pipe; closing a socket routes through the netpoller so parked waiters wake and the per-fd slot is clean before the fd number is reused. The edge-triggered contract — a wait may only follow an EAGAIN — is documented in docs/ASYNC.md, and every stdlib wrapper already has that shape. Non-Linux POSIX keeps the portable poll(2) reactor thread unchanged; a kqueue netpoller is the matching upgrade path.
Measured on the bench harness (12-core host, oha, keep-alive, median of runs; bench/http_server.nu vs the tokio/hyper peer):
| c | before | after | Rust peer |
|---|---|---|---|
| 1 | 20.2 k | 21.7 k | 11.3 k |
| 10 | 91.3 k | 123.6 k | 120.7 k |
| 50 | 178.8 k | 186.5 k | 176.7 k |
| 200 | 208.4 k | 248.1 k | 229.4 k |
Per request at c=200: context switches 0.64 → 0.09, scheduler wakeups 0.38 → 0.05, epoll_ctl 0.97 → 0.001, CPU 27.9 → 20.3 µs (the tokio peer: 19.2). p50/p99 latency improved at every concurrency alongside the throughput.
list of known pairs** — and the compiler now rejects code it used to compile into silent garbage.
Both boundary checkers — return and call — were built as batteries of pairwise checks: float beside integer, pointer beside scalar, two named structs, two closures. Every pair nobody had enumerated fell through to the emitter, and under opaque pointers the emitted instruction assembles: ret { i1, i64 } %o out of an i64 function, call i64 @f({ i1, i64 } %v) against declare i64 @f(i64). clang accepts both (a call carries its own signature), and the callee reads whichever bytes the ABI left in the register — garbage, not even a crash.
Now every such boundary ends with a total-agreement backstop: after the sanctioned coercions (integer/float widths, enum wraps, the FFI NULL/handle inttoptr bridge), the value's LLVM type must equal the declared one, pointer-beside-pointer excepted. A pair that matches no branch is a diagnostic, not a hole. This closed, with one rule per boundary:
* a closure returned where its own result type is declared (the ^ f( r ) C-style-call typo — the diagnostic now spells out the parenthesised prefix form); * an option/result/slice/closure aggregate returned as a scalar, pointer, or named struct — and each of those returned as an aggregate (six invalid-IR shapes from one fuzz campaign's mutants); the same aggregates passed as arguments — through every* call path: direct calls, generic instantiations (the per-argument battery was guarded with call_name == fname, so no generic callee ever ran it), trait-method dispatch (the impl scan now records each method's parameter roster and the dispatch site checks the assembled arguments against it), closure and fn-ptr invocations and dyn-trait calls (the arity check gained its type companion); * FFI fixed arguments: a pointer passed to an integer parameter was a silent ptrtoint — the callee read the string's address as its number — and is now a diagnostic pointing at the honest # i x spelling (( free 0 ) and handle-to-pointer stay legal); * a void expression as a call argument (? cond 1 two, arms disagreeing, degrades to void — the return boundary caught it, the argument boundary emitted call @f(void undef)).
The strictness immediately found real bugs in the tree: the compress_rawdeflate test freed the whole !ZInflate CompressErr RESULT value on its failure arms — heap corruption armed in dead code, four sites.
as a full-width integer.** FIPS 186-5 §6.4.1 defines z as the LEFTMOST qlen bits of the digest; the old BigInt path computed z = int(hash) mod n over the whole digest, a different value whenever the digest is longer than the curve — so every P-256 + SHA-384 signature the rest of the world accepts was rejected here (and an off-standard value would have been accepted in its place). Found while rebuilding the verify path below; pinned against OpenSSL across both curves and five digest widths (SHA-1/256/384 × P-256/P-384) in compiler/tests/ecdsa_verify_matrix.nu.
stdlib/ext/json.nu passes the full JSONTestSuite — 283/283 must-accept /
must-reject verdicts correct.** Six n_ documents parsed clean before, from two roots:
The surrogate-pair lookahead consumed as it peeked.* After \uD800, a \ was swallowed before knowing whether u followed — so in ["\uD800\"] the escape lost its backslash, the stray quote closed the string, and a document whose string never terminates parsed successfully. And when \u did follow with malformed hex (\u, \u1, \u1x, \uDd"), a code path its own comment called "best-effort recovery" dropped the error a plain \uZZZZ raises. The lookahead is now pure — \u is consumed only after both bytes matched, and once consumed the four digits must be hex, the rule every other escape lives by. A complete-but-unpaired surrogate stays accepted as U+FFFD (RFC 8259 leaves it undefined), and a value that breaks a pair re-enters the pairing loop, so \uD800 followed by a real pair still decodes the real code point.
`json_parse` takes `s`, so the document ended at the first NUL* — 123\0 truncated to 123 and parsed clean. The parser was already length-based internally; only the entry ran strlen. New entries carry the length end to end: json_parse_n src len (the root entry — an embedded NUL is an ordinary rejected byte) and json_parse_bytes buf for ( Vec u ) payloads (network buffers, read_file_bytes results). json_parse is now a documented C-string wrapper over json_parse_n.
tools/json_conformance.sh runs the full suite against the byte-exact harness (clones JSONTestSuite shallow when absent); the six regressions plus the pairing/NUL semantics are pinned by compiler/tests/json_surrogate_lookahead.nu. Parse throughput is unchanged (measured A/B on bench/json_parse), and the parser fuzzer's 3000-iteration ASan+UBSan pass stays clean.
nurlpkg install swarm-mcp had been broken since v0.55.0, and eight
packages resolved a different dependency from the registry than this tree builds against.** Two v0.55.0 compiler changes reached the published swarm-mcp 0.28.1 tarball and neither had been republished past: #1032 made a closure body that falls off its end drop what it bound, so the package's hand-written free of a map key became the second free ('ks' is auto-dropped at the end of its scope), and #1025 closed the print family under one rule, after which nurl_print_int no longer ends the line — this package generates the NURL kernels it compiles to wasm and they print their result with it, so a toolchain that still compiled 0.28.1 produced unparsable output rather than a refused build. A wrong answer, not a failure. The fixes were already in the tree; only the release was missing. swarm-mcp 0.28.2 is published, its minimum toolchain moves v0.10.12 → v0.55.0, and tests/shuffle_smoke.sh passes 5/5 against a real GPU including the 1000-key multi-chunk merge.
The same sweep pinned every [dependencies] requirement across packages/ to the major (^0, ^1). Caret locks the minor on 0.x, so gpukit ^0.6 resolved to a 0.6 series while the monorepo builds against 0.7.1 — the same commit building differently from the registry than it does here. That was real in eight packages: grad, tensor, onnx, yoloe, nurllama, mlp, lingbot-map, map-anything (and mlp's grad ^0.8 against a tree at 0.10). The trade is deliberate and worth saying out loud: ^0 also accepts a 0.x minor that breaks, and when a package really breaks compatibility the answer is to take the major to 1.0, not to narrow the pin again. All 19 packages are republished, and onnx 0.8.1 installed from the registry now resolves gpukit 0.7.1.
A follow-up caught what the sweep's own scan could not: three packages print their version through a literal that carries the package name next to it (( nurl_print lingbot-map 0.9.4n )), which a search for a bare version number never matches — tools/check_package_version_strings.sh did. lingbot-map 0.9.6, map-anything 0.4.4 and nurllama 0.17.4 carry the correction, byte-identical to the versions they replace apart from that literal.
packages/anomaly 0.6.0 and 0.7.0 — the model's metadata becameeditable, and then the editor stopped being written by hand.**
0.6.0 opened PUT /models/dynamic/<model>/metadata: the editable half of a model's metadata — the retrain schedule and the per-version configs — as a patch in which every field is optional, so a checkbox can send one field. An unknown version name adds a version with the defaults filling the gaps; replace_versions deletes the ones the body omits. Values are clamped into a range the trainer can honour rather than rejected, so a hand-written edit cannot produce a version that fails to train. The learned half — column kinds, category vocabularies, feature order, scaler — is still never accepted from a client: it is refitted at every train, and a hand-written copy would silently desync every forest. The same release made the autoencoder visible (it had been trainable since 0.4.0 and its verdict had ridden along in every detect, with nothing in the dashboard saying so) and made disabling its version actually mute it.
0.7.0 turned the ring size into metadata too. max_data_points was MAX_DATA_POINTS = 150000 compiled in, so a model that should keep a thousand points kept a hundred and fifty thousand. It is now per-model, persisted, and patchable — positive and at least min_data_points, since a ring smaller than the warm-up threshold could never train the model at all — and lowering it below the current fill evicts the oldest points and rewrites the log before the call returns, rather than converging on the new cap one ingest at a time.
And that field is why 0.7.0 also rewrote the editor. The dashboard's Advanced box hard-coded the editable half as {schedule, versions} — its own copy of a list that lives in model_apply_meta_patch — so the new field was patchable through the API and invisible in the one editor that exists to reach it. The list is now published: every metadata response carries editable_fields, and the box has two views over exactly those keys — a **form generated by walking the metadata's own shape** (one typed input per leaf, every version and every one of its fields, including the geometry and forest sizes no hand-written form ever showed) and the raw JSON. Neither view names a field, so the next editable key appears on its own.
so** — the whole family, in every spelling.
`nurl : ( Vec i ) v ( vec_new [i] ) ( vec_push [i] v 41 ) : ( @ i ) f → i { ^ ( vec_len [i] v ) } ( vec_free [i] v ) ( nurl_println_int ( f ) ) // compiled clean; segfaults `
The borrow checker rejects the same read written directly (( vec_len [i] v ) after the free) on the spot. Through a closure it reported nothing, and the hole was structural rather than a missing case: a closure body is analysed as its OWN function, where a capture is never seeded and therefore can never be reported, and the enclosing function's walk never saw the body's reads at all. Nothing connected the free to the later call. Confirmed accepted, and wrong at run time, for Vec (segfault), String and HashMap (a silent read of a released control block), for a read and for a mutation, for a capture taken AFTER the free, for a free through a sink parameter, for a closure captured by another closure, and for both binding forms.
One fact now crosses the boundary: a closure binding holds a COPY of every heap handle its body captured, so **invoking it reads those handles**. gen_closure_expr publishes the capture list (already expanded through captured closures), gen_let / gen_assign record it on the binding, and the invocation sites expand it into the statement's read set — after which the existing use-after-move machinery reports it with no new state, no new walk and no new diagnostic path. Capturing is a read too, so a closure over an already-freed binding is rejected at the literal even if it is never called. The error names the closure, because the line it points at spells the call and not the handle.
Invocation is the discriminator, not "the argument is a closure": a plain VALUE load of a closure is not a use of its captures, which is what ( nurl_free # s # *u cb 1 ) does to reclaim a drained closure's env (§7.4) after freeing what it captured. Reading that as a use rejected async_mn and async_sleep, two correct programs. One call deep is covered through the callee's invoke-only set — the positive signal that the parameter is only ever called.
Boundary, now written down (docs/MEMORY.md §2.11 and §3): a closure that leaves the frame — stored into a struct field, returned, kept by a callee — is not covered, and that is not a closure limitation. A plain Vec stored into a struct field and freed through its original name is unchecked in exactly the same way; --strict-borrowck reports the handover itself.
Verified: the bootstrap fixed point, 891 tests, 102 examples, and a differential sweep of old vs new compiler over all 1596 .nu files in the tree (stdlib, examples, every package, tooling, unikernel, registry) — zero diagnostic differences, so no false positive was introduced and no existing program contained the bug. The emitted IR is byte-identical between the two compilers over 400 of those files, which is the property a diagnostic-only pass owes the bootstrap. The sanitized compiler under LSan reports no new allocation on the new test: the two it does report sit in _nurl_main and are the pre-existing pair any early error exit leaks, identical on borrow_double_free. Regression compiler/tests/borrow_closure_capture_use_after_free.nu pins eight positives and four controls, the env-reclamation shape among them.
--no-borrowck was not reachable through nurl.sh / nurl.bat —
the compiler's own borrow-checker error ends with "re-run with --no-borrowck to bypass", and the driver answered ERROR: Source file not found: --no-borrowck. Its flag loop stops at the first unrecognised argument and treats it as the source file, so the documented escape hatch, --strict-borrowck, and the two --strict-arity spellings were all unusable unless you invoked build/nurlc directly. All four are now forwarded verbatim, and listed in both drivers' usage text.
"expected a field name in this nested field path" and "type 'T' has no field 'f'" messages of gen_nested_lvalue_addr, both listed as never-fired by tools/check_diag_coverage.sh. The first needs THREE dots to reach at all: with two, the single remaining hop is the one __nested_lvalue_ok already proved is an identifier, so the check inside the walk can only fire from the second hop onwards — which is why writing the obvious two-dot fixture reports something else. compiler/tests/diag_nested_field_path.nu covers both.
nurl_eprint_int / nurl_eprintln_int — the print family is nowone rule on both streams.
0.55.0's own comment said it was: "print = no newline, println = newline, _int = the integer overload, eprint/eprintln = the same pair on stderr". Three quarters of that was true. The _int overload existed only on stdout, so printing a number to stderr meant ( nurl_eprint ( nurl_str_int n ) ) — an allocation per diagnostic, and a leak unless the caller bound and freed it, which is the exact footgun nurlc.nu's own comment at the nurl_eprint call sites warns about. The claim of one rule everywhere was written a release before it was true.
All four _int printers now share one stack digit loop (nurl__int_digits), so the stdout and stderr halves cannot drift, and neither allocates. Regression compiler/tests/eprint_int_family.nu, whose golden also pins the stdout/stderr interleaving.
flags on the build endpoints and the build MCP tools — an
allow-listed subset of nurlc's flags: --lint, --no-borrowck, --strict-borrowck, --no-strict-arity, --no-cpu-dispatch.
Two of 0.55.0's headline items had no way to be reached through the API: --lint (the report on an allocation nothing owns) and the --no-borrowck panic fix are compiler flags, and the build tools took no flag parameter at all. An agent could read the changelog entry for the lint and had no way to make the lint speak — which is the audience it was written for. The lint's findings arrive in nurlc_stderr on an otherwise ordinary build.
An allow-list, not a passthrough: nurlc also takes --keep=a,b, --split-out=PREFIX and a file argument, and a free-form flag string on an unauthenticated compile farm would let a caller name paths inside the container. Every accepted flag changes only how the compiler CHECKS the one source it was handed. An unknown flag is a 400 naming the allowed set, not a silent drop — for the same reason run refuses loudly: a caller that asked for the lint and got a plain build would read the silence as "no findings".
` x86_64-w64-mingw32-ld: runtime.win.o:runtime.c:(.text$nurl_tz_offset+0x13): undefined reference to `__imp__get_timezone' `
nurl_tz_offset arrived in 0.54.0 with the time_local work and asked the CRT for the zone with _get_timezone. That is a UCRT export. There are three Windows toolchains in this project's life and the one nothing tested is the distro mingw-w64 the hosted playground's /build_windows cross-compiles with, which targets the older msvcrt.dll — windows-tests.yml builds with LLVM clang (MSVC ABI, UCRT) and with a bundled zig, whose windows-gnu target links zig's own UCRT mingw. Since mingw links the whole runtime object, the missing symbol had nothing to do with what the program called: the failure was every program, for two releases, while every Windows gate stayed green.
_get_timezone was also wrong on its own terms — it reports the zone's STANDARD bias and ignores the instant, so it answered 3600 for a Helsinki summer timestamp where the Unix path answers 7200. The replacement converts the instant to a SYSTEMTIME, asks Windows for the local wall clock at THAT instant (SystemTimeToTzSpecificLocalTime) and takes the difference: DST-aware per instant like tm_gmtoff, and entirely in kernel32, which every Windows toolchain links.
The gate that was missing is tools/check_mingw_cross.sh, run by ci.yml: compile stdlib/runtime.c for mingw-w64, link a trivial main against it. Ten seconds, no Windows, no emulator, no nurlc — and it fails on the pre-fix tree with exactly the message above.
` 'nurl_print_bool' was removed — … ( nurl_println ? x 'true' 'false' ), with backtick string literals as the ternary arms. `
Single quotes are not NURL string syntax anywhere, so the cure the message named had to be repaired by the reader before it could be pasted — and the trailing clause existed only to say so. The reason it was written that way is real: a NURL diagnostic is itself a backtick-delimited literal, and docs/spec.md §2.5 gives the backtick no escape, so a message physically cannot contain one. __diag_lit pokes the two quotes in at runtime instead, and the message now reads ( nurl_println ? x true false ). Regression compiler/tests/diag_removed_print_bool.nu (with diag_removed_print_str.nu for its sibling, which was correct and now has a golden holding it that way).
true and false got the generic undefined-identifier message.
NURL spells the boolean literals T and F. The words every other language uses are not near-misses of any identifier, so the "did you mean" suggester had nothing to offer and the reader got "no binding, parameter, constant, enum variant, or function with this name is in scope" — which sends them looking for a missing declaration. A file that writes TOML or JSON has just seen true as data a line earlier, which is what makes this the common mistake rather than a rare one. diag_bind_bool_literal_name.nu already locked the opposite direction (T used as a binding NAME); this is the one that gets written first. Regression compiler/tests/diag_bool_literal_words.nu.
built.** build_unikernel_arm64.sh skips the flat .Image when no llvm-objcopy is on PATH and says so in its stderr, but the response's boot.notes told the reader to hand that .Image to Firecracker or cloud-hypervisor regardless. The notes now branch on the same fact the artifact list is built from, and say what to do instead. The playground container also gains binutils-aarch64-linux-gnu (~10 MB, providing the third name the script probes for) so the file usually exists.
* The 0.55.0 "Added" entry for borrow-check rule §2.1b said the rule stops at a closure body and cited a regression (free_in_closure_ok.nu) that the same release's "Fixed" entry had deleted. The two halves of one release contradicted each other; the "Fixed" half is the true one, and the "Added" entry now points at it. * compiler/tests/should_fail_free_autodropped.nu still described that deleted companion and claimed the shape stays ACCEPTED inside a closure. It is now rejected there. * stdlib/std/time.nu's time_make warned that a wide-payload ! T E is silently truncated when matched directly as ?? ( call ) { … } and told the reader to bind to a : variable first. That compiler bug is fixed — the direct and the bound spelling agree for a narrow ! i ParseErr and for a multi-field struct payload alike — and the warning was routing readers through a detour for nothing. * stdlib/std/float.nu's float_to_string was documented as "String → float without error info (legacy convenience)", the description of its inverse. * docs/spec.md §6.6's member-access table listed opt and res together as { i1, T } with indices 0/1, while §4.5 defines the result as { i1, T, E } with . r 2 for the Err payload. The table now has a row of its own for the result.
it wrong in both directions.** nurlpkg publish shipped build output and dropped shipped data, and --dry-run reported every gate passed either way.
Two holes, found publishing mermaid-server:
A built binary walked in. __pack_build_output lists extensions — .ll, .o, .exe — but the binary nurlpkg build produces on Linux and macOS is named after its package and has none. A 540 KB ELF went into the tarball, and its checksum then depended on whether the publisher happened to have built in that checkout: the same drift the .ll rule was written for, through the door it left open. An extensionless file whose first bytes are an executable image (ELF, Mach-O, PE) is now build output too — the bytes are already read, so it costs no extra I/O, and the test is only applied to extensionless names so a .wasm fixture a package deliberately ships is untouched.
A shipped directory was dropped. Every dot-prefixed name was excluded, which took mermaid-server's .templates/ — the themes the server loads at startup — and produced a package that installed and then refused to run. Only VCS, editor and toolchain names are excluded by name now; a package's own dot-prefixed data is source like any other. What a package itself calls non-source is honoured instead, from its .gitignore, read per directory as the walk descends the way git applies it — which is how yoloe-demo's generated 900 KB test frame, ignored in tests/, stays out.
And the silence itself: --dry-run now lists the files it would ship. The packer's whole job is deciding what is source, and a wrong answer in either direction used to look exactly like success.
Swept all 51 packages: only mermaid-server (binary out, templates in) and yoloe-demo (its own ignored build products out) change. compiler/tests/pkg_pack_basic.nu now states the contract in full — what ships, what does not, and by which rule.
nurl_free on a binding the compiler already drops is now an
error:** — borrow-check rule §2.1b.
nurl_free sits outside the destructor move rule because it normally reclaims raw nurl_alloc memory that nothing tracks. When its argument names a binding auto-drop did register — an owned string from nurl_str_slice / nurl_str_cat, an owned slice, a Drop value, a struct with owned fields — the scope exit frees that pointer a second time. docs/MEMORY.md §1 has stated the rule in prose since the auto-drop layer landed ("do not then also free it by hand"); until now the compiler let the program say it anyway, and the double free surfaced only under ASan, in whatever unrelated allocation reused the block.
The binding is identified from the identifier the argument expression loaded rather than from its first token, so the cast spelling every FFI pointer uses — ( nurl_free # s piece ) — is covered as exactly as the bare one. At function scope there is no legitimate counter-case: reassigning an owned binding already frees its previous value, and a binding whose ownership has left it is no longer registered.
The rule reaches into a CLOSURE body too, and the reason it very nearly did not is a second finding worth its own line: a closure that returns through ^ runs the drop epilogue, but one that FALLS OFF ITS END used to emit a bare ret and drop nothing, so an owned s binding there leaked unless the body freed it by hand — which packages/swarm-mcp did, and which the first draft of this rule therefore rejected. The "Fixed" entry below closes that gap by running the same epilogue at the closure's fall-off terminator, so registration is a proof at both exits and the hand-written free inside a closure is the second one, exactly as at function scope. docs/MEMORY.md §2.1b carries the table. Regressions compiler/tests/should_fail_free_autodropped.nu (rejected at function scope) and compiler/tests/should_fail_free_in_closure.nu (rejected inside a closure).
--lint reports an allocation owned by nothing.
A fresh allocation handed straight to another call's argument list is bound to nothing, so nothing ever frees it — the shape docs/MEMORY.md §1 has always warned about in prose, and the one that leaked a string per HTTP request out of ext/http_router.nu and ext/http_middleware.nu until 0.46.0. It is invisible without LSan, and it grows.
The lint fires at the call site only when the outer callee provably does NOT take the value over: a destructor, a sink parameter, a position the callee embeds in an aggregate (vec_push, json_obj_set, …) and an escaping position are all silent, because each is a legitimate way for a temporary to end its life there. A callee that hands back a view rather than a fresh handle is silent too — which needed one gap closed on the way: the §2.2 returned-handle summary recorded ^ p but not `^ . p field`, so a function returning a handle stored inside a parameter looked like it minted a new one. Both call paths now publish that summary as view provenance. Zero findings across the stdlib and the compiler's own sources; regression compiler/tests/lint_owned_temp.nu.
stdlib/ext/toml.nu parses them, and integers grewthe two spellings the 1.0 spec has and the parser did not.
The number scanner was integers-only and sign-only-negative, so stroke_width = 1.5 in a config file was a TomlSyntax error and a caller wanting a non-integer had to quote it and parse the string back. One scanner now reads both: a fraction or an exponent makes the value a TFloat, nothing else does, either sign is accepted, and _ may separate digits (+1_000_000, 6.02e23, -1E-9) — with the spec's own requirement that a separator sit between digits enforced, so 1__0 and 1. stay syntax errors. toml_as_float widens an integer, because a config that writes 2 where the reader wants a float is not a format error; toml_stringify round-trips, appending the .0 that keeps an integral float a float on re-parse. TomlSerialize f and from_toml_f join the serde side. Regression compiler/tests/toml_float.nu.
packages/mermaid-server 0.1.0 — mermaid flowcharts to SVG, as amulti-threaded HTTP server that is also an MCP server: one process, one port, both protocols, and no headless browser or mermaid.js anywhere.
Parses the graph / flowchart dialect (thirteen node shapes, the three line styles, arrow / circle / cross heads, bidirectional links, & groups, both label spellings, <br/> breaks), lays it out with a layered Sugiyama-style pass — back-edge-tolerant ranking, dummy-node splitting so a link crossing several layers gets its own corridor, barycentre crossing reduction — and renders SVG, all in integer arithmetic.
Nothing about the look is compiled in: every colour, width, radius, font and gap is read from an external directory of TOML templates at startup and chosen per request by name, with per-shape and per-line-style overrides and a raw-CSS escape hatch. HTTP serves /render, /render.json, /templates and a live playground at /; MCP serves mermaid_render, mermaid_templates and mermaid_validate at /mcp or over --stdio.
them.** whisper 1.1.0 fused a decode-step attention shaped for ONE query — the composed path cost 95 us per cross-attention and 33 us per self-attention, 54 % of a decode step, against 31 us for the vocabulary projection that does sixty times the arithmetic. embed 0.2.0 stopped recompiling NVRTC per input shape (385 ms vs 36 ms) and put a bound on its exact-size buffer pool; 0.3.0 made a batch of texts one forward instead of N, taking a 16-text request from 28 to ~500 texts/s. gpukit grew the batch-native attention and the device-ops both lean on, and tokenizer its share of the unigram path.
Publishing embed surfaced the rest: nurlpkg's publish gate refuses a package whose dependency requirement excludes the version it was built against, and **ten manifests still asked for http ^0.3** while http has been 0.4.0 since #1014 — so every install from the registry compiled against different code than anything in this tree was tested on. The gate only fires on the package being published, which is why it stayed invisible until one went up. All ten now ask for ^0, the major-only form swarm-mcp already used.
simd clone the dispatcher never picks is now run against the
goldens** — compiler/tests/simd_baseline_agree.sh, wired into ./build.sh next to simd_dispatch_ir.sh.
The simd prefix emits every marked function twice and chooses on a cached CPUID answer. simd_dispatch_ir.sh proves both clones are built — two definitions, feature bits on the wide one only, a dispatcher owning the undecorated symbol, ymm in the object code. What nothing proved is that the other one *computes the right answer*, because no test can run it: on a host with AVX2 — every x86-64 runner this project uses — the dispatcher picks the wide clone every time, and on a host without, the wide clone is what never runs. --no-cpu-dispatch appeared in no build script and no workflow.
Every one of the eleven functions the prefix marks today is post-quantum cryptography: __ntt, __invntt, __poly_basemul, __cbd_batch and the three __kpke_* in mlkem.nu, mldsa_keygen_derand / mldsa_sign_mu / mldsa_verify_mu in mldsa.nu, and __kf1600x4, the x4 Keccak sponge under both. A divergence between the two lowerings there is not a slow path; it is a wrong key on the half of a fleet that was never tested.
The gate builds mlkem_vectors, mldsa_vectors and sha3x4_vectors a second time with --no-cpu-dispatch and requires both lowerings to produce the committed golden — all three ML-KEM levels, all three ML-DSA levels, and the x4 sponge either side of both rates. They agree today; that is now a fact a build checks rather than an assumption.
Two things it does that a naive version would not. It first proves the two legs are different machine code — 2057 ymm instructions inside the wide clones of mlkem_vectors against 0 anywhere in the baseline build, 2315 against 0 for mldsa_vectors — because "the outputs match" is equally true of two builds of identical code, which is exactly what a silently-dropped clone produces. And it reads the marked set out of stdlib/ rather than carrying a list, so marking a twelfth function without giving it a driver fails the build by name.
Verified by breaking it three ways: forcing both legs to --no-cpu-dispatch (the "clone silently stopped being emitted" regression) fails the different-code assertion on all three drivers and reports all eleven functions uncovered, while every output still matches its golden — which is precisely the regression no existing test could see; changing 3329 to 3330 in __ntt's modulus broadcast fails both lowerings of mlkem_vectors; and appending an undriven simd function to mlkem.nu fails the coverage assertion by name.
~10 s. Skips with a message on non-x86-64 hosts, where there is only one lowering to begin with and --no-cpu-dispatch is mandatory — so the macOS ARM64 runner, which also runs ./build.sh, is unaffected.
print = no trailing newline, println = trailing newline, _int = the integer form; eprint/eprintln are the same pair on stderr.
nurl_print_int used to append a newline while reading as the sibling of nurl_print (which does not), and 186 of its 421 call sites in this tree used it as if it did not — 60 followed it with an explicit \n (a blank line on every value) and 126 followed it with text meant for the same line, splitting one line into two. The printing spelling is now nurl_println_int, and nurl_print_int prints the integer with no newline. Every broken call site was converted and 22 test goldens now show the line each test always meant to print (524288 bytes, Onnistuneet testit: 12/12, …).
All three legacy printers also wrote straight to stdio, bypassing the nurl_print_buf_* capture buffer and the wasi-threads IO lock. The int pair is now routed through nurl_print (no allocation — stack digit buffer), so captured output finally contains what was printed, in order.
Removed: nurl_print_str (it was nurl_println under another name) and nurl_print_bool (write ( nurl_println ? x \true` `false` )`). Calling any removed or renamed spelling is a compile error with the replacement named in the message — nothing changes meaning silently.
. x 0 on an option/result extracts the i1 tag, typed b — so
: is_ok . r 0 and ? . r 0 … … now work like every other numeric field access (. r 1 already gave the payload). It used to return the whole aggregate, which type-checked only when handed straight to an FFI scalar parameter and then read the tag by ABI coincidence — call @f({ i1, i64 } %v) against a declared i1. The emitted IR now carries an explicit extractvalue …, 0.
allocation per call, in the shape a callback is usually written in.
`nurl ( each v i x → v { : s k ( nurl_str_int x ) ( nurl_print k ) } ) `
A closure is lifted into its own function, so its body is a scope whose end emits the drop epilogue like any other — except only ONE of its two ends did. gen_ret_term ran the epilogue for a body ending in ^; a body ending by falling off emitted a bare ret and ran only the deferred drain, so every owned value it bound leaked. Measured under LSan: three iterations of the loop above, three allocations outstanding; the same body written with ^ clean.
gen_closure_expr's fall-off exit now runs the same epilogue. The rosters it walks were already shadowed to empty when the body's scope is pushed, so a closure drops its own values and never the enclosing frame's; when the body falls off WITH a value and that value is a bare identifier, the binding's drop is skipped, because its handle is the closure's result — the rule gen_ret applies to a returned ident, and the same conservative direction (a missed skip leaks, it cannot dangle).
Two consequences, both carried here. §2.1b — nurl_free on a binding the compiler already drops — was scoped out of closure bodies precisely because the fall-off exit dropped nothing there; it now reaches inside them, since registration is a proof at both exits. packages/swarm-mcp's hand-written free of a map key inside a map_each closure was what kept that key from leaking and is now the second free, so it is deleted.
Verified: the sanitized corpus (885 tests under ASan / UBSan / LSan, 0 SAN_FAIL) — the load-bearing gate for a change that ADDS frees — plus the bootstrap fixed point, 885 tests, 102 examples, the stdlib symbol gate, the swarm appliance the unikernel job builds, and packages/mermaid-server's suite under LSan. Swept the restored §2.1b over every package, the stdlib, the examples and the compiler: zero findings. Regressions compiler/tests/closure_falloff_drop.nu (the shape reclaimed with no hand-written free, an enclosing-frame string live across every call so an over-reaching epilogue would show) and should_fail_free_in_closure.nu.
--no-borrowck was an internal compiler panic on any program with aclosure.** Fixed, and the flag is now exercised.
` internal compiler error: nurlc itself panicked panic message: remainder by zero `
gen_closure_expr saves the borrow checker's per-function state around a closure body and restores it after — the closure is its own function for checking purposes. That save/restore reached for g_bck without the gate every other site uses, and g_bck only exists when the checker is on (main builds it under the same flag). With --no-borrowck the handle was 0, so nurl_sym_get hashed the key modulo a zero-capacity table. Twelve lines of NURL and one closure were enough to reproduce it.
The escape hatch documented for "bisecting a suspected false positive" therefore could not be used on most real programs. It survived because nothing in the tree ran the flag: not build.sh, not the 906-file corpus, not a workflow. compiler/tests/nobck_closure.nu now runs it over five closure shapes — statement body, value body, an owned-string binding, a capture, and a closure nested in a closure — and the runner gives any nobck_* test the ordinary COMPILE / LINK / EXIT / OUTPUT record, because the flag has to leave a program not just compiling but running. Verified the test panics on the pre-fix compiler and passes on the fixed one, and swept --no-borrowck over the corpus, the stdlib, the examples and every package source: 1413 files, zero panics.
nurl_free was quadratic in the number of live allocations inside a
recover extent.** The panic-unwind journal records every owned heap value registered for auto-drop while a recover frame is active, and nurl_free removes the pointer again — by scanning the array and NULLing every match. The indices are load-bearing (recover frames hold marks into the array), so removal could not compact, and the entries were left in place. Under LIFO churn — which is what a request handler is: a temporary pushed, freed a few instructions later — the tail of the journal became a growing run of dead slots that every subsequent free walked again.
Measured on an embedding server whose handler serialised a 65 000-float response inside its panic→500 guard: the journal reached 65 000 mostly-NULL slots and nurl_free's scan was **95 % of a 3.9 s request** (perf put 94.9 % of cycles on the cmpq/jne pair of that loop). It now pops the trailing NULLs after every removal, so the journal stays the size of the LIVE set — one entry, in that server's steady state, instead of 65 000. The same request: 3.9 s → 55 ms.
Only trailing dead slots are dropped, so no live entry ever moves and no mark is invalidated: a mark above the shrunken length has nothing left to truncate or drain, which is exactly what it had before. compiler/tests/recover_basic, recover_unwind and div_by_zero_recover reproduce their goldens unchanged, under ASan too.
unikernel/k8s/deploy-kvm.yaml, built by build_image.sh --tls. The same server.nu serves TLS when the launcher hands it cert= and key=; the handshake is stdlib/std/tls.nu, pure NURL with no libssl, which is why it links into a machine with no operating system. It is also the workload that justifies the KVM device plugin: a handshake is where a guest spends real cycles.
The server key is P-256 and that is not a preference. Measured in the guest, same image, same handshake, only the key type differing: RSA-2048 costs ~490 ms per handshake, P-256 costs 4–11 ms — ninety times, and the difference between a TLS endpoint and a demonstration that TLS is possible. --tls generates a self-signed P-256 key per build, so no private key is ever committed.
TLS handshakes in six seconds, same image and key: **KVM 856, TCG 469**.
unikernel/k8s/kvm-device-plugin.yaml — a device plugin so the
guest can be given /dev/kvm without giving its pod the node. A workload adds devic.es/kvm: 1 to its limits and stays exactly as unprivileged as it was; the scheduler, not a hand-maintained nodeSelector, knows which nodes have the device.
Measured on one node, same image and same pod spec with and without it — 2000 sequential requests, CPU read from the container's own cgroup rather than sampled: **276–306 µs of CPU per request against 940–991 µs**, idle 5 millicores against 22, boot 35–45 ms against 53–97 ms. The wall-clock difference (1.15 ms against 1.48–1.82 ms) is the least interesting number of the set.
Two things the README says out loud because both fail quietly. The device arrives owned by root:kvm 0660, so a container running as a non-root user needs supplementalGroups with that node's gid — and without it QEMU simply emulates: the pod comes up, passes its probes, serves correct answers, and is slower. And requesting the resource collapses a deployment onto the nodes that have it, trading availability for speed in a one-line diff.
stack_arp_prime in
PR #1018 resolved the gateway before anyone needed it, which covers every peer that arrives through a router. It does nothing for a peer on the machine's own subnet, and that case was measured rather than reasoned about: Firecracker, a tap, three static addresses in one /24, ip= on the kernel command line so no DHCP server answers on the stack's behalf, and tcpdump timestamping both ends of the exchange (unikernel/tests/arp_learn_exp.sh, manual — it needs root).
The first client cost nothing: Linux ARPs for the guest before it sends anything, and the ARP handler already learns a sender that asks for us — RFC 826's receive side, long since implemented. The second client cost 858–1000 ms, because by then the host's neighbour entry was valid, no second ARP went out, and the guest had never heard of that address.
The wire says the interesting part. The ARP the guest sent in the SYN-ACK's place was answered in 0.3 ms; the SYN-ACK itself left 856 ms after that, when TCP's retransmit timer fired. Resolution was never the cost — the wait was entirely the timer.
__learn_sender now takes the sender's hardware address from any IP frame addressed to us, under three guards: unicast sender MAC only, on-subnet sender IP only (off-subnet, the Ethernet source is the router's MAC and binding it to a host behind it records a next hop that is not one), and never our own address. Second client: 3.7–7.9 ms, and no ARP request for it at all.
compiler/tests/net_frames.nu keeps (Δt, frame) rather than the frame: a golden that records only what came back passes whether the answer left at once or a second later, which is exactly how this hid. The new assertions report NO against the old code.
ip=A.B.C.D/prefix and gw= on the kernel command line — a guest
told what it is instead of asking. With ip= the DHCP client does not run at all. Needed by any network with no DHCP server, and by any measurement of the stack that wants nothing else answering for it.
unikernel/k8s/. One server.nu builds two ways and not a line of it differs between them: build_image.sh makes it a 616 KB bootable PVH kernel with a hypervisor in the container, build_static_image.sh makes it a 1.5 MB static binary in a `FROM scratch` container of 1.56 MB. Both run as ordinary pods — runAsNonRoot, all capabilities dropped, no privileged flag, no /dev/kvm, no runtimeClass — because QEMU's user-mode network stack is userspace code and needs no tap device.
Three defects, none of which a "does it answer" test could see, and all three found by deploying rather than by testing:
* dhcp_handle moved SELECTING → REQUESTING on an OFFER and set next_send_ms = now + dhcp_retry_base_ms, so the REQUEST — the answer to an offer already in hand — went out four seconds later. Sixty-eight state-machine assertions passed the whole time: the client bound, with the right address, lease and timers. Nothing asserted when. compiler/tests/net_dhcp.nu now does. * The first frame to an unresolved next hop is never the frame: stack_tx_ip4 sends the ARP request in its place and leaves the retry to the caller, "which for TCP is free, because a segment that did not go out is exactly what its retransmit timer is already for". Free only when someone is already waiting — for a server that segment is the SYN-ACK of the first connection it ever accepts, and a machine that had been listening for 40 ms answered its first client 1000 ms later. New stack_arp_prime resolves a hop with nothing pending; the guest primes its gateway at the end of DHCP. * server_run serves one connection at a time, keep-alive, to a 30 s idle timeout. A pod has two clients before it has any users — the readiness probe and the liveness probe — and one idle curl was enough to time the kubelet out and get the pod replaced. Both builds now use server_run_async, which needed nurl_fiber_spawn_owned in unikernel/runtime_bare.c: without it the freestanding target could not link the async server at all.
Together the first two took the cold start from 5.1 s to a first answer down to 66 ms. unikernel/k8s/smoke.sh asserts the budget and the held-open connection rather than printing them.
Also here: the TSC frequency under TCG is the host's, not QEMU's documented 1 GHz, so entrypoint.sh derives it — passing 1000000 on a 3.5 GHz host made every guest timer run 3.5× fast, which is what made the DHCP delay look like a working boot.
containers/dev/, with a .devcontainer/ wired to the same image. ./containers/dev/run.sh drops you into a shell with the whole toolchain and the repo bind-mounted at /work.
The compiler itself asks for very little: clang, and the committed bootstrap IR does the rest. Everything around it does not. The unikernel wants QEMU, GRUB, xorriso and mtools; the cross targets want zig; the Workers under registry/, webdocs/ and nurlweb/ want Node, pnpm and wrangler; the benchmark comparisons want Rust; the metamorph and reference gates want Python with numpy. That is a long afternoon of installing before the first interesting failure.
It is deliberately NOT containers/ci, which stays the union of the three Linux jobs' package lists and nothing else — a convenience package added for a human must never change what CI tests. This image starts from that same set and adds the rest of a working day, including gh, a debugger and Claude Code.
Verified by running the real gates inside it against a real checkout: build.sh (bootstrap fixed point + corpus), run_nolibc_tests.sh, the nolibc unit gates, and the QEMU guest suites on x86 (29/29) and AArch64 (19/19). 3.6 GB; three build ARGs (INCLUDE_RUST, INCLUDE_QEMU_CROSS, INCLUDE_WRANGLER) trade capability for size.
Two of the three defects in it were only visible from running it. corepack's pnpm downloads itself on first use into ~/.cache, which the launcher then hides behind a named volume — so every fresh container would have re-fetched it and an offline one would have had no pnpm at all. And busybox was missing: packages/nurlbox uses it as its differential oracle and skips when it is absent, which reads exactly like a suite that passed.
packages/nurlbox. One multi-call binary carrying 100 of the everyday UNIX utilities, dispatching on argv[0] exactly as busybox does; nurlbox --install [-s] DIR fills a directory with one entry per applet, which is how one file becomes a userland. It builds for every target the toolchain has, and it **boots as its own kernel** on the unikernel one.
The applet that matters most is sh, and it is a real one: per-byte quote tracking (so "$@" and echo "a b" are right), ${VAR:-…} / ${VAR#pat} / ${#VAR}, $(…), backticks, $((…)), globbing, field splitting on IFS, pipelines, redirections including 2>&1 and heredocs, && / ||, if / while / until / for / case, functions, and the builtins a script cannot be written without.
ONE DECISION SHAPES IT: an applet runs in-process. When a command names one of nurlbox's own, the shell calls it directly instead of exec'ing itself. That is busybox's standalone-shell trick, and here it is what makes the shell work on a machine with no fork at all.
The specification is the original, and the suite says so: **348 differential cases** — 48 of them through sh — run the same command line through nurlbox and through the system busybox and require the two to agree on stdout, on the bytes, and on the exit status. The mutating applets are compared by the resulting tree — names, contents, permission bits — because that is what a cp actually outputs. Where busybox does not implement an option (cat -E, cat -s, nl -n, cksum) the reference is GNU coreutils instead. Leak-clean under AddressSanitizer with detect_leaks=1 across every applet and every shell construct.
missing.** Every hole nurlbox hit was closed where it actually was — in the stdlib, for every caller — rather than worked around in the package:
* stdlib/std/fs.nu — stat(2) did not exist at all. FileStat plus fs_stat / fs_lstat / fs_fstat over a new fixed-layout runtime thunk, stat_mode_string, fs_set_times, fs_user_name / fs_group_name; and FsStat + fs_statfs — df's question — through a statvfs thunk. Plus fs_match / fs_match_glob, the wildcard matcher that was private to fs_glob. * stdlib/ext/regex.nu — no capture groups. The engine is now a Pike VM with capture slots: regex_find_caps, regex_ngroups, regex_expand (&, \1..\9). sed's back references are the reason; every caller gets them. * stdlib/std/time.nu — UTC only. tz_offset / tz_name / time_local ask the platform for the one thing that is not arithmetic; time_format grew `%e %F %T %D %R %I %p %C %u %w %k %l %h %n %t`. * stdlib/std/sysinfo.nu — new: uname fields, host name, processor count. * stdlib/ext/env.nu — the environment could not be enumerated: env_count / env_entry / env_list. * stdlib/std/bufio.nu — bufreader_read_line_raw, a line WITH its terminator, so a filter can copy a CRLF file without rewriting it.
shell on a machine with no host OS needs more than a serial port, so unikernel/ gained:
* stat(2) and directory listing over the baked-in archive, as a union with the disk — read resolution is archive-first, so the listing is too. ls -l, find, du, stat and test -d answer there, and uname -a says NURL unikernel. * a working directory, with every path-taking entry point resolving against it and folding . / .. — which is what cd is, and what made mkstemp's ./tmp.XXXXXX openable. * a redirection table for descriptors 0–2 behind dup / dup2 / fcntl(F_DUPFD), so >, 2>&1, $(…) and pipelines work in the guest. A pipeline there runs stage by stage through a temporary file and says so, because a machine with one address space cannot run two things at once and should not pretend to. * the rest of the libc surface, each real where the target can be real and refusing out loud where it cannot: realpath, fdopen, link/symlink/readlink, chmod, getcwd, the uid/gid quartet, getgroups, uname, sysconf, statvfs, utimensat, localtime_r.
The nolibc corpus goes from 505 to 516 PASS / 0 FAIL and the NEEDS-NOLIBC list from 9 to 5; the QEMU guest suite stays 29/29.
from** — cli 0.2.1, http 0.4.0, yoloe 0.6.9. Every in-tree manifest version equalled its published version; these were the three whose source had moved after the last bump and nobody bumped again. cli carries the CliCtx-before-CliCmd declaration order that the Windows toolchain requires (0.2.0 forward-references it, and every dependent — psql, redis, yoloe — inherited that). http publishes http_app_async, fiber-per-connection serving on the M:N runtime — new public API, so a minor; dependents pinned ^0.3 keep resolving 0.3.2 and opt in when they choose. yoloe declares tensor, gpukit and gpu in its manifest instead of making the resolver infer them.
A published version can never be replaced, which is what made 0.6.8 cost 0.6.9: the bump touched the manifest but not cli_new's hardcoded version literal, so --help announced a version that was already stale. The literal follows the manifest — grep it before bumping.
coroutine stack is allocated with a guard page below it, and arming that guard clears the present bit in its PTE. When the coroutine ended, munmap returned the whole range to the page allocator — and touched no page tables, so the guard page went back into the pool with that bit still clear. The pool handed it to somebody else, nolibc's malloc carved an arena across it, and the first write to a block that landed there took a page fault inside malloc, long after the coroutine that protected it had gone.
Reachable by anyone who could open a TCP connection: twenty connections that sent one byte and hung up killed a server. Narrowed by measurement rather than reading — the same attack against the sequential server_run did nothing, which put it on the fiber path; it survived swapping spawn_owned for spawn, which cleared the closure-environment free; and nm put the faulting instruction inside malloc, on a write to a not-present page in the middle of an ordinary heap. Guest memory size made no difference, so it was corruption and not exhaustion.
munmap now restores a range's protection before releasing it, in that order, so a range is never on the free list while it is unmapped — with a new pa_owns to ask the ownership question first, because a file mapping points into the image and its page tables are not ours to rewrite. All three architectures had the same munmap.
unikernel/demos/guardpages.nu is the gate, and it needs no network, no TLS and no timing: coroutines come and go, then the memory they gave back is allocated and written. The write is the assertion. Against the old munmap it faults; against the fixed one it prints what it wrote.
unikernel/tests/hypervisor_gate.sh could not actually boot
Firecracker.** From v1.16 the one-shot JSON config requires a drives field even when it is empty, and without it Firecracker refuses the config — which the gate reported as a failed boot of a perfectly good image. Nothing noticed, because the gate SKIPs when firecracker is not installed and CI does not install it. With the field present it now says PASS firecracker booted the unchanged image, which is the first time anything in this repository has watched a hypervisor other than QEMU boot the image rather than only checking the PVH note.
( string_from ( nurl_str_slice … ) ) leaked its inner buffer — in every program that wrote it, which after a userland's worth of string handling is a great many. mem_consumer_copy_safe in compiler/nurlc.nu now covers string_from, the path_* family, the string inspectors and nurl_eprint, audited one by one, with string_from_take deliberately excluded because it has string_from's shape exactly and ADOPTS the buffer instead of copying it. The bootstrap fixed point holds across the change. docs/MEMORY.md §7.4 gains the seam that cost an afternoon to find: a CAPTURING closure passed straight to a generic callee is not proven invoke-only, so its environment is the caller's to free.
file_delete could not remove a dangling symlink. It probed with
access(2) first, which FOLLOWS the link, so a broken one was "not found" and never unlinked — and every rm -r over a tree containing one then failed at the directory with ENOTEMPTY. In the same sweep: nurl_dir_create hard-coded 0755, overriding the umask, and fs_tempfile emitted //tmp.XXXXXX for a root directory.
path_dirname "foo"
answered "", path_basename "/a/b/" answered "", and path_join "/" "x" answered "//x". Each is what a path caller then joins back on, so each was a wrong path rather than a wrong string. compiler/tests/path_basic.nu covers the new cases.
break and continue work inside a foreach body.
docs/spec.md §5.4.1 scopes both to "the innermost enclosing ~ loop body", and a foreach is a ~ loop body — but only the while form ever published the labels gen_loop_jump reads, so every foreach rejected both outright:
` ~ x xs { ? > x 3 { break } {} = s + s x } ^ 'break' outside a loop — there is nothing to leave. `
leaving no way to stop an iteration early other than rewriting the loop as an index-counted ~ < k ( vec_len … ).
The reason it was never wired up is real: a while loop can send continue straight at its check block, because the induction variable is the program's own binding, while a foreach owns a hidden index whose bump lived in the body's fall-through — a continue branching at the check block would skip the bump and spin forever. So the bump gets its own label, the continue landing pad, reached by both the fall-through and every continue; it re-loads the index rather than reusing the check block's register, so the block reads correctly whichever predecessor arrived (-O2 folds the reload away). With that in place the foreach publishes exit/continue labels and the same five drop snapshots gen_loop does, saved and restored around the body so the innermost loop wins and the outer one comes back — a % Drop value at the top of the body is released exactly once on each of the three paths out.
= . . o inner x 7. Readinga nested path always worked; writing one did not:
` = . . o inner x 7 error: cannot assign to a field of '': it is a by-value NIn with no storage to write through … `
The empty name is the tell — the object's name was read from a token that was a ., not an identifier. The single-hop store takes the object's address from the <name>__ptr alloca a :-bound struct owns, and a nested path has no such name, because its object is a field path that gen_expr turns into a by-value register — which is not somewhere a store can land. A struct inside a struct could therefore only be written by copying the inner one out, mutating it, and storing it back whole.
gen_nested_lvalue_addr walks the chain by address: one GEP per navigation field, handing the last container's address to the ordinary store dispatch, which does the store, the width coercion and the diagnostics exactly as it does for a one-hop store — nothing about the store itself is duplicated. The walk is driven by types, not by counting dots: = . . d op + . d olen k # u byte in stdlib/std/zstd.nu is also two dots, but there the field is a *u and what follows is an index expression, so a hop that is not an aggregate IS the object and gets loaded and handed over as a value. That is how the pointer, slice and struct-pointer spellings keep working unchanged. Whether to walk at all is decided before anything is consumed, since the lexer does not rewind — the base identifier is read out of the peek slots (nurl_lex_peek2_val is new, one slot further out than nurl_lex_peek_val) and the walk is taken only for a by-value struct binding that owns its storage. A pointer binding, a by-value parameter or a deeper root keeps the old path and the old diagnostics, so nothing that compiled before takes a different route. An inout parameter does qualify: its __ptr IS the caller's address, so writing through it is the point.
MUST reject** (tools/fuzz/genreject.py, FUZZ_GEN=reject). The two existing generators emit valid programs and ask whether the answer is right. Nothing asked the opposite question, which is the one a borrow checker is actually judged on: does an invalid program get caught? That failure mode is silent — the program compiles, runs, and corrupts the heap — so only a deliberate hunt finds it. The generator crosses a small set of violation cores (alias double free, one binding freed twice, use after move, String double free, loop-carried free, iterator invalidation) with the contexts the language offers (? then/else arms, ~ loops, foreach bodies, bare blocks, ; defers, ?? arms, helpers, generic bodies, trait methods, closure bodies) and nests them: one bug written every way the language allows. The oracle is a diagnostic marker, not an output — the compile must fail and the message must be the one that violation deserves, because rejecting the program for an unrelated reason would let a syntax error in the generator pass for a working checker. It found two soundness holes on its first sweep (both fixed below, each accepted by the compiler and each segfaulting in the allocator at run time) and a third finding that became the [T] diagnostic. 400 seeds at depth 4 are clean now.
findings shrink to minimal reproducers.** The structural fuzzer had gone quiet — 250 seeds, zero findings — not because nurlc is clean there, but because the generator's grammar stopped at scalars: a struct of sized ints, an enum of scalar payloads, a while loop and a closure. Nine new statement kinds, each with an exact oracle, reach the rest: generic (instantiated at every sized type, so one template body must pick sdiv for [i8] and udiv for [u], and at a concrete struct whose name is tparam-shaped), option, result through a \ try-propagation chain, trait (required method, default method, several impls, a bounded generic reaching the default through the bound), nested, loopctl, vec_struct, nested_closure and early_return — the last two aimed at the lifted-function boundary and the drop-on-jump machinery. It found a miscompile in 3 of the first 25 seeds (fixed below).
A finding is worth what its reproducer is worth, and a raw seed is 150–400 lines across a dozen unrelated features, so tools/fuzz/reduce.py deletes statements while the same failure survives and fuzz.sh calls it on every finding — failures/ now carries a *.min.nu beside the full seed. The closure miscompile came out of a 397-line seed as 23 lines in 19 seconds. Two things make it work rather than merely run: the property is fingerprinted from the original failure (the normalised error message, the ASan report kind, or the specific expectation that disagreed) and every candidate must reproduce that — "does not compile" is true of almost any mangled program — and genprog.py --selfcheck emits the program with each expected value embedded next to the expression that produces it, so the program is its own oracle and deleting a line cannot desynchronise it. That is the only way to shrink a "wrong at every optimisation level" miscompile, which is the class this fuzzer exists for. Every knob is a workflow_dispatch input now, seed_start included — a weekly run pinned at seed 1 re-tests the same programs forever.
[T]-is-also-true
collision.** T and F are the boolean literals and the lexer is context-free, so a bare T is TT_BOOL in type position and value position alike. Monomorphisation rewrites a type parameter through the body by whole word, before the body is parsed, so a template written [T] has its value-position Ts rewritten along with the type positions:
` @ g [T] T x → i { ? T { ^ 1 } {} ^ 0 } ( g [i] 1 )
error: use of undefined identifier 'i' @ g__i64 i x → i { ? i { ^ 1 } { } ^ 0 } `
— an error against source the user never wrote, naming a token they never typed, where renaming the parameter to A makes the identical program compile. The stdlib avoids the collision by convention (stdlib/core/vec.nu names its type variable A and says why); nothing warned anyone else, and spec §4.8 spells its own canonical example [T]. The diagnostic now names the cause and the spec says not to name a parameter T or F when the body uses that literal. The note is earned rather than blanket — it appears only when the parameter is T/F and the body actually spells it after a ?, ~ or ^, the three prefixes that take a value — so an unrelated error inside a generic is not buried under a guess. What is not fixed is the ambiguity itself: removing it means resolving T by position in the parser instead of rewriting the body textually before parsing it, which is a rework of monomorphisation and is left for its own work.
was re-sent from the front — 1.1 MB of payload put 37.7 MB on the wire.** A wasm compute kernel dispatched to a swarm-mcp worker on **another machine** always failed with `payload failed the cluster HMAC check (wrong --token?)`. The token was fine. Measured on a three-node swarm (Linux x86-64, FreeBSD 14.3, ARM64): a worker co-located with the relay always passed, a worker on a different host always failed, on every platform and in both directions; expression kernels (a few bytes) never reproduced it, only wasm kernels (~1.1 MB) did.
nurl_tcp_write loops internally and counts what it has sent in total. On a non-blocking socket — which the fiber write path sets — send() returns EAGAIN partway through a large buffer, and the EAGAIN branch returned -1, discarding total. The bytes were already on the wire; the count was the only record that they had gone. tcp_write_all_async then saw an error, parked on the reactor, and retried from its own sent, which had never advanced — so it re-sent the buffer from the front. Because callers frame their writes (relay.nu puts a 5-byte type+length header in front of every message), reading the announced length spliced that repeat into the middle of the message: a frame of exactly the right size with the wrong bytes, and every frame after it garbage. The integrity check caught it and blamed the token, which is why this read as an auth problem. Loopback hides all of it — the peer drains as fast as we fill, send() never blocks, the whole buffer goes in one call — which is why every single-machine test passed.
stdlib/runtime_ffi.c now reports total when a non-blocking send would block after partial progress; -1 means "nothing was written", and Windows gets the same rule for WSAEWOULDBLOCK. stdlib/std/net.nu had three related gaps on the NURL side, each of which could desync a framed stream on its own: tcp_write_all (sync) issued one write and reported success without comparing it to the length — its async twin already looped, so the name promised the loop and the body did not have it — tcp_write_str had the same single-write shape, and tcp_write_all_async gave up on a reactor timeout even after putting part of a frame on the wire, where a timeout is now fatal only while nothing has been sent yet (the mirror of the rule __read_exact already applies on the reading side). Measured on one 1.1 MB relay frame: 37,713,045 bytes reached the peer where 2,249,298 were asked for, about 17 copies of the prefix; after the fix, 2,248,717, and the received module is byte-correct where it used to diverge exactly 64 KiB in, at the first socket-buffer boundary. Verified with 5/5 consecutive remote jobs across three physical machines and two architectures.
?? arm compiled clean and segfaulted in the
allocator at run time.** The checker rejected the shape inside a ? arm, a ~ loop, a foreach body, a bare { } block, a helper, a generic body, a trait method and a defer — but not inside a match arm. The reason was principled, and the code said so: a ?? arm binds payload variables (T v → …) that have no let row, so descending into an arm with the outer, flat name-keyed state would conflate an arm's v with a same-named binding outside and report a bug that is not one. The whole match was therefore a state-preserving black box.
The conflation is avoidable without widening the lattice: walk each arm from an empty state. Only a binding that gets its own let row inside the arm becomes tracked, and such a binding is arm-local by construction, so nothing outside can be confused with it; a payload name, or an outer name the arm merely touches, starts Uninit and is ignored, while moving an arm-local binding twice is still caught because the first move is what makes it tracked. The arm's exit state is discarded and the entry state returned unchanged, so what an arm does to an outer binding stays out of scope exactly as before, and the no-false-positive contract holds: everything reported is still a definite bug.
Same list of contexts, same silent failure — a double free written into a closure body compiled clean and segfaulted in the allocator, confirmed under AddressSanitizer. The cause was one flag doing two jobs. g_bck_closure_depth != 0 meant both "this statement belongs to the closure, not to the enclosing function" — true, and what the capture and summary logic asks — and "record nothing at all", which switched the checker off for everything written inside a closure. The code's own comment said as much: "Phase 1 must segregate closure scopes."
Segregate them, rather than suppress them. A closure body is its own function, so it gets its own recording context and its own analyze pass: save the enclosing function's capture state, start a fresh one, let the body record into it, walk it, put the outer one back. The ten capture hooks now gate on a separate g_bck_rec_off, leaving g_bck_closure_depth to the places that genuinely ask about closure ownership. The closure's parameters seed the walk exactly as a function's do; a capture deliberately does not, because what a closure does to a captured handle is the enclosing frame's question and the consumed-capture replay already answers it — so a capture starts Uninit and cannot be reported. A body whose verdict depends on a not-yet-known summary is parked with bck_defer_fn, the same rule borrowck_fn_end follows, so nothing is walked twice.
literal is lifted to its own define, but it is emitted in the middle of the enclosing function's emission and inherits that function's symbol table. gen_closure_expr shadows the slice and closure-env rosters with empty lists for exactly this reason — and left the other three visible: __owned_strings__, __owned_struct_fields__ and __user_drops__. So a ^ inside the closure body ran gen_ret, gen_ret drained those lists, and the lifted function ended by dropping values that belong to the outer frame:
` define i64 @__closure_1(i8* %__env, i64 %x) { … %r7 = load %DH, %DH* %r2 ; %r2 is main's alloca — not here call void @drop__DH(%DH %r7) `
clang rejected that as "use of undefined value"; had the register resolved it would have been a use-after-scope plus a second drop of a value the outer frame drops again on its own exit. The shape needs an owned value live at the point the literal appears and an explicit ^ in its body — an expression-bodied closure never reaches gen_ret — which is why it survived: nothing in the corpus put a % Drop value at function scope and then wrote \ i x → i { ^ … } after it. All three rosters are now shadowed the way the other two already were.
is_tparam_like answers a question about spelling — one uppercase letter, optionally followed by a digit, the project's tparam convention ([A], [K V], [T1 T2]) — and ensure_struct_instantiated used that answer to skip instantiations whose arguments are "still abstract". So a user struct named P or Q3, both legal names, was mistaken for a type parameter and its instantiation was never materialised: ( Vec P ) then referenced a %Vec__P that nothing defined, and the program died with stdlib/core/vec.nu:201: type 'Vec__P' has no field 'ctl' — pointing into the stdlib, with the real cause (the length of the user's own struct name) nowhere in the message, and renaming the struct to Quux making the same program compile.
Ask scope instead of spelling: is_abstract_tparam treats a tparam-shaped token as abstract only when it is not a type in syms. A declared struct or enum maps to %Name there; a genuine tparam has no binding at all, either inside a template body (substitution has not run yet) or at a nested instantiation site such as ( Vec A ) in a generic function. That required the names to be in syms before the instantiation scan reads them, so the purely-lexical scan_type_names pass now runs ahead of scan_generic_structs instead of after emit_header — it prints no IR and only writes Name → %Name, so nothing else moves.
packages/gpuand everything stacked on it links there.** Three separate holes, each of which stopped a program at the linker rather than at a diagnostic:
- build.bat never wrote the runtime.cuda / runtime.nvrtc sentinels. build.sh writes both unconditionally — both libraries have stub fallbacks, so the sentinel says "this declaration is allowed", not "a Toolkit is installed" — but without them nurlc rejected every & cuda declaration on any Windows box lacking a Toolkit, which is most of them. - nurl.bat now links CUDA/NVRTC the way nurl.sh does: a real Toolkit gives -lcuda -lnvrtc, and otherwise stdlib/cuda_stubs.c goes in as source, so the stub ABI follows whichever compiler the driver selected (clang/MSVC or the bundled zig cc/MinGW). The display driver's nvcuda.dll is not a Toolkit substitute and is deliberately not used: its export directory names itself nvcuda_loader.dll, so linking the DLL directly imports a filename that does not exist and the .exe dies at load. - stdlib/dl_win32.c (new) forwards dlopen / dlsym / dlclose to LoadLibraryA / GetProcAddress / FreeLibrary. gpu's CPU backend binds the POSIX loader, so a program that merely imports gpu — anomaly → tensor → gpukit → gpu, none of them asking for a GPU — died at link with undefined symbol: dlopen. The file is #ifdef _WIN32, so the translation unit defines nothing on Linux.
the two foreign frontends catch up with NURL's own.** packages/nwasm 1.0.1 → 1.0.8, seven releases of the JIT introduced in 0.51.0.
The 0.51.0 entry named its own coverage boundary: a function containing one record outside the template set falls back wholesale, and the C and Rust nbody modules each tripped a single opcode in their hot loop and ran interpreted — 9.98× and 10.06× the reference Cranelift JIT where the NURL module was 1.28×. That boundary is where this round went. Over the same bench/wasmbench.sh corpus, same protocol, same CI runner class:
| Column | 0.51.0 | now |
|---|---|---|
| NURL geometric mean vs reference | 0.78× | 0.78× |
| NURL rows at or below parity | 11/15 | 12/15 |
NURL fib (worst row then) | 1.44× | 1.09× |
| C geometric mean | 0.90× | 0.73× |
| Rust geometric mean | 0.89× | 0.73× |
C / Rust nbody | 9.98× / 10.06× | 1.04× / 0.95× |
NURL's own geometric mean is unchanged — the rows shuffled and the call-dominated worst row improved — but the control columns moved a long way, which is the point: the runtime is no longer measurably better at modules from the frontend that wrote it.
What got it there, in order:
- Template coverage. f64.abs / f64.neg as sign-bit arithmetic on the raw slot (btr/btc, no xmm), then the whole trapping division family — i32/i64 div_s, div_u, rem_s — mirroring the interpreter's exact trap semantics, including the div_s INT_MIN / -1 overflow stub and MIN rem -1 not trapping. Those were the last integer operators that dropped a function to the interpreter; they sit in libc formatting code today and in someone's hot loop tomorrow. - A shorter emitted encoding. Post-call status checks test the common case first, ALU immediates use the imm8 spellings, constant pool slots whose every use is an immediate skip their prologue write (proved by a scan, not assumed), and a per-Interp anchor block rides in r8 so the slab bump, the frame free and the direct-call table load lose their movabs+indirection pairs. The templates are front-end bound, so encoded bytes are cycles. - The pause chain is gone. Call-outs — imports, memory.grow, call_indirect, the FCB bridge — used to park every native frame and resume it from the driver, and the mere possibility forced every direct call site to carry a status-propagation tail and two resume entry points. They now happen in place, through a C-ABI bridge closure with the native frames intact. Traps take the same shortcut: generated code enters through a setjmp-guarded runtime entry, and a trap stub longjmps the whole native chain back to it instead of returning a status frame by frame. The guard-page SIGSEGV handler composes with it — it still steers the faulting frame to that function's out-of-bounds stub, and the stub longjmps. A direct call site is now call+jmp. - Arguments and the frame base travel in registers. A one-parameter direct call passes arg0 in rdx, and the callee's frame base arrives in rcx instead of round-tripping through the anchor block — a store-forward on the recursion critical path, once per call depth, feeding the register every slot access depends on. Locally fib 100–107 ms → 94–98 ms on the frame-base change alone. - A wider pin register file. r10 (byte count) and r9 (globals base) join r12–r15 when the function provably cannot need them for their standing role — no memory.size under guard pages, no global.get/set. Both are caller-saved and cross-call invariants, so they must earn double the pin threshold to cover the sync traffic. Locally binary_search 111–119 → 106 ms, hash_join 103 → 95 ms. - Dead compare results are not materialised. A compare feeding fused SELs used to setcc/movzx/store its 0/1 for the benefit of distant readers; a per-slot liveness walk over the record CFG now proves when there are none, and the store disappears. Locally binary_search 106–113 → 98 ms, sieve −5%.
Every step is gated the same way: 15/15 corpus outputs byte-identical, all suites in all four engine modes, nurlc.wasm self-compiling to byte-identical IR, differential fuzzing against the reference runtime, and 400k-deep guest recursion still degrading to the interpreter cleanly.
The toolchain side of this release is what makes it installable: nurl_call_code2_sj and nurl_code_trap_addr are new stdlib/runtime_core.c primitives, so nwasm ≥ 1.0.6 cannot be published to the registry until this toolchain ships — the same dependency that held nwasm 1.0.1 back behind 0.51.0.
fs_rename honoured its documented replace contract on POSIX only.
The doc comment promises "an existing to is replaced (POSIX rename semantics)"; Windows' CRT rename(2) fails EEXIST and leaves both files where they were (measured: rc=-1 errno=17, destination unchanged). That silently breaks the write-to-.tmp-then-rename-over pattern the function exists to serve, and breaks it in the worst shape — the caller sees a successful write, a stale file, and a .tmp accumulating beside it. Every atomic writer in the tree rides on it: anomaly, cas, gpu's kernel cache, hub, lsmdb, nurllama, nwasm, wasmbuilder. The symptom that found it was an anomaly train that succeeded and then crashed, because its metadata never reached disk.
The divergence is absorbed in fs_rename rather than at each call site: on EEXIST specifically — not on "did it fail" — drop the destination and retry. The gate is what makes that safe. The only POSIX rename reporting EEXIST is a directory onto a non-empty directory, and remove(3) refuses exactly that (verified: `rc=-1 ENOTEMPTY`), so the retry cannot destroy a destination on POSIX either; EXDEV/EACCES/EBUSY map to other kinds and never enter the branch. Verified byte-identical behaviour on Linux across replace-existing, fresh rename, missing source (still NotFound) and dir-onto-non-empty-dir (destination intact).
The retry is not atomic the way rename(2) is: a crash between the remove and the rename loses to while leaving from in place, so the new content survives under the temp name and a concurrent reader is the one that loses. MoveFileExA with MOVEFILE_REPLACE_EXISTING closes that window but needs a kernel32 FFI declaration behind a build-time sentinel no POSIX build has reason to produce; the comment says so rather than leaving the caveat unwritten.
cuda_stubs.c's 41 bound symbols were missing — and this broke
Linux too.** packages/gpu/src/cuda.nu binds 41 & cuda symbols; the driverless stub file defined 16. It linked only because dead-code elimination dropped the declarations nothing called: a program that actually calls gpu_open fails on Linux with undefined references to cuDevicePrimaryCtxRetain, cuMemGetInfo_v2, cuEventCreate, cuStreamCreate and more. Now 41/41 plus the legacy pre-_v2 aliases, and the file carries the grep that keeps the two lists in sync. The same program now links and runs, reporting the CPU backend.
instead of trapping.** Found by the differential fuzzer (seed 30047): the segment offset is a u32, so a negative canonical i32 is far out of bounds, and even an empty segment past the table end must fail instantiation — as the reference runtime does. It is now a decode error, matching the existing data-segment check. Two adjacent holes closed the same way rather than misexecuting: a module declaring more than one table (only table 0 is materialised, so table-1 operations silently hit table 0) and an element segment targeting a nonzero table.
nwasm's "mode matrix" test runs were four copies of the same
pure-interpreter run.** The suites constructed Interps directly and never called interp_enable_jit — NURL_NWASM_JIT/PIN/GUARD are read by the CLI, not the library — so NURL_NWASM_JIT=0 changed nothing and the 41 semantics edges never touched the JIT at all. Each wasm-executing suite now mirrors the CLI's engine-mode switches, so the matrix means what it says. Two regression modules pin the const-expr fuzz findings behind it: f64/f32 globals whose bit patterns contain 0x0b bytes (the old const-expr scanner desynced on them), ref.func and ref.null inits with integer globals after them as desync canaries, a two-result function checked in stack order, and an externref table that decodes, sizes and reads null.
packages/cli's CliCtx is declared before CliCmd. CliCmd's
handler takes a CliCtx by value and nurlc emits %Name = type in source order, so the IR forward-referenced it — rejected on the Windows toolchain with "invalid type for function argument". clang 18 on Linux accepts the forward reference, so the reorder is defensive rather than load-bearing there, and the comment says that rather than overstating the rule.
bench/sort_window.nu's header claimed a cost the benchmark does not
pay.** It said the NURL implementation buys a heap window where C and Rust use a stack array. Measured: LLVM -O2 already elides the malloc/free pair and SROAs the window into registers in both the native and the wasm artifact, so the claim does not describe what executes. A prototype heap-to-stack pass in nurlc confirmed it from the other side — no change on wasm, ~19% regression on native, LLVM optimising the malloc form better than a pre-promoted alloca.
production Cranelift JIT over the benchmark corpus.** packages/nwasm lowers its predecoded records to x86-64 per function and runs them natively; the interpreter remains the semantic reference and still executes everything the templates decline, plus every metered (--fuel), shared-memory and non-x86-64 run. On by default; NURL_NWASM_JIT=0 keeps the pure interpreter.
Measured over bench/wasmbench.sh's 15-benchmark corpus against the reference wasmtime running the same modules: geometric mean 0.78× its wall clock, 11 of 15 rows at or below parity (prefix_scan 0.39×, matmul 0.48×, bloom_filter and collatz 0.61×), and the worst row fib at 1.44× — a call-dominated benchmark, which is where a template JIT's per-call protocol still shows.
The C and Rust columns of the same table are the control — modules from two other LLVM frontends, never seen during development. On 14 of 15 rows they track NURL within a few points (geometric mean 0.90 and 0.89 against NURL's 0.78), so this is not a NURL-shaped fast path. The exception is worth naming rather than averaging away: **nbody is 9.98× on C and 10.06× on Rust** where NURL is 1.28×. A function containing any record outside the template set stays interpreted, per function — those two modules emit f64 shapes the templates decline, and fall back wholesale. That is the JIT's coverage boundary, and it is where the next round of templates belongs.
The tiers, each forced by the one before it: - Tiers 0–2 — a fixed x86-64 sequence per record against the frame's slot file (disp32(%rbx)), rel32 branch patching over a per-function label table, br_table as a clamped indirect jump, and a one-register cache that elides the reload when a record's first operand is what the previous record just stored. - Call-out cost — the 7-word call context comes from a per-Interp freelist instead of a Vec, and the JIT hook recycles its frame to the function's pool rather than freeing it. Recursive fib went 1893 ms → 626 ms on that alone. - Tier 6: direct JIT-to-JIT native calls. A guest call between two compiled functions is one native call — no driver round trip, no Frame object, no per-call context. The entry allocates its own frame from a per-Interp slab, copies params through rsi, and writes results back through the saved args pointer on RET. A call-out's status return unwinds the native stack, so each direct frame parks (page, resume, rbx, args) on a chain the driver resumes innermost-first; the final entry of a segment tail-iterates, so a loop that calls out N times uses constant chain space. Slab overflow degrades to the interpreter instead of trapping. - Guard-page linear memory. On Linux/x86-64 (glibc, non-ASan) a non-shared memory is an 8 GiB PROT_NONE reservation with the live pages committed in place, so the memory templates emit **no bounds check at all**: every address a masked 32-bit index plus a u32 offset can form lands inside the reservation, a fault is steered by the runtime's SIGSEGV handler to that function's existing out-of-bounds stub — same trap, same message — and memory.grow is an mprotect that never moves the base. A fault outside every registered region or page reinstalls the previous handler and re-faults, so a real crash stays a crash. NURL_NWASM_GUARD=0 forces the checked path. - Tier 7: function-wide slot pinning. __jit_pin_select scores each slot's static accesses (weighted 8× per backward-branch nesting level), subtracts the sync cost at call sites, and pins the winners into callee-saved r12–r15 for the whole function — no block-edge spills, because the map never changes inside a function. Memory stays each slot's home: pinned registers sync before anything that reads the frame from outside and reload at every resume and post-call join. NURL_NWASM_PIN=0 keeps every slot in memory, and with it the emitted code is byte-for-byte what tier 6 produced. - Tier 7b: pure-f64 slots in xmm8–xmm15. A slot whose accesses are xmm-dominant and never hit a fused or partial-width integer site rides an xmm register for the whole function; plain integer reads and writes go through movq, so a couple of slot copies no longer disqualify a hot f64 slot. Register-to-register copies are movaps, not movsd — movsd merges into the destination's upper half and the false dependency serialized nbody's inner loop (174 ms → 136 ms). Seating past the fourth register must clear twice the threshold, because each one still costs a sync and a reload at every call site. - Constants as immediates. A constant-pool slot is marked in the pin map and its use sites carry the value in the instruction stream — shl/shr/sar by imm8, add/sub/and/or/xor/cmp with a sign-extended imm32, imul r,r,imm32, mov e/rax,imm32. Only compact spellings qualify; a constant too wide for imm32 keeps its plain load. The immediate-servable ones leave the scoring pool, freeing r12–r15 for real variables. - A compare's flags feed the SELs behind it. setcc, movzx and mov leave the flags alone, so when the records right after a compare are SELs keyed by its destination, their cmovs issue on the still-live flags. The chain stops at the first non-SEL, at a branch target, or after a SEL that overwrites the condition slot. Paired corpus 1226 → 1158 ms; binary_search −21 %.
The correctness gate throughout is byte-identical output across the interpreter, the JIT, and every A/B flag combination — including a nurlc.wasm self-compile of the full 65k-line compiler — plus trap parity (message and all) and the ASan/UBSan build running the whole JIT path.
stdlib/runtime_core.c):
nurl_code_alloc / nurl_code_seal allocate and seal W^X executable pages, nurl_guard_code_add registers a sealed page with the guard-page fault handler, and nurl_call_code* enter generated code. A NURL program can now emit and run machine code without leaving the language. **These are what a package's JIT links against, so a package that uses them needs this release** — nwasm 1.0.0 and swarm-mcp 0.28.0 were published before it existed and cannot be built from the registry.
NURL_NWASM_JIT_DUMP=1 streams every sealed page as decimal bytes
on stderr for objdump-based inspection. It is how the immediate-operand and movaps findings were diagnosed, and stays as a tool.
packages/wasmtime is now packages/nwasm, published as nwasm
1.0.1.** The old name is a third-party trademark (the Bytecode Alliance runtime), and using it for NURL's own runtime was a standing source of confusion — most concretely in bench/wasmbench.sh, where a developer box with the toolchain installed had $NURL_HOME/bin/wasmtime on PATH and the harness could fill the reference JIT column with the very runtime that column exists to compare against. Two different binaries can no longer answer to one name.
Nothing about the engine changed. The CLI is unchanged apart from the program name, and still mirrors the subset of the reference wasmtime's interface it covers (run --dir/--env/--fuel/--invoke), so it remains a drop-in for that usage. References to the external wasmtime as the benchmark baseline and the semantics cross-check oracle stay — that is a factual citation of another project, and the reason its expectations are trustworthy.
What moved: - nurlpkg install wasmtime → nurlpkg install nwasm; the installed binary is nwasm, and nwasm --version reports `nwasm 1.0.1 (pure NURL)`. - Environment: NURL_WT_JIT / NURL_WT_PIN / NURL_WT_GUARD / NURL_WT_JIT_DUMP → NURL_NWASM_*. - packages/swarm-mcp 0.28.1 follows the pin (nwasm ^1 — the major, so a patch release of the runtime needs no release here) and, as a breaking change, reads $NURL_WASM_RUNTIME instead of $WASMTIME when selecting an external runtime; the binary that variable falls back to is nwasm, not wasmtime. - bench/wasmbench.sh: the --wt-all-langs flag is now --nwasm-all-langs (and the workflow input wt_all_langs is nwasm_all_langs); report and JSON label the column nwasm, and the JSON keys wasm_wt / wasmtime_ref / wasmtime_nurl became wasm_nwasm / wasm_runtime_ref / wasm_runtime_nurl. $WASMTIME still names the reference binary the harness measures against. - The package README dropped its accumulated optimisation narrative; what the runtime does now is documented, what it cost to get there lives in this file.
Existing changelog entries were rewritten to the new name so the repo reads consistently; the published wasmtime package versions are untouched and what to do with them is a separate decision.
nwasm 1.0.0 and swarm-mcp 0.28.0 were published before this release existed and cannot be built from the registry: the template JIT calls nurl_code_alloc / nurl_code_seal / nurl_guard_code_add, which stdlib/runtime_core.c gained after the v0.50.0 tag, so `nurlpkg install fails at link. nurlpkg publish`'s fifth gate is a front-end typecheck and cannot see a missing C-runtime symbol. Those two version numbers are spent — a version can be yanked but never replaced — so the runtime republishes as 1.0.1 and the engine as 0.28.1 on top of this release.
(−20 % over the corpus) before the JIT existed.** Two record-level wins, both invisible to the JIT tiers above because they happen at predecode: - Every i32-typed slot holds the canonical form (low 32 bits sign-extended) — the invariant i32.eqz already banked on. Under it i64.extend_i32_s is the identity and emits no record at all, and i32.wrap_i64 emits nothing either when the adjacent consumer masks the address, re-wraps, canonicalises its input, or stores only the low bytes. - i = i + k; branch while i <> n fuses into one ADDBRIFC record: the add keeps its write, the compare and branch fold in behind it, and two dispatches disappear per back edge. A labfloor watermark — the record count at the most recent block/loop/if/else/end — proves no label can point at the compare.
Measured with all 15 corpus rows byte-identical: prefix_scan −11.9 %, packet_classifier −8.7 %, lcg −7.3 %, ring_write −5.8 %, none slower.
packages/swarm-mcp 0.26.0 moved its pins to the runtime that has
threads and sockets (^0.14.0 / wasmbuilder ^0.2.0). The caret sits at the minor, so an installed swarm-mcp could not resolve either until that release; for a worker running CPU wasm chunks in-process the interface is unchanged, but the set of kernels that can run is strictly larger — one that sleeps, one that touches errno, one built --threads, one that opens a socket.
disp32 encoding**, so the emitted bounds check could pass while the SIB access wrapped below the buffer — legal wasm, wrong code, and a heap write in the Vec-backed mode. __jit_ok now rejects such functions to the interpreter, which is exact at any offset. Found by the guard-page audit.
pfuncs vec
instead of the module**, leaving garbage entries that could be called; and **call-out context words written as dwords were read with stale upper halves** from earlier 64-bit writes (an FCB before a memory.grow corrupted the grow's destination slot). Both were found by the byte-identical gates rather than by a failing test.
NURL_SAN=1 crashed on the first JIT call. UBSan's
-fsanitize=function verifies an indirect call by reading the callee's clang-emitted type hash at fn-4; a JIT entry is the first byte of an mmap page, so the read falls off the front of the mapping. Generated code will never carry that hash, so the check can only fault or false-positive here. The five nurl_call_code* wrappers — the only indirect calls into generated code — now carry no_sanitize("function"), and nothing else is exempted; the ASan+UBSan build runs the full JIT.
a 64-bit read-modify-write** — exact on one thread, and it loses a neighbour's byte on a shared memory. A single-threaded worker never hit it; a swarm-mcp compiled to one wasm module and run as relay + workers + MCP inside a single instance did, as a wild pointer in the guest's own free.
connect is the host's connect. WASI
preview1 has no way to open a socket — it can only accept on one the host preopened — so stdlib's socket layer under __wasi__ was a set of stubs that answered every call with NetOther. It is now a set of thin wrappers over wasm imports in the module nurl_net, and packages/nwasm answers them with the very same nurl_tcp_* / nurl_udp_* / nurl_dns_* runtime entry points a native build links directly. A NURL program that listens, connects, resolves and serves works compiled to wasm exactly as it does natively — including TLS, because stdlib's TLS 1.3 is pure NURL and runs inside the guest over these plaintext sockets.
The guest never sees a host handle: it gets an index into a per-instance table, so a forged handle can only miss. That is also what makes handles work at all on wasm32 — a host handle is a 64-bit pointer and the guest's stdlib keeps socket handles in a pointer-sized field, four bytes there, so the real thing came back truncated. Every guest pointer is bounds-checked before the host sees it, and host-produced text (peer and local addresses, DNS answers) is copied into a guest-supplied buffer with an explicit cap.
The surface is off by default: nwasm run --allow-net enables it (embedder API interp_allow_net), and without it a nurl_net import traps with a message that says so. The bridge is linked only into a module that actually calls a socket entry point, so a program that never opens one carries no import and still runs on any plain WASI runtime. packages/nwasm/tests/net_test.sh builds the same program native and as wasm and compares the two runs.
wasmbuilder --threads. A module built
with it declares a shared memory, imports wasi.thread-spawn and exports wasi_thread_start; packages/nwasm runs it with one interpreter instance per thread — its own value stack, frames and globals, and therefore its own __stack_pointer, which the host sets from the guest's start block because C cannot assign a wasm global — over one linear memory, table and module. The whole 0xfe atomics family executes: load/store at every width, the six read-modify-write groups, cmpxchg, wait32/wait64/notify and fence, each under one process-wide lock, which is what makes them sequentially consistent in an interpreter that performs a read-modify-write in three steps. A shared memory is reserved at its declared maximum when the module is instantiated and memory.grow only raises the addressable bound, so growth never moves a buffer another thread is reading.
The guest side brings what wasm32's libc does not have: pthreads built on the atomics (futex mutex, condvar, join), per-thread TLS via __wasm_init_tls — without it every __thread variable, errno included, is one shared word — a locked heap in stdlib/runtime_wasm_alloc.c (wasm32's libc allocator is single-threaded by design and cannot be locked from outside), and a 1:1 backend for the stdlib's M:N async runtime, where a fiber is a thread. That last one is what makes spawn + runtime_run work at all: a relay's accept loop is a fiber.
The measured result: packages/swarm-mcp, unmodified, compiled to one wasm module and run with nwasm run --allow-net — relay, three worker threads and the MCP coordinator in a single instance — mints its own TLS certificate, answers MCP over HTTPS and serves compute requests, correct every time. packages/nwasm/tests/threads_test.sh builds a four-thread program native and as wasm and compares the runs.
poll_oneoff. The one WASI call a server cannot do without: libc's
sleep, nanosleep and every timeout go through it. Clock subscriptions sleep for the shortest deadline; file and stdio subscriptions are ready immediately, which is what poll(2) says about a regular file too. Before it, a worker thread trapped on its first sleep_ms. fd_write also flushes now, so a guest that never exits — a server, which is most of the point of having sockets — produces output while it runs.
packages/nwasm 0.12.0 — 17.5 % off the wasm benchmark corpus, everybenchmark faster.** The interpreter's dispatch is a chain of equality tests on the record's opcode, and LLVM folds such a chain into jump tables in batches of 64 arms, in source order, each behind the previous batch's range check. Over the wasm opcode byte that space is sparse and in an order nothing chose, so it came out as three chained tables: an arm in the third paid twelve dispatch instructions where one in the first paid five.
The record now carries a **dense internal opcode ordered by measured frequency** (__iop, one table lookup per instruction at predecode time, never at run time). The 56 opcodes that are 99 % of what a compiled module executes land in the first batch — one bounds check, one table — and everything else in the second. No arm's body changed; the corpus went 8.35 s → 7.22 s.
On top of that, the address add folds into the load: 57 % of every i32.add a module executes is the address for the instruction immediately after it, so a load record grew an index-slot operand and __fuse_addr deletes the add. An unfused access names constant-pool slot 0, which now always holds zero, so the arm adds a slot rather than branching on whether it has an index. And the byte-crossing paths of the two linear-memory accessors moved out of line — a path no wasm compiler ever emits, which inlined put a loop that never runs inside all twenty-odd access arms; that alone was 1.9 % with the host instruction count unchanged to the digit.
Corpus: 96.0G host instructions → 78.3G over 3.12G → 2.98G records, 30.8 → 26.3 per record. A nurlc.wasm self-compile stays byte-identical to the native compiler's output.
That 17.5 % is one microarchitecture. The two halves of the change do not travel together. Measured against the reference JIT on the same runner, on the unchanged C module, the corpus is a wash on an EPYC 7763 (geomean 1.03 against a same-machine noise floor of 0.98) — and bimodal underneath: the rows the address fold moves are 0.74–0.94, and sort_window, prefix_scan, lcg and sieve are 1.44–1.56. Those four are the ones whose host instruction count and branch misses do not change between builds at all; what moves them is where the optimiser happens to place the dispatch, which lands well on one front end and badly on another. The algorithmic half — one record fewer per address computation — travels; the layout half is luck, and bench/wasmbench.sh cannot currently tell the two apart because it measures one revision on one runner.
strlen is no longer a byte loop. `zig cc
--target=wasm32-wasi resolves strlen` from its own compiler_rt shim before wasi-libc's, and that shim is while (s[n] != 0) n += 1;. NURL's runtime calls strlen on every strdup and every symbol-table key, so a compiled NURL module spends most of its time there: a nurlc.wasm self-compile executed 4.7 of its 7.8 billion wasm instructions inside compiler_rt.strlen, and it was the top frame under the reference JIT too.
stdlib/runtime_core.c now defines a word-at-a-time strlen for __wasi__ only — the musl algorithm, which wasm32 does in one load and three arithmetic ops per four bytes. runtime.wasm.o is a plain object on the link line, so the definition wins and no archive member is pulled. The same self-compile executes 5.19G wasm instructions instead of 7.83G, runs 12 % faster on the reference JIT and 15 % faster on packages/nwasm, byte-identical throughout. Native targets are untouched: there libc's vectorised routine is the one you want.
wasm interpreter's fast path for storing fewer than eight bytes read the containing 64-bit word, patched the bytes it owned and wrote the whole word back. Exact on one thread. On a shared memory it is not: wasm says those bytes are independent locations, and two threads writing neighbouring bytes of one word — two malloc headers, a length beside a flag, adjacent struct fields — each write back a word carrying their own stale copy of the other's byte, so one update is simply lost. The damage surfaced far from where it was made, as a wild pointer inside the guest's own free: a size field that never took its new value, and an allocator handing out a block computed from it. Narrow stores to a shared memory are byte-wise now; single-threaded modules keep the word path untouched.
nurl_rand_fill had no CSPRNG on wasi. It fell through to its LCG
fallback and returned 0 — which means "degraded" to every caller — so std/x509_gen and the pure TLS stack panicked on a target that has perfectly good entropy. wasi-libc's getentropy() is wasi_snapshot_preview1's random_get; the wasi build uses it.
dir_create_all could not create any absolute path on wasm. It
insisted on creating every prefix, and under a capability filesystem "/" and "/tmp" live outside every preopen, so mkdir answers ENOENT and opendir cannot prove they exist even when the leaf's parent is perfectly reachable. Only the final component decides the verdict now; the first intermediate error is kept and reported if the final step fails too, since it is the one closer to the real cause.
wasmbuilder stubs the POSIX symbols wasi-libc does not ship, and the stub table hardcoded i64 returns — but std/thread.nu declares pthread_create → i32. wasm-ld answers a signature mismatch with a stub whose body is a single unreachable, so thread_spawn trapped where it had promised to return -1. The stubs are built from the declaration in the IR now, exactly as the libc width-shims already were; only the sentinel value stays tabulated.
wasmbuilder's @ mask was not a bijection. The IR rewriter masks
@ inside c"…" constants so symbol renames cannot corrupt emitted-IR data, then unmasks at the end — and a program whose own data contains the sentinel had that constant rewritten without its [N x i8] length changing, which clang rejects. Every program that embeds wasmbuilder carries the sentinel as a literal. The token is escaped before use now, so the round trip is exact.
nwasm's own flags were read out of the guest's argv. --help,
--version and --allow-gpu were scanned across all of argv, so nwasm run app.wasm --help printed the runtime's usage and never started the module. Options are read up to the module path; everything after it is the guest's own argv, and -- ends the runtime's options explicitly.
nwasm's path_open buffered a created file and only wrote it at flush, where the error was swallowed. O_CREAT creates on the host at open time now, and fd_close / fd_sync return the errno. An out-of-bounds trap also names the address, the access width and the limit — the two numbers that separate a wild pointer from a stale bound.
packages/swarm-mcp 0.25.1 announced a version that did not exist.
0.25.0 shipped and reported 0.24.0 from initialize, server/discover and --version alike: sm_version is a hand-written literal and the release bumped the manifest without it. A published version can be yanked but never replaced, so the fix shipped as 0.25.1 — and the gate that should have caught it now can, since v0.49.0 taught check_package_version_strings.sh to resolve a one-line version accessor.
wasmbuilder could link yesterday's runtime object. The cache key for
runtime.wasm.o is a walk of #include "…" lines matched as that exact literal, so an indented # include inside an #if was invisible: changes to that file did not change the key. The scan accepts whitespace between #, include and the quote now.
sha256_hex was quadratic: 3.5 s to hash a 383 KB file, 1 ms after.
stdlib/std/hash.nu's s → ( Vec u ) converter pushed byte by byte through nurl_str_get, which re-runs strlen on every call — the trap that accessor's own doc comment names, and that bytes.nu had already fixed in bytes_from_str and bytes_extend_str. This copy of it was missed, so every sha256_hex and hmac_sha256_hex caller paid O(n²): the SHA-256 transform over 383 KB is 8 ms, the conversion into it was 3494. One strlen and one memcpy now, a 3500× speedup on that input, same digest.
wasmbuilder 0.1.8 walked straight into it — hashing the whole runtime translation unit for its cache key put that 3.5 s in front of every wasm build, which is what the refreshed WASMRESULTS showed as a **45× jump in the NURL → wasm compile column** while every other compile column moved only with the runner. A wasm compile is 67 ms again, with the correct key kept. A new test hashes 512 KB under a wall-clock budget with a three-orders-of-magnitude margin, so the shape cannot come back quietly.
The same per-byte shape is fixed in stdlib/ext/crypto.nu, stdlib/std/hkdf.nu and stdlib/std/hash_blake2b.nu. It survives elsewhere in the stdlib — stdlib/ext/mcp_search.nu and the HTTP header and body parsers walk their inputs this way — where the inputs are usually short but not always. Not audited here.
wasmbuilder 0.1.8: the cached wasm runtime object could be stale. The
cache key was the content hash of stdlib/runtime.c — a three-line aggregator whose #includes are where every line of the runtime actually lives. An edit to runtime_core.c, runtime_ctx.c or runtime_ffi.c therefore did not invalidate it, and the next build silently linked the object compiled before the edit. The key is now the whole translation unit: the named file plus every file it pulls in with a quoted #include, depth-first, each visited once. Found while measuring the strlen change above, which the stale object hid.
only fire once its operands are consumed leaves the lexer past the statement, so a plain die lex anchors on whatever came next — the closing brace when the mistake was a block's last member, or a caret past the end of the echoed line. Every cast diagnostic (# b of a float, # f of a string, # i of a nested aggregate, …) reported the next statement; a match arm naming an unknown variant reported the arm after it; a bad type in a struct field, parameter, return, FFI, global or enum-payload position reported the declaration's }. All of them now anchor at a position captured before the token was consumed (die_pos): the #, the arm's pattern, the type. check_type_known takes that position from its caller.
check_diag_anchor.sh failed a diagnostic whose subject really is abrace.** The gate exempts a message that names the closing delimiter it found, but only matched the verb "found" — so "unexpected '}' at the top level", which is about a stray brace, was reported as mis-anchored. The exemption now matches either verb immediately followed by the quoted delimiter, and still checks a message that merely mentions a brace in its cure text.
r0, r3, … produced invalid LLVM IR. A by-value
parameter keeps its source name as its SSA register, and the code generator's temporaries are %r0, %r1, … in the same LLVM local namespace — so @ f i r0 → i { ^ + r0 1 } emitted %r0 = alloca i64 inside define i64 @f(i64 %r0). nurlc exited 0 and clang rejected the generated file with multiple definition of local value named 'r0', a diagnostic against a file the user never wrote. nurlc now reserves the shape — r followed by digits — in both function and closure parameter lists, and says why, at the parameter. Locals are unaffected: the generator names them, the user's spelling never reaches the IR.
inline: the always-inline prefix on a function (grammar v2.7) —
inline @ f … puts LLVM's alwaysinline on the definition, which makes the inliner skip its cost model for that callee. pub, simd and inline are order-independent, and inline is accepted on @ declarations only.
The cost model is usually right, and this is not a knob to reach for casually: an inlined body is duplicated at every call site, which costs instruction cache and changes the caller's register pressure. It earns its place where the model is structurally wrong — a helper whose arguments are constants at every call site, so an inlined copy folds to a few instructions, but which is scored on its whole body, called from a caller large enough to push the decision the other way. An interpreter's dispatch loop is that shape exactly: marking packages/nwasm's two linear-memory accessors took 9.5 % off the whole wasm benchmark corpus, 24 % off nbody and matmul, 19 % off sieve.
Two combinations nurlc rejects rather than leaving to the backend: inline on a generic (a generic is monomorphised after the whole program is parsed, and the prefix does not survive to its instantiations — the rule simd already has), and inline together with simd (which replaces the function with a CPU-dispatching stub, the opaque call inline asks to remove). A directly recursive inline function is accepted: LLVM inlines the call sites it can and leaves the self-call.
nurlfmt learned the prefix in the same change — without it the formatter read inline as a stray identifier and broke it onto its own line, which is a parse of a different program.
vec_zeroed and vec_resize_zeroed in stdlib/core/vec.nu.
vec_zeroed [A] n is the calloc-shaped sibling of vec_with_cap: n zero-filled elements in one allocation with len already at n, so a large buffer arrives as untouched zero pages from the kernel and costs nothing until it is written. vec_resize_zeroed [A] v n sets the length to exactly n, zero-filling any new tail in a single memset. Both carry the same trivial-element-type rule vec_extend documents: bitwise zero has to be a valid A. Reaching either state by pushing a zero n times costs n bounds checks, n length stores and log2(n) reallocations, and touches every page — which is what the wasm runtime was doing to 16.8 MB of guest memory on every start-up.
packages/nwasm**, all found by the ASan/UBSan mutation sweep and all now in tests/hardening_test.nu:
*A count bound has to apply to the count, not to a sum containing it.** A local run declares count slots and the ceiling read locals_so_far + count > 1_000_000. With count at 2⁶³−1 and one local already pushed, that sum wraps negative and the test passes, leaving a 2⁶³-iteration push loop. It surfaced as a timeout on a mutated module; the count now goes through __chk_count like every other, bounded by the bytes that physically remain. A LEB128 stops contributing after ten groups.* Past ten the value cannot fit in 64 bits and << x 64 is poison in LLVM (docs/spec.md, § operators). A corrupted continuation byte turning a terminal group into a running one is exactly how a module reaches that shift. *An active data segment that overhangs the declared initial memory is refused**, at decode where the offset, the length and mem.min are all in hand, and again at instantiation rather than copying what fits. The loop this replaces silently dropped the overhanging bytes and, for a negative offset, wrote the segment's tail at the wrong address. Both fit tests are written off > cap - len, never off + len > cap: the offset is a number the module chose and the sum can wrap.
nurl-lsp indexed no prefixed declaration at all. Its
declaration scanner read one identifier and tested starts_with "pub" under a guard that reduces to p == 0, so past the first byte of the file the prefix was never skipped. A pub @ f therefore had no document symbol, no go-to-definition and no rename — for exactly the functions marked as another file's API. It now consumes any run of pub / simd / inline, and all three are reserved words for the rename check. (Measured against the old binary on a four-declaration file: one symbol out of four.)
pub but not simd. Both, and
inline, are storage.modifier now.
swarm-mcp's manifest went to 0.25.0 on 2026-08-21 while its initialize reply kept saying 0.24.0; nurl-mcp's went to 0.11.0 and then 0.12.0 while its reply kept saying 0.10.0 — through two releases. check_package_version_strings.sh knew two spellings and neither can see through one level of indirection; both servers use a third, where the literal sits in a one-line accessor (@ sm_version → s { ^ 0.24.0 }) that every announcement calls. The gate now resolves that, keyed on the package's own name at the call site — which is what keeps wasmbuilder's __wb_zig_version, a real version of something that is not the package, out of the check. 14 checked literals → 16.
swarm-mcp did this once before: 0.31.1 fixed a handshake reporting 0.20.0 from a package at 0.21.1 by unifying three hand-written literals onto one sm_version. That addressed the symptom. The cause is that the one remaining literal is hand-written and no gate could read it, so it drifted again the moment a release bumped the manifest without it. This time the gate reads it.
bundled zig 0.16, and both zig-built unikernel ports broke with it — invisibly, because CI was still running the 0.13 container image. Two separate faults, one behind the other.
RISC-V would not link: build_unikernel_riscv64.sh has always passed -mcmodel=small, and LLVM 18 did not recognise that alias for RISC-V, so the flag was inert and the PC-relative default stood. LLVM 21 honours it, medlow takes effect, and a bare lui cannot reach the 0x8020_0000 the guest is linked at — every nolibc object failed with relocation R_RISCV_HI20 out of range. Now -mcmodel=medany, which is what the port always meant. x86-64 and AArch64 keep small.
Then both arches faulted before printing a line, in nl_tls_init_guest. It installs the thread pointer with inline asm and then reads a canary through the compiler's own TLS addressing, to measure where the linker put the PT_TLS image rather than assume it. But LLVM treats the thread pointer as invariant — nothing connects the msr TPIDR_EL0 / mv tp to the read that depends on it — and LLVM 21 hoists that read to the function entry, above the install. The disassembly is unambiguous: mrs x10, TPIDR_EL0 at +0x24, `msr TPIDR_EL0, x9` at +0x60. The canary was addressed off the boot-time thread pointer, zero, so the guest died reading 0x40 — the canary's tprel offset with nothing added. The canary read now lives in a noinline function, so the mrs is emitted in its prologue, after the call and therefore after the install; a "memory" clobber cannot fix this on its own, because the read is not a load LLVM can see.
Both guest suites are green on both compilers: RV64 20/20 and AArch64 19/19 under zig 0.13 and 0.16 alike.
packages/nwasm 0.11.0 — the interpreter is 3.1x faster over the
benchmark corpus and 7.5x on start-up.** Three rounds on top of the 0.9.0 work below, measured on one workstation so the ends are comparable: corpus 25.3 s → 8.17 s, nbody 5.39 s → 0.93 s, hello world 50.1 ms → 6.7 ms, the 65k-line compiler self-host on the interpreter 47.2 s → 29.3 s, and host instructions per dispatched record 68.5 → 30.5. Output is byte-identical at every step: all 15 benchmarks, the seven package test suites (64 expectations, each one produced by the reference wasmtime), and nurlc.wasm still compiling nurlc.nu to an LLVM IR file identical to the native compiler's.
Floats and the int↔float conversions left the value stack* for register form — they were the last records bridging through it, and the bridge forced an operand flush that turned every local.get feeding a float op back into a move. local.set **folded into its producer** (the producing record is retargeted to write the local and the copy is deleted), taking MOV from 31 % of everything dispatched to 3 %; cmp; br_if became one record; and the driver split into two loops, the outer owning the frame stack and the inner one frame's records with a single compare as its condition.
A function's frame slot count is capped at 2²⁰.* __emit_branch packs a destination and a source slot index into one word, and nothing bounded the indices: a ten-byte function can declare a million locals, and any stack height on top of the decoder's cap wraps the field. Past 2²⁰ slots the body is replaced by a single trap record — the module still loads and still decodes, and the function says so cleanly if it is ever called.
Instantiation stopped scaling with the guest's heap.* A wasi command module declares 257 pages — 16.8 MB — and interp_new filled it with a vec_push of a zero byte per byte: **78 % of the wall clock** of a run whose guest printed one line and exited. Linear memory is now one calloc, data segments one memcpy each, memory.grow one resize, and a frame's slot array one zeroed block. Hello world went 50.1 ms → 6.7 ms and every longer run kept the same ~43 ms.
The dispatch became one jump table over every opcode.* It had been a chain of range tests in measured frequency order, and each arm then paid a second dispatch inside the helper it called — f64.mul cost seven range tests, a call to __farith, six more range tests to pick the family, a call to __f64_binary, and a jump table, to reach one mulsd. Written flat, one ? == op K arm per opcode with the work in the arm, LLVM builds a single table and the integer, compare, float, load and store helpers disappear into their arms. Corpus 12.16 s → 9.44 s; 145.9G → 113.2G host instructions.
The two linear-memory accessors are `inline`.* Every arm calls them with n and signed as literals, so an inlined copy folds to a bounds check, one load and a shift — but LLVM scores the whole callee and declines. Another 9.6 % off the corpus, 22 % off nbody and matmul.
Three measured negatives, recorded so they are not re-tried. Inlining the fused cmp; br_if — 8.6 % of all records — made the corpus 7.6 % slower: twenty arms inside an existing arm is not the shape twenty arms in the table is, and what pays for it is the driver's register allocation. Merging the register/control opcodes into the same table was 8.8 % slower, because LLVM partitioned the merged chain differently rather than building the one table it was asked for. And emitting the chain hottest-first was exactly neutral — LLVM partitions by value range, not by source order.
A fourth is worth more than the three: forcing the whole numeric chain into a single LLVM switch by hand, at the IR level, does produce one table — three levels of range test collapse to one, verified in the disassembly — and it removes 1.28 host instructions per record, and the corpus does not move at all (ratio 1.000). Those range tests issue alongside the real work and the predictor gets them right 99.9 % of the time; IPC simply rises from 3.23 to 3.31. The dispatch path is not what this loop is waiting on, so further instruction-shaving there will not pay — and a NURL construct for demanding a dense switch, which is what that experiment was scouting, would buy nothing. nurlc's own token dispatch already lowers to one jump table with no help at all.
bench/wasmbench.sh builds nwasm with NURL_SPLIT=0. nurl.sh
defaults to lowering a large program as one module per core so the clang step parallelises, and ThinLTO cannot import every callee back across a part boundary — docs/BUILDING.md puts the cost at 3.4 % of the program's own speed and says to turn it off for a release build. nwasm is the subject of section 3 of the report and the reference runtime it is measured against is a release build; the split one measured **5.0 % slower** over the corpus, for a build-time saving a benchmark harness never spends.
packages/nwasm 0.9.0 — the interpreter is 30–49 % faster, and the
reason was visible only once the executed opcodes were counted.** A histogram of every record the benchmark corpus dispatches said that 45 % of them were R_MOV. Register form had kept the one thing that makes a stack machine a stack machine: local.get x emitted a copy of local x into the slot for the current stack height, so the consumer could find it at a statically known place. A register machine does not need that — the consumer can address x itself.
The predecoder now keeps a height → slot map: local.get records that this height is the local's slot and emits nothing, and every operand read resolves through the map. Destinations stay canonical, so no alias ever names another alias. An alias is materialised again wherever the layout has to be the canonical one — before a control transfer (branch targets move results between canonical slots), before a call or a value-stack bridge (both want their operands contiguous), and before a local.set overwrites a local some live stack entry is aliasing. That deleted 27 % of the records the corpus dispatches (local.get fell from 45 % of them to 24 %) and 31 % of the host instructions.
The rest was the driver's per-record overhead, which was paying for things it could have hoisted. The pc, the code base and the frame's register base now live in loop-locals typed *i instead of being re-read through the Wc cursor and addressed with nurl_peek / nurl_poke — the runtime helpers NULL-check their base, so every operand access carried a branch the type system already ruled out. Fuel became a plain countdown: -1 (unlimited) maps to a budget nothing can outlive, so metering costs a dec and a js rather than a load, a zero-test, a conditional decrement and a store — and --fuel N still stops at exactly the same instruction. PFunc now carries the arity and body offset it already knew, so a call no longer walks funcs → typeidx → types twice to re-derive them.
With local.get gone, i32.const was the biggest class left at 22 % of what remained — and it is the same redundancy one level down. The value is fixed for the life of the module, so copying it into a stack slot at run time is work the predecoder can do once. A pre-scan interns the body's distinct constants; the frame reserves a slot for each between the locals and the operand stack, and i32.const K becomes an alias to that slot, handled by the map operand forwarding already built. Placing the pool there is the whole trick: both boundaries are known before the pass, so every slot index a record carries is final when it is emitted — a pool after the stack would need maxh, which is not known until the pass ends.
The first attempt did it the other way, carrying the constant as an immediate in the record with a flag bit. It deleted 91 % of the CONST records and was slower: the flag turns the hot arithmetic arm into an unpredictable branch, and the immediate cannot be speculated into a load index. Making the operand an ordinary slot costs the driver nothing at all, which is why that is the version that shipped.
Together the two passes delete 43 % of the records the corpus dispatches and 39 % of the host instructions it runs. Cost per surviving record went 56 → 60, which is the shape to want: what was deleted was the cheapest work.
Measured on the wasm benchmark corpus: packet_classifier −59 %, ring_write −59 %, affine_mix −57 %, prefix_scan −56 %, lcg −53 %, sort_window −50 %, sieve −47 %, binary_search −43 %, matmul −43 %, and the full 65k-line compiler self-host on the interpreter 57.8 s → 33.7 s. Every benchmark's output is byte-identical to the old runtime's, all seven package test suites pass, and nurlc.wasm compiling nurlc.nu under this interpreter still produces LLVM IR byte-identical to the native compiler's.
One behavioural note, deliberate: --fuel N meters predecoded records, and there are now fewer of them per unit of guest, so a given budget runs a guest further than it did. The bound is the same kind of bound; the unit is documented in the README.
the body with __skip_imm, the routine the decoder already trusts for immediate layout, and a malformed length there can decode to a cursor move that goes backwards. The scan only reads, so it just has to end: the loop now forces the cursor forward, making termination a property of the loop rather than of every encoding it steps past.
__pfunc_for bounds the function index before it can grow the
predecode cache.** It is reached from a call record — i.e. straight out of the module — and the old order relied on __frame_new having checked the module's own function table first. The check now lives where the index is used.
nurllang/ci:ubuntu24-20260821, which ci.yml's three Linux jobs now pin. A containers/ci/Dockerfile edit alone changes nothing: the image is built by hand and the workflow pins a dated tag, so the two move together or the bump is inert — which is exactly how v0.48.0 shipped a zig its own CI never ran.
wasm-bench, a manually-triggered workflow that re-measures the wasm
benchmark on a runner and commits the report** — the fourth member of the bench / http-bench / pq-bench family, and the last benchmark whose published numbers could only come from one person's workstation. It provisions the pieces bench/wasmbench.sh refuses to start without (zig, a reference wasmtime, rustc's wasm32-wasip1), runs the suite, and pushes bench/WASMRESULTS.md + bench/results/wasm-latest.json straight to main with the same replay-on-conflict mechanism the other three use. Manual-only, like http-bench and pq-bench: the pure-NURL interpreter column alone is tens of minutes.
Two inputs: quick runs one repetition per cell to smoke-test the harness and deliberately skips the commit — a 1-rep run is not a measurement and must not replace the published table — and wt_all_langs adds the C and Rust modules to the interpreter column. $WASMTIME is pinned to the installed reference binary rather than left to a PATH lookup, because on any box with the NURL toolchain installed wasmtime on PATH was then the pure-NURL interpreter, which would quietly fill the reference-JIT column with the runtime that column exists to compare against.
is byte-identical to native.** unikernel/demos/wasmc.nu bakes nurlc.wasm, eight import-free corpus programs and the NATIVE compiler's IR for each into an image; the guest decodes the module on packages/nwasm, runs it IN-PROCESS (a unikernel has no subprocess to run it in) and compares byte for byte. All eight agree, 23 s under TCG. unikernel/tests/wasmc_gate.sh runs the whole chain from nurlc.nu to the comparison and is wired into the guest suite; it skips loudly without zig or QEMU rather than passing quietly. (#962)
unikernel/tests/selfhost_gate.sh is build.sh's own fixed-point equality — the compiler compiles its own source and the emitted IR must equal the IR it was built from — with the compiler running as wasm, in a machine with no operating system. The guest compiles the 29 067-line nurlc.nu and emits the 4 337 947 bytes nurlc.wasm was linked from, byte-identical, in 1300 s under TCG. It is a fixed point at the compiler's OUTPUT boundary, which is where build.sh checks the native one — turning IR back into a module needs LLVM, which stays on the host, and the script's header says so. Deliberately not in run_qemu_tests.sh or CI: 22 minutes against wasmc_gate.sh's 23 s. (#963)
appliance.** The same packages/swarm-mcp that runs hosted builds as a guest, --connects to the relay, appears in the census and answers both expression and compiled-wasm tasks. Wasm runs IN-PROCESS on the pure-NURL nwasm runtime (now a dependency and the default engine; $NURL_WASM_RUNTIME selects an external one, which GPU work needs). Measured under TCG: join 6 s, cold start to first answer 8–9 s. unikernel/tests/swarm_gate.sh runs it on every commit. Also from that work: a DNS resolver over the pure UDP stack (stdlib/net/dnsclient.nu + the dns= cmdline key), every key=value on the kernel command line becoming the guest's environment, and tickless idle — a joined idle worker went from a full host core to 5.6 % of one. (#961)
build_unikernel.sh --disk carries a virtio-blk driver (unikernel/drivers/virtioblk.nu), a FAT12/16/32 filesystem (stdlib/fs/fat.nu + fatfs.nu, read AND write: long names, subdirectories, rename, truncate, readdir, fsync) and a VFS (unikernel/boot/vfs.c) that dispatches open/read/write/ lseek/unlink/rename/mkdir/truncate/fsync/access/ getdents64 to the baked-in archive first and the disk second — so write_file, read_file, dir_list and the rest of std/fs.nu work unchanged in a guest. Until now every write was refused with EROFS, which is correct for a machine with nowhere to put the bytes and fatal for anything stateful. New cmdline key `disk=rw|ro|format| off; format` writes a filesystem only onto a device that does not mount, so it is not a reformat-on-every-boot switch. Measured cost: +72 KiB of image over the same program built without --disk. Gated by unikernel/tests/disk_gate.sh (guest suite now 29/29) — a mkfs.vfat volume mounted, written across three boots, validated by fsck.vfat, and read back by unikernel/tests/fatread.py, a reader in another language that shares no code with the guest — and by compiler/tests/fat_fs.nu, 140 assertions over both layouts.
stdlib/fs/fatfmt.nu — mkfs.vfat for a machine with no host. Aguest handed a blank disk can format it itself. TRAP found writing it: sizing a FAT is a recurrence that does not generally have a fixed point — on a 64 MiB disk it oscillates between 1008 and 1009 sectors for ever — so the criterion has to be safety (grow until the table covers the clusters, then shrink while it still does), not equality. A loop looking for equality returns whichever value it stopped on, and the smaller one is a table one entry short of the volume's own cluster count.
nurlc --keep=a,b — extra dead-code-elimination roots. The DCE
pass roots at main, which is complete for a program whose entry points are all reachable from it and wrong for a module linked with C that calls into it: nothing in the IR recorded that boot/vfs.c calls nurl_disk_open, so the whole disk layer was dropped, the image linked (the C side's references are weak), booted, and reported "no filesystem" about a filesystem it was carrying.
nurl_native_constant now answers O_RDONLY, O_WRONLY,
O_RDWR, O_CREAT, O_EXCL, O_TRUNC, O_APPEND and O_BINARY.** They are not the same numbers on every target — O_CREAT is 0100 on Linux and 0x0100 on Windows — so NURL code that needed them had no correct way to spell them and had to avoid open(2) entirely. O_BINARY is 0 wherever there is no text mode to opt out of, so a caller ORs it in unconditionally instead of branching on platform. Alongside them, three portable descriptor shims — nurl_pread, nurl_pwrite, nurl_fd_sync — because the MSVC CRT has none of pread, pwrite or fsync, and nurl_have_posix_dirent, which answers whether opendir/readdir are real on this build. That last one replaced a platform test that asked whether O_RDONLY existed: publishing the constant on Windows silently rerouted every directory listing there into an ENOSYS stub, so the capability is now asked about directly and cannot be broken by adding a constant.
release bundles (Linux x86_64/aarch64, Windows), the CI image's /opt/zig, the fuzz workflow's wasm differential leg, the playground image (nurlapi/Dockerfile), and the zig release wasmbuilder provisions into $NURL_HOME/zig when none is found (wasmbuilder 0.1.7). Note zig's release archives renamed themselves between 0.13 and 0.16 — zig-linux-x86_64-<ver> became zig-x86_64-linux-<ver> — so every hardcoded download URL moved with the version. wasm32-wasi is still the wasm target name, and zig cc's LLVM 21 stays ≥ the system clang 18 used for the shipped runtime.o bitcode.
nurl_api's exact-module replies never truncate mid-module, and
every query term is accounted for.** A grab-bag query like vec sort string split lowercase contains used to split the byte cap evenly across the named modules and cut core/string.nu mid-surface — and said nothing about split/lowercase/contains, so an agent could read the truncation as "lowercase does not exist". Now a named module's API surface is emitted whole or not at all (what doesn't fit becomes a one-line module= pointer), and the reply ends by listing the terms that named no module as NOT searched, pointing the agent at one-concept follow-up queries. Both fronts' query descriptions now teach the one-concept-per-call shape (2–4 related terms).
(readOnlyHint/destructiveHint/idempotentHint/openWorldHint, via the new ext/mcp.nu helper mcp_tool_annotate). Clients such as claude.ai key their per-tool permission defaults and grouping off these hints — and an absent destructiveHint defaults to TRUE in the spec, so an unannotated read-only tool was being presented as if it could destroy state, leaving every tool stuck in "Needs approval". Browse/search/read tools declare read-only (auto-allowable); builds declare non-destructive + idempotent; nurl_run honestly declares destructive + open-world.
(bumped to 0.12.0) and the playground — agents reported the descriptions eating real context budget. Every tool now follows one shape: what it returns, when to use it, and at most one note that changes how you call it; parameter mechanics live in the parameter descriptions and runtime details (fallback order, byte caps, what wasn't searched) are stated by the replies themselves.
int as i read every failure as
a success.** pipe/fork/dup2/close/fcntl/waitpid/kill/ getpid/execvp/poll in stdlib/core/posix.nu (and the pthread family in std/thread.nu) declared a 64-bit return for a function that returns 32 bits. A C int of -1 leaves the upper register half ZEROED, so the result arrived as 4294967295 and < 0 answered no — every failing call silently read as a large success. Fatal in the unikernel, where nosys.c refuses all of these: process_run misread four refused pipes and a refused fork as successes and hung reading garbage fds. Latent hosted, on any path where these calls actually fail. → i32 makes nurlc emit the matching declare and sign-extend; read/write keep i64, since ssize_t genuinely is. (#961)
nurl_sym_free walked the symbol table with the wrong stride —
*i where nurl_sym_def and fourteen other readers use *s. Where a pointer is 8 bytes the two spellings address the same bytes and the bug is invisible; on wasm32 a pointer is 4, so every read fused two entries into one bogus address and free walked outside linear memory. nurlc.wasm trapped in dce_free after emitting correct IR. (#962)
nl_open checked the access mode and ignored O_DIRECTORY, so opendir SUCCEEDED on a file — and every "is this a directory?" predicate written the obvious way then says yes about a file. The wasm runtime's path_open asks exactly that, was handed an empty directory fd, and nurlc.wasm dutifully compiled an empty program: 21 939 bytes of preamble instead of the program's 6 624, with no error anywhere in the chain. Now ENOTDIR. (#962)
mingw-w64 that 0.16 ships (0.13 predates it) defines clock_gettime and nanosleep in <pthread_time.h> — which <time.h> includes — as static __inline__ forwarders to libwinpthread's clock_gettime64 / nanosleep64, and runtime_core.c defines both itself, so runtime.mingw.o stopped compiling with two redefinition errors. Deleting ours is not an option: static emits no external symbol, and std/time.nu binds both as plain c imports, so the runtime has to export those exact names. runtime_core.c now claims the header's include guard on the MinGW path; nothing else in the runtime uses the rest of that header.
bench/wasmbench.sh and bench/chaincheck.sh now pin LC_ALL=C.
$EPOCHREALTIME and awk both follow the locale's decimal separator, so under e.g. fi_FI the comma made wasmbench report negative compile times and collapsed chaincheck's wall readings to whole seconds. bench/bench.sh had carried this line for a while; its two siblings had not, and CI never noticed because runners are C.UTF-8.
nurl_read_gotchas, the nurl://gotchas resource, the playground's
/gotchas routes, and docs/GOTCHAS.md itself.** The document earned its keep when the compiler couldn't yet explain its own errors; 170 of 173 error sites now carry the explanation inline, and the gotchas file had been reduced to saying so.
nurl_api 0-hit queries now resolve exact module names before fuzzy
widening** (ext/mcp_search.nu, shared by nurl-mcp — bumped to 0.11.0 — and the playground API). Agents habitually query concept lists like csv json string; no single declaration contains all three, so the reply used to be an OR-ranked declaration list even though two of the terms literally name stdlib modules. Now, when the AND pass finds nothing, any term that exactly names a module — csv, csv.nu, or ext/csv.nu — returns that module's whole API surface (in query order, deduplicated, the byte cap split evenly between the named modules), and only when no term names a module does the search fall through to the whole-word OR pass and the examples/registry widening as before. Query terms are now also split on commas, not just spaces, so csv,json behaves like csv json.
/build_wasm no longer dies on programs that import
stdlib/core/io.nu** (or anything else whose IR declares a vararg libc symbol — open(2), fcntl, printf). nurlapi/main.nu carried its own ~590-line fork of the wasm32-wasi IR rewriter, and the copies had drifted: the fork predated the vararg fix in __wb_ir_decl_params, so those declares produced an illegal ... %aN parameter in a shim define and clang rejected the module — a five-line hello importing stdlib/core/io.nu could not build to wasm, while the local wasmbuilder CLI (whose copy had the fix) was fine. The fork is deleted; the service now imports packages/wasmbuilder/src/wasi_ir.nu — one rewriter, two consumers — and inherits everything else the package version had learned since the fork: the realpath stub, spacing-robust declare removal, env-import attribute marking, and the -O0 main alias. (#954)
packages/pki-server 0.3.0 closes three reachable holes, each
reproduced against a running server before and after the fix. Reflected XSS: POST /revoke echoed the caller-supplied serial into the HTML result page unescaped, so serial=<script>… reached the browser verbatim; ui.nu now escapes every interpolated value and HTML responses carry Content-Security-Policy: default-src 'none'; script-src 'self'; … (the UI's one script moved to /js/app.js so that policy can hold). Arbitrary file write: revoking by PEM lifted the CN out of an unverified certificate and used it to build <initial-dir>/<cn>/<cn>.crt, so a self-signed certificate with subjectAltName=DNS:../… wrote outside the tree; the certificate is now signature-checked against the CA first, the CN is reduced to [A-Za-z0-9._-] at the one function that turns it into a path, and that function refuses to create files that do not already exist. `index.txt` record forgery: a serial carrying tabs and newlines was appended straight into the tab-separated index, forging revocation records; serials must now be even-length hex of at most 40 bytes.
your-device-init-key / your-management-key-here placeholders authenticated every caller of any deployment that never overrode them. They now authenticate nothing; when no key is configured a 192-bit key is generated at startup and printed to stderr once. An empty key denies rather than admits, and both keys are compared with std/subtle's constant_time_eq — string_eq stops at the first differing byte, which turns every request into a measurement of how many leading bytes matched.
used to be a side effect — the stored enrollment file was scribbled over — so revoking by serial alone, with no CN to name a file by, left the device free to keep collecting operational certificates. /request-cert and /renew_initial_cert now check the certificate's serial against index.txt.
http_app_async n (n worker
pthreads, 0 = one per core) makes the async runtime the facade's scaling mode instead of a path that existed but could not be deployed. Three things had kept it unusable: TLS record I/O blocked worker pthreads, the TLS handshake blocked the accept loop, and the async path skipped both the DoS gate and the per-connection idle timeout. Now std/tls.nu's __fill / _tls_sock_write are context-aware — on a fiber, EAGAIN parks on the reactor and the worker stays free; off one they block as before — tcp_accept_transport + tcp_conn_complete_tls move the handshake onto the connection's own fiber so handshakes overlap (675 → 7 034 handshakes/s), and __serve_accepted factors admission control and timeouts so the sync, pool and fiber paths share them. A plain-TCP probe against a TLS port used to kill the whole server; it is now one failed connection. Plaintext req/s against the old 10-thread pool, same host (12-core i7-5930K, oha): C=1 14.6k → 20.5k, C=50 141k → 203k, C=200 151k → 252k; TLS C=200 57.8k → 186k. Gated by compiler/tests/async_http_server_tls.nu.
packages/pki-server — `--algorithm
p256|mldsa44|mldsa65|mldsa87 (default p256`) selects the CA key type at creation, and the choice covers the CA certificate, every issued certificate, the CRL signature and chain verification, so a PQ deployment has no classical signature anywhere in its trust path. /request-csr keeps whatever key algorithm the requester generated while signing with the CA's, and GET /health reports algorithm and post_quantum. --algorithm names what a new CA is minted with; an existing key pair keeps its own. Composite/hybrid certificates are deliberately out of scope — the choice is pure-classical or pure-PQ.
mldsa_priv_from_pem in std/pkey.nu — loads the PKCS#8
OneAsymmetricKey container x509_gen.nu writes for ML-DSA and returns the parameter set alongside the key, since the OID is the only thing that names it. Accepts both the wrapped and the bare privateKey encoding.
set_permissions in std/fs.nu — POSIX chmod(2), which is what a
program that writes a private key needs: write_file creates at 0666 & ~umask, leaving a key readable by every account on the host. pki-server now writes every private key it stores at mode 0600.
keyUsage
(critical), extendedKeyUsage, subjectKeyIdentifier and authorityKeyIdentifier joined the basicConstraints and subjectAltName that were already there. Key identifiers are RFC 7093 method 1 (leftmost 160 bits of SHA-256), which keeps the package free of a SHA-1 dependency.
SSL_CERT_FILE anchors verify-full — std/tls_verify.nu reads the
de-facto standard environment variable every mainstream TLS stack honors (OpenSSL semantics: when set, the file REPLACES the system bundle, and an unreadable path fails loudly with "no trust anchor" rather than silently falling back). Until now verify-full could anchor only to the three hardcoded system bundle paths — a private CA or a self-signed lab server meant editing /etc/ssl or giving up on verification. Gated by compiler/tests/tls_ssl_cert_file.nu, a pure-NURL end-to-end: mint a certificate with x509_selfsigned_p256, serve it through the same tcp_listen_tls PEM path packages/http uses, verify-full against it as its own anchor over an X25519MLKEM768 handshake — plus the override-replaces and default-unchanged halves, mutation-tested.
bench/pq.nu — the post-quantum stack measured, with the same-hostreference numbers (pq-crystals / sphincsplus, ref C and AVX2, dated) in the header as the bar.
v256 sixteen-lane arithmetic — `nurl_v256_{add16,sub16,mullo16,
mulhi16,sra16,bcast16}; mulhi16` is spelled as the widen-multiply-narrow sequence LLVM pattern-matches back into vpmulhw.
simd: CPU-dispatched code generation (grammar v2.6) —
A function declaration may carry a leading simd prefix. nurlc then emits the body twice — @f.base for the baseline ISA and @f.x86v3 with an AVX2 + BMI2 + FMA feature set — plus a dispatcher under the real name @f that picks between them on a CPUID answer cached by the runtime (nurl_cpu_x86_v3, stdlib/runtime_core.c). Callers are unchanged and the binary still runs on machines without AVX2, which is what separates this from handing clang -march=native. pub and simd compose in either order. See docs/spec.md §3.3b.
The prefix is deliberately opt-in and coarse: marking every function in a module measured 2× slower than marking none (ML-DSA-65 sign 574 → 1127 µs), because a dispatcher is an opaque call that stops every helper it fronts from being inlined. Marked on a handful of entry points instead, the callees are inlined into the wide clone and vectorise there.
Because nurlc emits no target triple, a build aimed anywhere other than x86-64 must pass --no-cpu-dispatch or clang reports one unrecognised feature per feature per marked function (26 warnings on a module importing std/mlkem). nurl.sh, unikernel/compile_nu.sh, the ESP32 scripts and the /build_wasm + /build_target API endpoints all pass it automatically. --g also suppresses cloning, since a !DISubprogram binds to one function.
v256: a 256-bit vector type, and std/hash_sha3x4 — four Keccak
sponges at once** — v256 is <4 x i64>, four 64-bit lanes, the shape four interleaved Keccak states want. Unlike v128 it is not baseline anywhere, and that is the point: LLVM legalises it into paired 128-bit operations on any target, so a v256 kernel is correct everywhere and fast where AVX2 exists — put it behind simd and the wide clone gets real vpxor/vpsllq/vpandn. Primitives: `nurl_v256_{zero,ld,st,set64,bcast64,get64,put64,xor,and,or, andnot,not,rotl64}`. Verified to lower on x86-64, AArch64, RISC-V and wasm32.
stdlib/std/hash_sha3x4.nu builds Keccak-f[1600]×4 and a four-way sponge (shake128x4_init, shake256x4_init, sha3x4_absorb, sha3x4_squeeze) on it. Measured 3.32× per stream against the scalar sponge (2254 → 678 ns for a 34-byte seed and 504 squeezed bytes). This is the kernel every production PQ implementation's AVX2 win is actually made of: on this host SPHINCS+'s AVX2 build generates four WOTS+ public keys in the time its reference C generates one.
Correctness is differential against the scalar sponge that NIST's vectors already cover, one lane at a time, over 16 cases spanning both rates, both domain bytes, empty/short/block-straddling inputs and outputs, and absorb-in-pieces + squeeze-resume. Eight mutations of the permutation and the sponge plumbing were each caught.
gen_matrix (FIPS 203) and ExpandA (FIPS 204) expand kk / kl independent SHAKE128 streams that differ only in two trailing index bytes, which is exactly what the four-way sponge is for. Rejection makes the four lanes consume different numbers of bytes, so each group runs until its last lane has 256 coefficients and the finished lanes stop reading — wasted squeezing that is still far cheaper than four separate sponges, because the four share one permutation and the permutation is the cost. ML-DSA's three copies of the ExpandA loop (keygen, sign, verify) are now one __md_expand_a: a matrix that disagreed between signing and verifying would be a silent interoperability failure, not a crash.
std/hash_sha3 now declares its public surface — the twelve documented
API entries and the Sha3 type carry pub; everything else in the file is private to it, as the header comment always said. keccak_round_constants is public so the four-way sponge shares one transcription of the ι table rather than keeping a second copy that could drift.
k·G runs in
Jacobian coordinates.** The field kept eight 32-bit limbs and packed them to 4×64 inside every Montgomery multiply, unpacking on exit — ~24 pack/unpack operations riding on each of the ~1500 multiplies of a scalar multiplication. Elements are now stored as four 64-bit limbs, with add/sub running on nurl_addc/nurl_subb chains in registers. The fixed-base comb then moved from complete RCB projective addition (14 muls for every doubling and addition) to Jacobian with an affine table (dbl-2001-b, mixed Jacobian+affine), completeness replaced case by case with constant-time masks; the variable-base ladder keeps the complete formula, so adversarial inputs never reach the masked paths. ECDSA sign 168 → 96 µs, P-256 keygen 130 → 62 µs, TLS server CPU 775 → 659 µs/handshake. Verified by differential testing against bigint (400 field trials, 60 scalarmult trials) and a new p256_scalarmult_base_kat whose 18 vectors — cross-checked against an independent Python implementation — include the structured edges that exercise every masked exceptional path.
ServerHello through Finished left as six separate send()s — six syscalls and up to six client wakeups per handshake — and now leave in one write, which is what rustls also puts on the wire. The fixed-base comb widened 4 → 8 teeth and its Montgomery tables are built once per process rather than re-parsed from a 1.5 KB hex string on every sign. __fill reads straight into rxbuf's spare capacity and __consume shifts in place, instead of a fresh 16 KB scratch Vec plus copy per record. The reactor's spin window also grew 64 → 256 probes: a plaintext peer answers inside the old window but a TLS turnaround is ~35–45 µs, so every low-concurrency HTTPS request missed the spin and paid a full park. https C=1 13–15k → 18–20k req/s (p50 0.069 → 0.046 ms); the ≤4-live-fibers gate keeps loaded servers off the spin entirely.
(nurllang/ci:ubuntu24-<date>, built from containers/ci/Dockerfile) — clang/llvm, the FFI dev libraries, qemu + grub, gdb, zstd and the zig toolchain pre-installed, so no CI run touches the Ubuntu package mirror or ziglang.org. Exists because the mirror reachable from GitHub's runner pool degraded three separate times on 2026-08-19 and every affected job burned its whole timeout inside apt-get update without executing a line of the code under test. CI pins the dated tag; :latest is never referenced.
http_app_cors
made a CA's management API scriptable from any origin; nothing in the package needs cross-origin access, and a browser-driven caller can set its own proxy. validity_days is also capped at 3650 on every issuance endpoint, and --serial-file is accepted and ignored (serials have been 96-bit CSPRNG values throughout; the flag never did anything).
__bitpack (ML-DSA) and __byte_encode (ML-KEM) reserve their exact output (a polynomial packs to 32·bits bytes) and write through the raw pointer. The per-byte vec_push cursor was a call, a capacity check and a possible grow per byte, ~15,000 times per ML-DSA-65 signing attempt, and the hottest scalar loop in both mldsa_sign_mu and K-PKE encryption. ML-KEM-512 encaps 27 → 20 µs, decaps 32 → 25 µs; ML-KEM-768 encaps/decaps 32/39 → 30/38 µs. ACVP byte-exact throughout.
NTT** — PRF_η / ExpandS / ExpandMask run four nonce-streams per sponge; CBD sums η-bit groups a word at a time instead of per bit; the ML-KEM NTT's wide layers run sixteen Montgomery butterflies per operation, bit-identical to the scalar arithmetic (the borrow in mulhi(x,y) − mulhi(t,q) cannot happen, so ACVP byte-equality holds). From the branch start: ML-KEM-768 keygen/encaps/decaps 39/42/60 → 31/32/39 µs, ML-DSA-44 sign 180 → 153 µs, ML-DSA-65 sign 312 → 273 µs.
implementation on every operation** (i7-5930K, µs/op, sphincsplus shake-avx2 built from source on the same host):
| before | after | AVX2 ref | ||
|---|---|---|---|---|
| 128f keygen / sign / verify | 2598 / 62085 / 3771 | 656 / 19807 / 1616 | 1104 / 25546 / 1894 | 1.2–1.7× faster |
| 128s keygen / sign / verify | 168156 / 1307537 / 1337 | 43200 / 325635 / 651 | 70857 / 544364 / 707 | 1.1–1.7× faster |
| 192f sign | 101442 | 31744 | — | 3.2× |
| 256f sign / verify | 214134 / 5475 | 58618 / 2239 | 83238 / 2786 | 1.4× / 1.2× faster |
Three changes, all structure, no algorithm: shake256x4_block in std/hash_sha3x4 (F/H/PRF always fit one rate block — one four-way permutation, no sponge struct, no allocation, against ~90,000 hashes and six allocations each per 128f signature before); WOTS+ chains four at a time (equal-step lockstep in keygen/sign, per-lane entry points in verify — a lane joins at its own start and idles before it, since the permutation runs on all four lanes regardless); and FORS trees built bottom-up (leaves and internal levels four at a time, the auth path extracted from the one build instead of recomputing a sibling subtree per level). A per-call SlhCtx scratch is threaded down the call chain, so concurrent signers share nothing.
Oracle: SLH-DSA ACVP byte-exact (keyGen 60, sigGen 168, sigVer 168 — 0 failed), and four mutations of the new paths (chain-entry off-by-one, FORS sibling without ^1, pad domain byte, verify parent parity) each caught.
simd-marked function is no longer partitioned by
--split.** Splitting separates the wide clone from the callees it needs inlined, giving back most of what the prefix buys: ML-DSA-65 sign measured 352 µs unsplit against 509 µs split ×3 (and the ThinLTO backend at -O3, which helps an unsplit module, makes the split one worse still). nurlc answers "no parts written" — the same answer a module too small to split already gives, which every driver already handles.
@ 3.5 GHz, µs/op, no algorithm changed — NIST ACVP vectors still pass byte-exactly: ML-KEM 180, ML-DSA 615, SLH-DSA 396):
| before | after | speedup | AVX2 ref | gap now | |
|---|---|---|---|---|---|
| ML-KEM-512 keygen / encaps / decaps | 33 / 36 / 43 | 25 / 27 / 33 | 1.3× | 8.1 / 7.7 / 8.0 | ~3.5× |
| ML-KEM-768 keygen / encaps / decaps | 52 / 55 / 69 | 41 / 43 / 50 | 1.27–1.38× | 11.8 / 11.7 / 12.5 | 3.5–4.0× |
| ML-KEM-1024 keygen / encaps / decaps | 90 / 83 / 98 | 62 / 59 / 68 | ~1.44× | 16.2 / 16.6 / 17.9 | ~3.7× |
| ML-DSA-44 keygen / sign / verify | 94 / 404 / 112 | 56 / 177 / 53 | 1.68 / 2.28 / 2.11× | ||
| ML-DSA-65 keygen / sign / verify | 184 / 644 / 161 | 105 / 304 / 82 | 1.75 / 2.12 / 1.96× | 44 / 113 / 45 | 2.4 / 2.7 / 1.8× |
| ML-DSA-87 keygen / sign / verify | 255 / 699 / 291 | 127 / 393 / 120 | 2.0 / 1.78 / 2.4× | ||
| SLH-DSA-128f keygen / sign | 3308 / 70728 | 2728 / 68176 | 1.21 / 1.04× | 1104 / 25546 | 2.5 / 2.7× |
SLH-DSA still runs one Keccak at a time: its WOTS+ and FORS subtrees are four-way parallel too, but wiring them up is a larger change than the two matrix expansions above and is not in this entry.
nurlpkg publish checked a proxy for the thing it claimed to check.
Its own --help said gate 3 verified that "required stdlib symbols exist in the RELEASED toolchain"; it verified that every imported stdlib file was present in the installed prefix, and nothing about what the package called inside those files. packages/pki-server 0.3.0 imports std/fs.nu and std/pkey.nu — both present in every release — while calling set_permissions and mldsa_priv_from_pem, added to them in the same PR: every path existed, every gate passed, and the tarball would not compile for anyone running nurlpkg install. Publishing is irreversible (yank, never replace), so the gate now does the real check — typecheck src/main.nu with the INSTALLED compiler against the INSTALLED stdlib. An unresolved deps/ tree and a too-old stdlib are told apart by the compiler's own diagnostic, since they want opposite advice; a missing or unlaunchable compiler WARNs and lets the publish through rather than passing silently.
silently truncated length above 65535 — unreachable while every certificate was a half-kilobyte ECDSA one, reachable the moment an ML-DSA CRL crosses 64 KiB. It now emits the fewest length octets that hold the value, as DER requires.
index.txt records the revocation date; the reader threw it away and substituted the current time for all entries, so the CRL lost its history on every restart. Dates now round-trip as UTCTime, entries are no longer duplicated when the same serial is revoked twice, and a line whose serial or date does not parse is dropped instead of propagated.
nurl_str_int allocates, and auto-drop only tracks resources it saw bound (spec §8.1) — an owned temporary handed straight to a call is never collected. with_log_requests leaked one such string per request and with_access_log three, in processes expected to run for months. Both now bind before printing. (Found by running packages/pki-server under NURL_SAN=1; the same idiom appears at other nurl_str_int call sites in stdlib, which this does not sweep.)
enrollment check** — the match nested the stored-PEM unwrap inside the submitted-PEM arm, so any request whose submitted PEM did not parse (that is, every probe) dropped the other buffer on the floor.
greedy sed for "certificate" matched "ca_certificate" — the last occurrence in the response — so the OpenSSL chain check never looked at an issued certificate. JSON is now parsed as JSON, and the suite runs the whole lifecycle twice (classical and post-quantum) with security regression tests for each hole listed above.
pqc probe no longer reports "handshake failed" for an untrusted
certificate** — a probe's question is "does this server negotiate a post-quantum group", and the handshake answers it whether or not the chain anchors here. On TlsBadCert it retries insecurely and reports the real group with a (certificate UNTRUSTED here) note, keeping "not post-quantum" and "not trusted" apart. And probe HOST:PORT now works as the usage always documented — the port suffix was parsed by nothing, so probing anything but :443 silently dialled the wrong port.
nurlfmt no longer breaks a simd @ declaration across lines — the
formatter glues the prefix to its decl-starter exactly as it does pub.
json_float keeps the float-ness in the text: 85.0 serialises as
85.0, not 85.** An integral double printed as bare digits, which a strictly typed consumer reads back as an integer field — real schema drift the moment an ETL value lands on a whole number. Digits-only raw text now gets .0 appended; fractional, exponent and non-finite forms pass through untouched. The fix propagates to every JNum producer built on it: a CBOR half-float 1.0 now stringifies as 1.0 (the old golden had baked the bug in as 1). Pinned by compiler/tests/json_float_integral.nu.
warnings, one of them living on borrowed time: the initfs data object was built with ld -r -b binary, which emits no .note.GNU-stack section, so GNU ld assumed an executable stack and warned the assumption will be removed from future linkers. x86 now embeds the archive the way arm64/riscv64 always did — a generated .S with .incbin and an explicit empty GNU-stack note — which also names the symbols directly instead of the objcopy rename dance (and drops the dead _size symbol nothing read). The .ll compile gets -Wno-override-module (nurlc emits no triple; same silencing as every other driver), and the redundant -no-pie clang 18 warns about is gone from the unikernel and nolibc links — -static already implies it.
vec_get-miscompile workaround notes were three
months stale — the compiler bug they cite was fixed 2026-05-14.** http_multipart, http_router and http_response still documented "multi-field vec_get miscompiles" / "multi-field structs can't ride ! T E Ok arms" as live facts (http_request.nu's twin note was already updated). Both shapes verified correct today — including the exact String+String+closure struct the router note feared, the 4-field owned struct multipart uses, and the out-of-range None default-construction path the comments blamed — clean under ASan+LSan, and pinned by compiler/tests/vec_get_multifield.nu. The pointer iteration stays (it is the zero-copy borrow); the comments now say why honestly.
std/csr: PKCS#10 Certificate Signing Requests (RFC 2986) in pure NURL —
RFC 2986 ASN.1 DER parser (csr_parse), generator (csr_generate_p256, csr_generate_ed25519), self-signature verifier (csr_verify supporting ECDSA P-256 + SHA-256, Ed25519, RSA PKCS#1 v1.5 + SHA-256, and ML-DSA-44/65/87), PEM encoding/decoding (csr_to_pem, csr_from_pem), and CA certificate issuance from verified CSRs (x509_issue_from_csr). Devices can generate keys locally and request certificates without private keys ever traversing the network. Two-way interoperability is verified against OpenSSL via compiler/tests/csr_openssl_gate.sh.
ext/jwt & ext/http_jwt: ES256 (ECDSA P-256 + SHA-256) support —
jwt_es256_sign, jwt_es256_verify_at, and jwt_es256_verify (RFC 7515 / RFC 7518 §3.1), plus HTTP authentication middleware with_jwt_es256, enabling direct interoperability with modern OAuth2 / OIDC providers (Apple, Google, Okta, GitHub, WebAuthn).
std/x509: SLH-DSA (FIPS 205) SHAKE OID recognition —
OIDs 2.16.840.1.101.3.4.3.{21,23,25,27,29,31} for SHAKE SLH-DSA parameter sets.
packages/pki-server (v0.2.0) — Pure-NURL Private PKI Certificate Authority and Service —
A complete, native re-implementation of a Private PKI CA microservice in pure NURL with zero external dependencies. Features ECDSA P-256 key generation, automated CA setup, two-tier device lifecycle (POST /init enrollment, POST /renew_initial_cert renewal, POST /request-cert operational issuance), Zero-Trust PKCS#10 CSR enrollment (POST /request-csr), RFC 5280 CRL generation (POST /revoke, GET /crl), OpenSSL index.txt compatibility, API key authentication, and responsive modern server-rendered Web UI dashboard.
std/slhdsa: SLH-DSA (FIPS 205) in pure NURL — the third and lastNIST post-quantum standard, and the one that assumes least. ML-KEM and ML-DSA rest on lattice problems; SLH-DSA rests on nothing but the hash function, so it is what still stands if a break lands on lattices. All six SHAKE parameter sets: WOTS+, XMSS, the hypertree, FORS, and the 32-byte address structure that domain-separates every hash call.
The price is size and speed — 7 856 bytes at the smallest set against ML-DSA-44's 2 420, and signing walks a hypertree of Merkle trees rather than doing algebra. Use ML-DSA by default; use this where a signature is rare, long-lived, and has to outlast an assumption: firmware images, root certificates, release artefacts.
tools/slhdsa_acvp_gate.sh checks it against NIST's vectors — all 60 SHAKE keyGen cases uncapped, sigGen and sigVer capped per group because signing is slow by construction. The three reasons a case does not run (the cap, the unimplemented SHA-2 family, the pre-hash interfaces) are counted and printed separately, so none of them can be read as coverage.
Two bugs, both signedness, both found only by the vectors. SLH-DSA-256f is the one parameter set whose hypertree index fills all 64 bits (h − h/d = 68 − 4). Its mask was computed as 1 << 64, which x86 takes modulo 64 — yielding 1, so the mask became 0 and every tree index was pinned to zero. With that fixed the same set still failed, because % and >> on a now-negative i carried the sign into the next layer, and the address writer shifted past 64 for the top bytes of the 12-byte tree field. Five of the six sets passed throughout; only the one that reaches exactly 64 bits ever showed it, which is why that apparently redundant set is pinned in the offline test too.
The SHA-2 instantiation of FIPS 205 is not implemented.
profile picked.** Nothing was guessed: perf --call-graph lbr put __keccakf1600 at 40.6% of all PQ time — everything in both schemes rides on SHAKE — and a disassembly histogram showed the round compiling to 191 movs against ~155 real ALU instructions. More than half the work was spilling.
The cause: materialising all 25 ρ+π results and then running χ over them leaves 35 values live on a 16-register machine. χ cannot consume them a row at a time in place, because ρ+π scatters lanes across rows and χ's first row would overwrite lanes its fourth row still needs. Writing each χ row into a second buffer removes the conflict, so only one row's five values need to exist at once — live set drops to ten. The buffers ping-pong; 24 rounds is even, so the state lands back where the caller left it. Round: 367 → 249 instructions, movs 191 → 73, with rol/xor/not/and counts unchanged. SHAKE128 squeeze: 260 → 354 MB/s.
Then ML-DSA's rounding. Decompose, Power2Round and UseHint were written in the spec's mod± form, which reads as a remainder and compiles to idiv; they run 256·k times per signing attempt and signing retries several times. The reciprocal-multiply forms are division-free, and MakeHint now derives the hint from the decomposed pair the signer already has rather than calling HighBits twice per coefficient.
| before | after | |
|---|---|---|
| ML-KEM-768 keygen | 18.3k/s | 20.8k/s |
| ML-KEM-1024 keygen | 11.9k/s | 14.2k/s |
| ML-DSA-65 keygen | 4.8k/s | 6.1k/s |
| ML-DSA-65 sign | 1356/s | 1737/s |
| ML-DSA-44 verify | 7.7k/s | 9.8k/s |
Every ACVP vector still passes byte-for-byte — 180 ML-KEM and 615 ML-DSA — which is the point of having them: a rewrite of the permutation and of three rounding routines is exactly the change that needs an oracle it cannot influence.
ML-DSA now covers every interface the standard defines, and all 615 published ACVP cases pass with none skipped — up from 480 run and 135 skipped.
Pre-hash mode signs a digest instead of the message, so a signer can handle a message it never holds whole, or a protocol that already has a digest need not carry the message to the signer at all. All twelve approved hashes are supported. mldsa_ph_mprime is public for callers who already hold the digest or who must supply their own randomness.
std/hash_sha256 gains SHA-224; std/hash_sha512 gains SHA-512/224 and SHA-512/256. The last two are not truncations of SHA-512: each has its own initial state, so SHA-512/256(m) and the first 32 bytes of SHA-512(m) are different values, and a test asserts exactly that — an implementation that truncates produces a hash that looks entirely plausible and interoperates with nothing.
The vectors earned their keep again. The OID that goes into the signed representative was written as bare content bytes rather than a full DER encoding, missing the two-byte 06 09 tag and length. Signer and verifier both used the same wrong bytes, so every round trip agreed; only a vector produced elsewhere could show it. The offline test now pins that encoding structurally, by building the representative from a literal OID and requiring the module's pre-hash signature to be byte-identical to a pure-mode signature over it.
That structural check exists because the obvious test does not work: a "signature under hash A must not verify under hash B" case passes whether the OID is right, wrong or absent, since two hashes give different digests anyway. Both were mutation-tested — the offline test catches a wrong or headerless OID, the ACVP gate catches a wrong digest length — and neither catches the other's.
made the key exchange post-quantum, so a recording made today survives a future quantum computer. That left the other half: authentication was still ECDSA or RSA, and the same adversary could forge the certificate and simply be the server, no recording required. Now the server can present an ML-DSA certificate and sign its CertificateVerify with ML-DSA, and every asymmetric operation in the handshake is one a quantum computer does not break.
- std/x509 recognises the ML-DSA OIDs (2.16.840.1.101.3.4.3.{17,18,19}) in a SubjectPublicKeyInfo and as a signature algorithm. One OID does both jobs, because ML-DSA specifies its own hashing and so has no "with-SHA256" half to name. Such a key parses as key_alg = 4, with the raw key in ec_point and the parameter set in ec_curve. - std/x509_gen: x509_selfsigned_mldsa and a deterministic x509_selfsigned_mldsa_pinned, plus PKCS#8 output for the key. - std/tls offers mldsa44/mldsa65/mldsa87 (0x0904–0x0906) ahead of the classical schemes and verifies a CertificateVerify signed with them; std/tls_server's tls_accept_mldsa produces one. ML-DSA signs the CertificateVerify content, not the SHA-256 digest the other schemes take — it hashes internally, so handing it a digest would sign the wrong thing. - tls_cv_verify checks the CertificateVerify signature on its own, apart from chain and hostname validation. That is the half that proves possession — a certificate is a public document until its key signs the transcript — and callers pinning a key or trusting a private root need it without the rest.
compiler/tests/tls_pq_certificate.nu runs it end to end: the certificate parses as ML-DSA and verifies its own signature, the group is X25519MLKEM768, the scheme is mldsa65 rather than a silent fallback, the CertificateVerify checks against the certificate's key, and one flipped bit in that signature makes it fail.
std/mldsa: ML-DSA (FIPS 204) in pure NURL — the post-quantum
signature, at all three parameter sets, alongside the KEM that landed with X25519MLKEM768. The complete 256-point NTT over Z_8380417 (unlike ML-KEM's, this modulus admits a 512th root of unity, so the transform runs all eight layers and multiplication is an ordinary pointwise product), Montgomery and Barrett reduction in i32, rejection sampling of the public matrix and of the noise, Power2Round and Decompose, hint generation and its packing, and the 3/4/6/10/13/18/ 20-bit encodings. The pure external interface with FIPS 204 §5.2 context strings, plus the external-mu interface; HashML-DSA (preHash) is not implemented.
Signing is hedged by default — 32 fresh random bytes into the per-signature nonce, so a repeated message does not produce a repeated signature and a fault does not expose the key the way deterministic signing can. mldsa_sign_deterministic remains for interoperability.
tools/mldsa_acvp_gate.sh runs all 480 published ACVP cases: 75 keyGen, 270 sigGen (deterministic and hedged, internal and external, and external-mu) and 135 sigVer.
A signature's hint has three encoding rules, and violating them is not a corrupted signature but a second valid encoding of a genuine one — signature malleability. Deleting each check and re-running all 480 NIST cases shows how little they are covered: the strictly-increasing-indices rule fails 1 of 135 sigVer cases, and the trailing-zero rule and the totals bound fail none at all. The reason is that corrupting a hint changes the bits it decodes to, so c~ stops matching and the signature is refused regardless. So compiler/tests/mldsa_vectors.nu constructs re-encodings that decode to identical hint bits — the first index duplicated with every running total bumped, and a non-zero byte in the unread tail — and requires both to be rejected. With either check deleted, they fail.
std/tls_server.nu
now accepts X25519MLKEM768, in preference to the classical groups when a client offers it. There the server is the encapsulating party: the client sends an ML-KEM encapsulation key and the reply carries a ciphertext, not a public key. compiler/tests/tls_pq_hybrid.nu puts the NURL client and the NURL server on the two ends of one handshake and asserts the negotiated group is 4588 — the encapsulating half that talking to Cloudflare can never exercise, since there that side is theirs.
std/net: tcp_tls_group and tcp_is_post_quantum. A server
built on net.nu previously had no way to ask whether a connection's key exchange was post-quantum; a peer without ML-KEM falls back silently and nothing else about the connection differs.
packages/pqc: sign-keygen, sign and verify, so the tool
now covers both halves of the migration. Signatures are bound to a pqc context string, so one cannot be replayed as a signature for another application sharing the key.
std/tls.nu's client offers X25519MLKEM768 (group 0x11ec) as its first preference, so an ordinary tls_connect negotiates post-quantum key exchange with every peer that supports it — Cloudflare, Google and Wikipedia all take it today; github.com does not and falls back to X25519, which is why tls_group and tls_is_post_quantum now report which one actually happened. The secret handed to the key schedule is ML-KEM secret ‖ X25519 secret, so the session survives either primitive being broken and adopting it is never a downgrade. The X25519 half keeps its own RFC 8446 §7.4.2 all-zero check: testing only the concatenation would let a low-order peer point zero the classical half while the ML-KEM half stayed random. The ClientHello is now ~1.5 kB and spans more than one TCP segment, matching what browsers sending this group already do.
std/mlkem: ML-KEM (FIPS 203) in pure NURL, at all three
parameter sets — 512, 768 and 1024. The NTT over Z_3329, Montgomery and Barrett reduction, rejection sampling of the public matrix from SHAKE128, centred binomial noise, and the 1/4/5/10/11/12-bit encodings, in i16 arithmetic throughout — the first serious use of NURL's signed narrow integers, whose truncation, wrapping, sign extension and arithmetic shift were differentially validated against a model over all 65 536 inputs before anything was built on them. Decapsulation is branch-free on secret data: the re-encryption comparison is subtle's constant-time equality and the implicit-rejection select is an arithmetic mask.
Correctness is pinned to NIST's own ACVP vectors rather than to itself — a KEM round-trips fine when both halves are equally wrong. tools/mlkem_acvp_gate.sh runs all 180 published cases (75 keygen, 75 encapsulation, 30 decapsulation including implicit rejection), parsing NIST's JSON directly in NURL; compiler/tests/mlkem_vectors.nu carries an offline subset that runs on every build. The gate is mutation-tested: a wrong twiddle factor, a dropped NTT layer, a wrong Montgomery constant, a swapped noise sign, a missing k byte in G(d‖k) and an unconditional rejection select are each caught.
std/hash_sha3: SHA-3 and SHAKE (FIPS 202) — Keccak-f[1600],
SHA3-224/256/384/512, and SHAKE128/256 both one-shot and as a streaming sponge. The incremental squeeze is load-bearing rather than a convenience: ML-KEM's rejection sampler pulls three bytes at a time and needs one continuous stream, so ( squeeze h 3 ) three times and ( squeeze h 9 ) once must agree, and a test pins exactly that. SHA-3 was the last standard hash family stdlib lacked.
std/random: rand_bytes n — the raw CSPRNG draw every
key-generating caller wants. std/tls.nu had open-coded its own, which meant the fail-closed policy was restated per call site.
packages/pqc — ML-KEM on the command line, plus `pqc probeHOST...`, which completes a real TLS 1.3 handshake and reports the key-exchange group the server chose. Offering the hybrid group is not the same as getting it, and nothing else in the handshake says which happened.
std/zstd: an optimal parser for levels 13–19. The greedy parser
chooses each match on its own; the new one chooses the SET of matches with the smallest priced total — a shortest path over every position in the block, where an edge is one literal or one match and its weight is the bits it will actually cost. Literals are priced at the integer lengths a Huffman tree would assign, sequence codes at their share of the real quantized FSE table, repeat offsets carried in the arrival state the way the decoder tracks them, and the literals-length code is charged marginally as a run grows (a potential transform — same totals, honest attribution — and alone worth 0.3 % on text). Prices come from the parse itself: parse, count, reprice, parse again, keep the cheapest round; level 19 runs the iteration from two starting-price families because the fixed point depends on the start. Measured: 200 kB of text at level 19 compresses to 55 837 bytes where zstd -19 writes 55 938 — past the reference — and 100 kB of dictionary text lands 0.5 % short (24 903 vs 24 781). Multi-block frames now also use the format's repeat modes (sequence-table mode 3, treeless literals) when reusing the standing table prices below re-sending it.
std/zstd — Zstandard (RFC 8878) in pure NURL, both directions.
The real format, not a subset: frames with or without a declared content size, raw / RLE / compressed blocks, Huffman literals in one or four bitstreams with a tree that is itself FSE-compressed or repeated from the previous block, FSE sequences in all four table modes, the three repeat offsets with their zero-literals exception, skippable frames, concatenated frames, and the XXH64 content checksum. The compressor is a hash-chain match finder with priced matches and lazy matching, Huffman-coded literals, and sequence tables normalised per block when the block is long enough to pay for them. Checked against the reference zstd CLI in both directions by tools/zstd_gate.sh — 1020 reference frames decode byte-identically, 1932 of ours pass zstd -t and decode through zstd -d, 600 mutated frames are refused without a crash or a hang. Measured at level 3: 100 kB of dictionary text → 28 578 bytes where zstd -3 writes 32 377; decompression 276 MB/s.
std/hash_xxh64 — XXH64. The stdlib had no fast non-cryptographic
hash, and a Zstandard frame checksum is exactly this. One-shot over a Vec or over a pointer range (the pointer form is what lets a decoder checksum a buffer it is still filling). Verified against the reference implementation's vectors by tools/xxh64_gate.sh.
packages/zst — the Zstandard CLI. compress / decompress / verify
/ bench, and inspect, which prints the anatomy of a .zst file block by block: how the literals were coded, how many sequences each block carries, and the mode of each of its three sequence tables. That is recoverable without entropy-decoding anything, and zstd --list stops at the frame.
read_all_stdin_bytes (core/io). read_all_stdin returns a
String, and a String stops at its first NUL — so a program in a pipe could slurp a text stream whole but not a binary one, silently truncated. This is the dual of write_bytes; the pair is what makes a byte-clean filter possible.
packages/lsmdb — an embedded, crash-safe key/value store in pure
NURL.** A real LSM tree: a write-ahead log, a skip-list memtable over a byte arena, immutable SSTables (CRC-32 per block, Bloom filter, block index, 48-byte footer), ordered range scans, compaction, and snapshot reads. A write is fsynced before put returns; every crash point in the flush and compact sequences recovers to a correct state; a torn log tail loses exactly the write that was never acknowledged; a block that fails its checksum fails the read rather than returning something wrong. Ships as a CLI and as a library.
file_sync / dir_sync (std/fs) — there was no fsync at all.
file_flush pushes libc's buffer into the kernel, which survives the process dying and not the machine dying, so nothing in the tree could promise a write had reached the device. file_sync is the barrier a write-ahead log needs, and dir_sync is what makes a publish-by-rename durable: the file's bytes and the directory entry naming them are separate writes, and syncing only the file can leave a recovered tree where the data is on disk and the name reaching it is not. One runtime shim behind both (fsync on unix, _commit on Windows, a no-op on WASI).
file_truncate (std/fs). The operation an append-only filecannot do without: after a crash, recovery keeps the records up to the last intact one and the file has to END there. Leaving the torn bytes in place is not untidiness — replay stops at them, so every later append lands behind bytes the next replay refuses to walk past and is silently invisible from then on.
write_bytes (core/io) — binary stdout. NURL could read binary
from stdin (read_n_bytes) but not write it back out: every print takes a NUL-terminated string, so a program holding arbitrary bytes — a value out of a database, a decoded image, a proxied response body — had its output silently truncated at the first zero byte. write_bytes emits the buffer exactly, sharing stdout's buffer and tty-flush rule with the ordinary prints so mixing the two never reorders output.
places emitted them without nurl_llty, so a closure taking a BYTE parameter produced u8 — not an LLVM type — in its define header, in the closure value, and at every call site. nurlc exited 0 and clang rejected the file. i / f / handle parameters happened to spell the same in both, which is why it stayed hidden. Found by ( vec_eq [u] a b \ u x u y → b { ^ == x y } ), the obvious way to compare two byte vectors. Regression: compiler/tests/closure_sized_params.nu.
read_file could not read /proc or /sys. Both readers ask forthe size first and treat zero as empty, but a procfs entry reports zero and reads fine — as do character devices and named FIFOs — and some fail the seek outright. Either answer now means "length unknown" and routes to a streaming reader that reads until EOF. Returning an empty string for a file with content is the worst way to be wrong.
ext/compress's zstd_* no longer needs libzstd — or any library.
It calls std/zstd instead. The libzstd detection in build.sh / build.bat, the stdlib/runtime.zstd sentinel, the -lzstd in nurl.sh / nurl.bat / nurlapi, and the static libzstd hunt in tools/nurlpkg/build.sh are all gone. This matters more than a link flag: an &-FFI declaration is checked against its sentinel at COMPILE time, so on a machine without libzstd-dev — or on any target that has no such library at all, which is every freestanding build, every unikernel, and wasm — importing ext/compress failed outright. You could not have gzip without having libzstd.
std/deflate's CRC-32 is table-driven. It computed the checksum
bitwise — eight shift-mask-xor rounds per byte — which a profile found taking 66 % of the cycles of a database point read. It now builds a Sarwate table for inputs from 512 bytes up (2048 steps to build, then one lookup per byte; the two break even near 300) and keeps the bitwise path for short inputs, which would otherwise pay the setup to checksum a gzip header. A new Crc32 context (crc32_ctx / crc32_ctx_hash) holds the table for callers that checksum thousands of buffers instead of one. Same values as before: tools/crc32_gate.sh checks 610 of them — every length across the threshold, plus chained updates — against python3's zlib.crc32, and now runs in CI. Measured on a 4 KiB block: 7.8x. Every gzip, tar and PNG user gets it.
The last package that could not be installed, and the three defects standing behind it.
0.44.1 took the registry from 42 of 48 installable to 44. yoloe was the one left, and reaching it meant going through a chain where each link only became visible once the one in front of it was gone.
not the one running it.** stdlib/runtime.<lib> tells nurlc an FFI library is available, and build.sh writes it by probing the box it runs on — which for a release archive is the CI runner. That runner has no libX11-dev, so no release has ever shipped runtime.X11, and nurlpkg install yoloe failed at compile time telling the user to install libX11-dev and re-run build.sh — on a machine that already had libX11 and cannot run build.sh at all. The installer now probes the machine it is installing onto, with the same test. No library → no sentinel → the same clean compile-time message as before, which is the right answer on a headless box: nurl.sh would otherwise reach the linker and fail there instead.
calls.** Two declarations of the same linker symbol cannot both be emitted, so the first wins the declare — while every later one overwrote the call-site metadata (parameter types, variadic signature, return type). Calls were then lowered against a signature the module never declared:
` call i64 (i8, i32, ...) @open(i8 %path, i64 %r1) `
nurlc exited 0 and clang delivered the news, about generated IR. One linker symbol has one ABI, so a disagreement is now an error naming both signatures and pointing at the & rather than at the next declaration the lexer had already reached. Identical redeclarations stay legal — two modules declaring the same extern is ordinary, and the stdlib relies on it. (should_fail_ffi_sig_conflict)
cli_new[^)]* matcher stopped at the first ), and yoloe's about-string is "…segmentation (pure NURL, GPU)" — so the version sat behind a parenthesis inside the description and the gate written to check it saw nothing. yoloe shipped 0.6.6 announcing 0.6.0. It now takes the last backticked semver on the line; 14 literals checked.
A patch cut for one reason: 0.44.0 could not compile a correct program, and a published package was the proof.
Installing all 48 registry packages with 0.44.0 — the standing rule that publishing proves nothing, so install it — turned up six that did not build. Five were the ecosystem's own defects and are fixed in the packages. The sixth was the compiler's.
{ after it is the body. The
n-ary arity trap (0.37.0) fires when a { follows a completed ternary: ? & a b c d { then } { else } means an n-ary AND, & a b takes two operands, c and d are eaten as the bare then/else, and the blocks become stray statements. Real trap, worth the hard error it became.
That signal has a second and entirely correct source:
` ~ & ok < i0 ? >= stopat 0 stopat N { body } `
Here the ternary is part of the loop's condition and the { opens the loop body. Nothing is stray, there is no rewrite that avoids the diagnostic, and it is an error by default — so a correct program could not be compiled at all. The ?-condition form has it too. packages/lingbot-map had exactly this shape and could not be installed by any toolchain.
The check now stays silent while a condition is being parsed and fires exactly as before everywhere else. Inside a condition the two cases are genuinely indistinguishable — the { is the enclosing construct's block either way — so silence is the only answer that keeps correct code compiling, and it is the direction every other diagnostic here takes: miss, never invent. The corpus compiles to byte-identical IR; the only exit-code change anywhere is the new test. (arity_ternary_in_condition, with arity_strict_nary unmoved)
announced the wrong version.** gguf passed an i where a b was declared; lingbot-map carried stray fourth blocks on two ? statements. Both failed identically under 0.43.0 — they had simply been uninstallable since publication, and nothing was looking, because nurlpkg publish does not build what it publishes.
Then the freshly published gguf 0.3.1 printed gguf 0.3.0: the gate added days earlier to stop exactly that checks cli_new prog about VERSION, and gguf prints its version with a bare nurl_print of the literal gguf 0.3.0. It now also matches a literal carrying the package's own name followed by a semver, went from checking 1 literal to 13, and found four more drifted packages — safetensor was two minors behind. All are bumped rather than corrected in place: each had already shipped announcing the wrong number, and a published version can be yanked, never replaced.
The order-independent release. Every entry started from the same question, asked once and then asked again of each rule in turn: *does this check answer a question, or does it recognise a shape?* Three things turned out to be shapes.
A stack reference was safe until it rode a ?. ^ f was an error and ^ ? c f f compiled clean — a branch that cannot change the answer, since both arms hand the caller the same dead frame. The same was true of a field store, and of a join nested inside a ?? arm. Every one is a confirmed stack-use-after-return or heap use-after-free under AddressSanitizer.
And where you wrote a function still decided two verdicts. A helper defined below its caller has no summary yet, and an empty summary reads as "does not mutate" rather than "not compiled yet". Both rules that still consulted one inline now park what they cannot answer and resolve it after the module — §2.10 by parking the finished report rather than the question, because its diagnostic fires at a later read of the pointer and there is no call site to park at. **No rule in the memory model depends on definition order any more.**
Underneath both is one method: §2.3 was the only default-on rule with no tools/metamorph class, which is exactly why its interprocedural cousins had been swept twice and the rule they build on never had. Ask what is not in the harness, not what is. The harness itself gained a second axis for the one part of the model no compile verdict can answer — the panic-unwind journal, whose question is an LSan run — and that oracle was found broken by mutation-testing it before it was trusted.
docs/MEMORY.md listed four boundaries around the two interprocedural escape checks. Enumerating the spellings found eleven, and every one had the shape the whole 0.43.0 cycle had: the check asked its question at one syntax and said nothing at the others. Out through the result (§2.8) missed a nested aggregate, a closure that captured the reference, a local name given to it, a second helper, a ternary, and a callee defined below the call or written generically. In through a parameter (§2.7) missed a whole chain defined below its caller, a helper whose own escaping callee sat below it, a generic escaping parameter, and a field of the parameter reaching thread_spawn. ASan reports stack-use-after-return on all of them.
Three mechanisms close them. A binding carries the parameters its initialiser carried, and a call result carries those at the callee's returned positions, so a reference does not lose provenance by being given a name or taking another hop. Propagation between summaries is parked as an implication and run to a fixed point before any parked check replays — which is what makes a forward chain behave like the same functions written bottom-up. And the return-escape check parks what it cannot answer at a forward or generic call.
§2.4 and §2.10 were the two checks with no coverage class at all, and each turned out to have one gap: strict-mode aliased mutation knew . c n but not ( peek c ), so the depth of the expression decided the verdict; and the stale-borrow rule knew only a direct stdlib mutator, so ( grow v ) was silent where ( vec_push v … ) warned — which reads as "wrap the push in a function" being the cure for the diagnostic rather than for the bug.
tools/metamorph was not running in CI, and its baseline was
stale.** The harness had run only by hand. known_gaps.json still listed two gaps that had already been fixed, and a stale baseline is not cosmetic — it would have absorbed the regression had either hole come back. It is a CI step now and the baseline is empty. Separately, --verify could not see a stack bug at all: ASan instruments only functions carrying sanitize_address, which the C frontend adds and nurlc's hand-written IR does not, so escalations saw only what the runtime's malloc interceptors caught and every dangling stack reference ran clean and was filed UNCONFIRMED. The escalation now stamps the attribute on the generated module and runs with detect_stack_use_after_return=1. The gate also fails loudly when llvm-as is missing rather than quietly reporting every module valid.
?.
Reference-ness propagated through closure literals, aggregate literals, copies and = assignments — but not through the phi of a conditional, so ^ f was an error and ^ ? c f f compiled clean. The branch cannot change the answer: both arms hand the caller a pointer into the frame that is about to disappear. AddressSanitizer reports stack-use-after-return on every spelling — the ternary, the ??, the join bound to a local first, a struct literal built from one, a ternary between two structs, and the same join reaching vec_push or thread_spawn instead of a return.
Two more of the same shape came with it. = . box cb f — a field store — walked a stack reference into a longer-lived struct with nothing said, and returning that struct handed out the dead frame; the field form now has the two effects the whole-binding form always had (the struct inherits the reference, and a store into a struct that outlives the referent is reported at the store). And a join nested inside a ?? arm dropped the handle candidates gen_cond has carried up since 0.42.0, so ?? c { 1 → ? d a a _ → ( make ) } handed a's buffer over with nothing recorded: freeing both names compiled clean into a heap use-after-free.
§2.3 was the only default-on rule with no tools/metamorph class, which is why its interprocedural cousins had been swept twice and the rule they build on never had. It has one now — eighteen spellings — and so does the strict # *T escape check, which found no gap and now cannot grow one. Four new controls pin the other direction: a join between closures that capture nothing, a join that stays in its frame, a same-region field store, an ordinary scalar field store. (borrow_escape_join, borrow_escape_field_store, borrow_strict_nested_join_alias)
§2.5 follows a container mutation one call deep through the per-function mutation summary, and that summary is built in codegen order — so ~ y xs { ( grow xs ) } was diagnosed when grow sat above the loop and silent when it sat below. The call now parks the question and replays it against the final summary.
checker gives.** §2.10 was the last rule where it could: a pointer borrowed with vec_data goes stale when the container is grown through a helper, and that was reported when the helper sat above the borrow and silent when it sat below, because an empty mutation summary reads as "does not mutate" rather than "not compiled yet".
§2.5 parks that question at the call, where its diagnostic fires. This one fires at a later read of the pointer, so there is no single site to park; what is parked is the finished report plus the callee it is conditional on. The call kills the pointer provisionally, and the condition rides inside the stale-set entry rather than in a side table — a question parked before a ? and a certain kill inside one arm are precisely the pair that meet at the join, and a side table does not survive the union. A certain mutation supersedes a parked one, entry and line together, so the report names the call that really reallocates. (should_warn_stale_borrow_forward)
anomaly --version told the truth for the first time since 0.5.2.
cli_new takes the version as a string literal and nothing derived it from nurl.toml, so the two drifted the moment a release bumped one and forgot the other — invisibly, because no test ran --version and compared it to anything. anomaly shipped 0.5.3 and 0.5.4 while --version kept answering 0.5.2; a version can be yanked, never replaced. tools/check_package_version_strings.sh now compares every package's manifest against the literal it passes to cli_new and fails on a mismatch, with comments stripped so the doc example in packages/cli is not mistaken for a real call. All 45 packages audited; anomaly was the only real drift.
test_skips.sh
skipped any test whose name ended in _mod / _helper / _lib, on the theory that those are importable modules with no main(). diag_thread_arc_shared_mutation_helper.nu — the regression test for the Arc shared-mutation race one call deep — has a main() and was skipped by name. It had no golden either, and neither the MISSING-golden check nor the ORPHAN check fires for a skipped test, so the only trace was the SKIP count. The rule now asks the file whether it has a main().
packages/anomaly.** Model blobs and data.jsonl written by timevector_test.nu, swept in by a git add -A and about to go into a published tarball — the same class as the packer defect fixed in 0.41.0. Removed, and .gitignore now excludes the directory the test writes to.
tools/metamorph grew a second axis, for the one part of the
memory model no compile verdict can answer.** Every class in the harness asks the compiler a question and compares verdicts across spellings. The panic-unwind allocation journal is a runtime mechanism: a panic longjmps past the scope-exit frees the compiler queued, and the journal reclaims what it skipped, so the question is whether the program leaks — or frees something the caller still owns. The panic-reclaim class asks it as an LSan run over nine spellings of one abandoned allocation: a ? arm, a ?? arm, a loop body, two frames deep, a nested extent (the inner unwind must not touch the outer's scratch), a second extent after the first (a stale journal would drain twice), an extent that never panics, and the escape case where a value moved into a caller's binding must not be freed. All nine come back clean — the journal is uniform across them, measured rather than assumed.
The oracle was broken first, and mutation-testing it is what found that: under LSan's defaults a deliberately-leaking control reported CLEAN, because the buffer's only owner is a live main frame and so it reads as still reachable. With use_stacks=0 the control reads leak and a double free reads crash. At process exit an allocation the program still owns is the leak being asked about; the stack must not launder it. The gate runs in the sanitizers job, where the --san build has already produced the runtime it needs, and it skips loudly without one.
__ path is obsolete, and nothing first-party uses
it any more.** A __name function is file-scoped by design; calling one from another file resolved anyway, as a compatibility shim for code written under the old flat namespace, and warned that it was deprecated. The stdlib had been quietly living on that shim: seventeen helpers — _p256_scr_new, _p256_mul_d, _mag8 and the rest — were defined __-private in p256_field.nu and called from ecdsa_p256.nu, so every build of the crypto stack printed warnings telling its own author to rename them. Four in-tree packages (anomaly, gpukit, whisper) did the same within their own trees.
All twenty-one are renamed to the single-underscore shared-internal spelling, at the definition and at every call site. Compiling the whole stdlib now produces zero cross-file __ warnings, as do the packages, the examples and the tooling; the only three call sites left in the repo are the fixtures that exist to pin the diagnostic itself.
The warning is relabelled from deprecated to obsolete and now says the path will stop resolving. It is not removed yet, and the reason is narrow: package versions already published were compiled under it, and deleting the path would break nurlpkg install of a release nobody can go back and edit. It goes when those have turned over.
The same-verdict release. Every entry below started from one observation: the checker was recognising shapes where it should have been answering questions. It knew that : T b a gives a handle a second name, that a payload slot holds a box, that a thread closure must not capture something spelled Rc — and every one of those is a spelling, not a property. Written another way, the same situation got a different verdict.
tools/metamorph is the instrument that made that measurable: it enumerates one semantic situation across N spellings and fails when the verdicts disagree. It found gaps in three checks that had shipped days earlier — one of them hours old — and it stays behind as a gate, now at nine situation classes plus the controls that guard the other direction, reporting zero gaps and zero control regressions.
The headline is Send / Sync. Thread safety had been a single line, "is this capture spelled Rc?", and twelve probe programs found ten ways to write the same undefined behaviour that compiled clean. It is now a property derived over a type's whole graph, checked at every boundary a value can cross, with a marker for each of the two directions a structural derivation can be wrong in.
Send / Sync — thread safety as a property of the type, not of
its spelling.** Two marker traits (stdlib/core/marker.nu) name the two questions a thread boundary asks: Send ("may this value MOVE to another thread?") and Sync ("may two threads reach ONE of these at once?"). Both are derived structurally over a type's whole graph — struct fields, enum variant payloads, generic arguments, aggregate members, closure captures — from two language-level leaves: Rc is neither (its refcount is not atomic) and Cell is Send but not Sync (a raw byte buffer with unsynchronised writes).
The derivation is checked where a value actually crosses: thread_spawn and spawn (every capture must be Send — a fiber runs on whichever M:N worker picks it up, so it is a thread boundary too), chan_send (the value must be Send), and Arc T, whose payload must be Send and Sync because an Arc exists to be shared. [T: Send] and [T: Sync] bounds are answered by the same derivation instead of by an impl lookup, so they accept i, s and ( Vec i ) with no impl block anywhere.
What this replaces is a single line asking "is this capture spelled Rc?". It caught that one spelling. The same undefined behaviour written with the Rc one struct field, one Vec element, one Box, one Arc payload, one option, one enum variant, one nested closure, or one different boundary away compiled clean — ten ways to write one bug, one of them diagnosed. tools/metamorph now enumerates fifteen spellings of it plus three of the Sync half, and the diagnostic names the reason (it reaches an 'Rc' …) rather than the shape it happened to recognise.
Because the derivation is structural it can be wrong in two directions, and there is a marker for each. % Send T { } / % Sync T { } assert safety the compiler cannot see — Mutex is { Cell c } and is nonetheless the thing that makes contents shareable, so stdlib/std/thread.nu marks Mutex, Cond, Thread and Semaphore by hand. % NotSend T { } / % NotSync T { } assert danger it cannot see: NURL spells String and every opaque FFI handle i8*, so a sqlite3* derives as Send and there is no way for the compiler to know better. A marker impl is NURL's spelling of Rust's unsafe impl — an assertion, not a proof — and a negative one outranks a positive one on the same type.
Send/Sync answer "may this value cross?", never "is this program race-free": ( Vec i ) is Send and Sync, correctly, because sharing one read-only is ordinary code. Two threads mutating it is the race the existing shared-mutation check catches, at the mutation. The two are complementary and neither subsumes the other; MEMORY.md §6.5 and LIMITATIONS.md state exactly what each one does and does not guarantee. (diag_send_struct_field, diag_send_generic_arg, diag_send_enum_payload, diag_send_nested_closure, diag_send_chan_send, diag_send_fiber_spawn, diag_send_notsend_marker, diag_sync_arc_cell, diag_send_generic_bound, send_sync_markers_ok)
tools/metamorph doubled its classes — and stopped lying toitself.** Four classes to eight (use-after-free, loop-carried-free, iterator-invalidation, thread-nonsend) plus four controls for the documented §2.6 exemptions, which turned up two more gaps of the usual shape. The more valuable half is the three fixes to the harness, each a way it could have handed back a confident wrong answer:
- A template with a typo compiled to a syntax error, which the harness read as "rejected — the checker caught it": a false negative wearing coverage's clothes. A rejection must now match the diagnostic its class is about, and anything else is reported INVALID against the template, never against nurlc. It immediately caught three of the new templates, including ( ? … ) — which in NURL is a call to a function named ?. - Controls were compared with >=, so a control that got rejected counted as "better than asked" — precisely the false positive that class exists to catch, passing in silence. Fixing it exposed a second modelling error: controls were held to --strict-borrowck, which is documented to over-flag. The no-false-positive contract is the default checker's, so that is the bar now. - Three gaps were reported as "hung (weak signal)". They were not hung: ASan had already printed SEGV on 0xfffffffffffffff8, WRITE and was stalling in its symbolizer, and the harness threw that away on timeout. It now scans partial output and runs with symbolize=0. A weak label on strong evidence is its own kind of wrong answer.
Classes now carry a severity and the worklist ranks by it, because not every disagreement is memory unsafety. iterator-invalidation deliberately is not: measured, a forced realloc mid-iteration does not dangle — a Vec is a handle to a control block and the loop reads through it — so §2.5 is a conservative guard and a gap there is a diagnostic inconsistency, not corruption. Ranking it alongside the double frees would have sent the next reader at the wrong thing first.
The sequencing trap that cost the most time is documented in the harness README: --verify wants a sanitized runtime, never a sanitized compiler. Leaving build/nurlc sanitized turns the sweep from 1.5 seconds into ~50 minutes and looks hung. The tool now warns, and takes $NURL_SAN_RUNTIME so the sanitized runtime survives a normal rebuild.
tools/metamorph controls. Both
were suspected bugs and turned out to be defined behaviour, so they are now guarded against silent drift rather than "fixed": a partially filled struct literal zero-fills (the aggregate starts as zeroinitializer — @ P { 7 } gives 7, 0, 0, not garbage), and a match arm may bind a prefix of a variant's payloads and ignore the rest. An under-fill check written before measuring rejected the first and broke five tests that rely on it deliberately.
tools/metamorph gains an invalid-input class and an IR-validityinvariant.** The classes so far asked "the same situation spelled N ways — does the verdict agree?". This adds the other question: *bad input must be diagnosed, never lowered.* Two shipped bugs escaped exactly there, and both times nurlc exited 0 and clang reported the problem against generated IR rather than the line to change.
The invariant is the stronger half and applies to every spelling in every class, not just the new one: whenever nurlc exits 0, the module must pass the LLVM verifier (llvm-as). Unlike everything else in the harness this is never a template bug — whatever a program says, if the compiler accepted it then what it emitted has to verify.
tools/metamorph — a metamorphic coverage harness for the checker.
The fuzzers in tools/fuzz ask whether a program computes the right value. This asks a different question, on the axis where the diagnostics have actually been wrong: *the same semantic situation, written N ways, must get the same verdict*.
Four consecutive fixes were one shape — the checker asked its question at one syntactic spelling and missed the others (#898 generic vs plain wrapper, #899 : T b a vs ?/??/=/returned argument, #901 boxed vs typed payload slot, #902 mutation inline vs one call deep). None was the analysis being too weak in theory; each was coverage nobody had enumerated.
A disagreement is not assumed to be a compiler bug: a spelling accepted where its class says reject is rebuilt with --no-borrowck and run under AddressSanitizer, and reported as UNCONFIRMED if it runs clean — the template is then the thing to look at. That escalation is what separates a bug finder from a generator of plausible-looking noise. A controls class of correct programs guards the other direction, and fails separately and loudly, because a false positive is the worse failure.
is per top-level declaration: a reported error skips the rest of the @ it fired in, so two bad statements in one body produce one error and aborting due to 1 previous error. Nothing there is false, but the silence reads as "the rest was checked and passed" — and for anything driving nurlc in a loop, an adversarial sweep or a model fixing its own output, that inference is systematically wrong: nine of ten broken constructs look accepted when nine were never examined. The summary now adds one line saying the count is a lower bound.
Reporting every statement instead was implemented and reverted. With no statement terminator to resync on and no poison bindings, the extra errors were derived: a failed : 5 n 0 made the next line's ^ n read as "undefined identifier 'n'", one fixture reported the same error twice, and another desynchronised its braces into "unexpected '}' at the top level". Naming an innocent line costs a reader more than staying silent about it does; the real fix needs poison bindings and derived-error suppression, and the reasoning is recorded at the site.
pub prefix leaked out of its declaration — and out of its
file.** Found while adding the marker traits above, and unrelated to them. The scan_fn_sigs pre-pass set a pending-pub flag before dispatching on the declaration kind, but only the @-function arm ever cleared it. A pub on anything else — a trait, an impl, a struct, an enum, a const — left the flag set, and the next @ the scanner reached inherited it. Once the scan crossed an import boundary that @ was in a different file: importing a module whose last declaration was pub % Trait [T] { } silently made the importer's next function public and flipped the importer into strict-visibility mode, at which point its own unmarked types stopped being visible across files. The error blamed a declaration nowhere near the pub that caused it. The flag is now read-and-cleared where the prefix is parsed, and gen_import_decl consumes one too. (pub_prefix_scoped)
pointer form was rejected earlier, and the user-enum form after that. A Vec reaches the option/result payload path as %Vec__i64 — a named struct, not a raw pointer — so it took the struct-handle branch, which folds the value to an i64 and hands it to the slot. The ?? arm read it back as the declared type: @ ?i { T d } with a Vec d returned 192, the low byte of a heap address. Same lie as the first two, one branch over.
~ x xs foreach
holds a borrow of xs (§2.5) and the direct ( vec_push xs … ) in the body has been rejected for a long time; the same push inside a helper was not, which reads as "wrap it in a function" being a cure for a borrow violation. A per-function summary records which parameters' containers a body mutates, inferred in codegen order like the sink / escape / shared-mutation summaries, and the call site consults it. Measured, this shape is not memory-unsafe today — a Vec is a handle to a control block, so a forced realloc mid-iteration does not dangle — but a guard that applies to one spelling and not its twin teaches the wrong cure. (diag_payload_struct_into_number, diag_foreach_mutate_one_call_deep)
With these, tools/metamorph reports zero gaps: every spelling of every class now gets the same verdict as its siblings.
it is read back as arithmetic.** The option/result form of this was fixed earlier; the user-enum side kept the hole. @ E { A s } where A declares an i payload compiled clean, linked, ran, and returned 96 — the low byte of a .rodata address — from an arm the writer expected to yield a number.
docs/MEMORY.md has carried a "user enums: SCALAR payloads only" gotcha for exactly this. A gotcha in prose is what a compiler says when it has no diagnostic; this is the diagnostic.
Found by tools/metamorph's invalid-input class — and not by its IR-validity invariant, because the emitted module verifies fine. It is a wrong value, not malformed IR, which is why that class enumerates plausible-wrong inputs as well as checking what gets lowered. (diag_enum_payload_ptr_into_int)
^ ( vec_new [i] )
from a → i function lowered ret %Vec__i64 %r1 out of a define i64. The return-type chain asked about every pair of shapes it knew — float/float, int/int, {…}/{…}, struct/struct, int/enum — and a struct beside a plain number matched none of them, so it fell through to the emit.
arity check was conditioned on call_name == fname, which is false for a generic, and generics recorded no parameter count anyway: ( vec_push [i] v ), one argument short, was accepted, and a missing argument reads an unset ABI register — the same silent failure the FFI arity check exists to prevent. The template's count is now recorded under __garity, deliberately not the shared __arity: a file may define its own non-generic function with the same name as an imported generic, and writing the template's count into the shared key made every call to the local function look one argument short. (diag_ret_struct_and_generic_arity)
?? arm must name something the scrutinee can be. Nothing
checked that, and the consequence was not a diagnostic: an unrecognised name fell through to the enum-variant path, which emits load i64, i64* @<name> for a global that was never defined, and the arm's payload binding got an empty type —
`llvm %r8 = alloca ; "Cannot allocate unsized type" %r5 = load i64, i64* @Ok ; a global that does not exist `
— so nurlc exited 0 and clang delivered the news, about generated IR rather than about the arm the writer has to change.
The shape that surfaced it is the Rust habit rather than a typo:
` ?? ( int_parse 123 ) { Ok val → val _ → -1 } `
so the message names the NURL spelling instead of only rejecting the wrong one — T and F, not Ok/Err/Some/None. A misspelled variant of a named enum is reported the same way, and lists the variants that type actually has. (diag_match_arm_not_a_variant)
the copy, the ? / ?? join, the assignment and the callee-returns-its-argument spellings. tools/metamorph enumerated the same situation across spellings and found three the verdict did not agree on — each an ASan-confirmed double free (SEGV on a write):
- a nested ? — the outer join's arm is not a bare identifier, so the outer published no candidates and the inner's were dropped. One level worked, two did not. Arms now hand their candidates up. - an aggregate-literal field — @ Holder { a }. The lint already treated this as released and the code comment already called it a move; only the checker had not been told. Measured: the field and the binding share a data pointer, and a push through one is visible through the other. - a closure capture — a closure that frees what it captured. Moves recorded inside a closure body are deliberately dropped, since its statements must not inline into the enclosing function's list; that dropped this one too. The body's consumes are now replayed against the capture list.
All three are maybe-moves, reported under --strict-borrowck, for the reason that made the assignment case conditional in #899: the handover is certain, but the old name may still be a legal way to use the live buffer, and only liveness — which this checker does not compute — could say when it stops being one. For the closure there is a second reason: the checker knows the capture, not the call count, and a closure that frees its capture but is never called leaks rather than double-frees.
Recording the aggregate case as a definite move was tried first and reverted: it rejected the option-wrapper idiom the MCP tests are built on, where @ ?Json { T p1 } is a transient argument wrapper and p1 is still the caller's to free.
Default-mode acceptance is unchanged across stdlib, examples and 320 package files. Three stdlib files gain a --strict-borrowck diagnostic, all the documented mutually-exclusive-frees pattern that strict mode is stated to flag (a handle either returned inside an option or freed, never both). (borrow_strict_handle_second_name_more)
closure that mutates an Arc's contents through a helper, but the summary stopped at depth one: closure → outer → inner was accepted while closure → inner was rejected, leaving open the exact "wrap it one level further" escape the summary exists to close. A function that calls a mutating function is now itself recorded as one. Found by tools/metamorph on its first run, hours after #902 shipped.
Arc's contents is now a compile error.
docs/MEMORY.md §6.5 named this as the concrete unchecked data race — "Arc makes the refcount atomic, not your data" — and it was reachable from ordinary code: no *T, no FFI, not a single cast.
` : ( Arc ( Vec i ) ) a ( arc_new [( Vec i )] d ) : ( @ v ) w1 → v { : ( Vec i ) v ( arc_get [( Vec i )] a ) ( vec_push [i] v k ) } : ( @ v ) w2 → v { … the same … } `
Measured, two workers × 2000 pushes: 5 of 8 runs segfaulted, and the survivors returned 2000 / 2996 / 3166 instead of 4000. arc_get over a manually-managed handle hands back a copy of the handle aliasing the one buffer, so both workers write the same length and the same backing pointer, and whichever reallocates first frees the buffer the other still holds.
thread_spawn now rejects a closure that mutates the contents of an Arc it did not create, whether the mutation is inline or inside a helper it calls — moving a vec_push into a function must not move it out of the check, so a per-function summary carries it, inferred in codegen order like the sink and escape summaries.
The error fires at the detach, not at the mutation: mutating an Arc's contents from one thread is ordinary correct code. Four shapes stay legal and are pinned as a control — single-threaded mutation, a read-only worker, a worker mutating its own Vec, and the cure: mutation under a lock the worker takes itself. That last one matters most, because a diagnostic that forbids the fix it recommends is worse than no diagnostic; it is verified to compile and to return 4000/4000 over ten runs. Lock depth is counted rather than proved, and reset when a closure body is entered — a lock the defining thread holds says nothing about the thread the closure is detached onto — so the check can only miss a race, never invent one. (diag_thread_arc_shared_mutation, diag_thread_arc_shared_mutation_helper, thread_arc_shared_mutation_ok)
over.** Field 1 carries T's real type in both forms — ? i lowers to { i1, i64 }, ! i s to { i1, i64, i8* } — but the payload coercion believed a comment saying a result's slot "is always i64" and folded any pointer into it with ptrtoint. That is the correct representation for a slot which is an untyped box, and the wrong one for a slot declared as a number. Two shapes got past nurlc:
` @ ?s { T 42 } // nurlc exited 0; clang rejected the insertvalue @ ?i { T x } // compiled clean, linked, and RAN `
The second is the dangerous one. The fold made the address fit, so ?? o { T v → ^ v } returned the low byte of a .rodata address from a ^ v the writer expected to be a number — a silent miscompile with no diagnostic anywhere. The first only ever failed at clang, in a message about { i1, i8* } and i64 that named neither the option nor the line to change.
The fold now asks what the slot is before reinterpreting an address as arithmetic, and a backstop refuses to emit an insertvalue the coercion chain could not legally bridge — so a payload mismatch is a NURL diagnostic naming both types, not IR handed to the next tool. (diag_opt_payload_ptr_into_int, diag_opt_payload_type_mismatch)
tls_large_record
failed in CI with the client's two output lines missing — exactly the shape http_server_pipelined had in 0.42.0. Its IR was byte-identical before and after the branch it failed on, so the program under test had not moved.
The cause is the one fixed there: the client is spawned with & from a shell that then exits, orphaning it, and the collector later runs wait $(cat pid) in a different shell where that pid is not a child. The wait fails instantly — which is what the 2>/dev/null was hiding — and only a fixed sleep makes it work, which a loaded runner defeats.
The 0.42.0 note that no other test used the pattern was wrong: the grep it rested on was mis-escaped and matched nothing. A correct search finds http_server_tls, http_server_seq, net_loopback, http_server_limits (two clients) and tls_large_record — six sites in total. All six now use the deterministic handoff: the client writes a .part file and renames it when done (rename(2) is atomic, so the collector sees all or nothing), stale files are cleared first, the background group redirects its own stdout so it cannot hold the pipe process_run_shell reads, and the collector polls for the renamed file instead of assuming it has arrived. Verified against the failure mode rather than assumed: six rounds of all six tests under 24-way CPU saturation, 6/6 clean.
The second-owner release. A heap handle in NURL has exactly one owner, and the borrow checker's job is to notice when a second name acquires it. It recognised exactly one way for that to happen — the copy : T b a — and five other ways were invisible: a ? or ?? that selects between handles, an assignment that hands one over, a callee that hands an argument back, and a generic wrapper that consumes its parameter. Every one of them compiled clean and double-freed under AddressSanitizer.
Neither bug was the analysis reaching a wrong answer. Both were the analysis never being asked: the ownership question was asked at one syntactic shape, and values arrive by more routes than that. What made the generic case worse than a gap is that it was a *regression under abstraction* — the non-generic wrapper was rejected correctly, so adding [T] to a working program silently removed its checking.
Upgrading: code that this release rejects was double-freeing before. The default checker now errors on the unconditional shapes; the conditional ones need --strict-borrowck, because refusing them by default would also refuse the mutually-exclusive-frees pattern, which is correct code. If a rejection is a false positive, it is a bug worth reporting — the default checker is meant to have none, and the whole first-party tree (stdlib, examples, 320 package files) compiles with no acceptance change in either mode.
borrow checker now follows it there.** Move tracking recognised exactly one way for two bindings to end up owning one buffer: the syntactic copy : T b a. But any construct that yields a value can yield an existing handle, and three that do were ownership blind spots — both names looked like sole owners, so freeing both compiled clean and double-freed at runtime (all three confirmed under ASan):
` : ( Vec i ) chosen ? flag a ( make ) // a ? selects between handles = prev t // an assignment hands one over : ( Vec i ) c ( pick a 1 ) // a callee hands an argument back `
gen_cond / gen_match now publish which bindings their live arms could select and whether every live arm selects the same one; the : / = receiving the value turns that into a move. When the result IS one binding's handle on every path it is an ordinary move, reported by default. Otherwise it is a maybe-move — Owned ⊔ Moved in the existing lattice — reported under --strict-borrowck, which is also where the assignment and interprocedural forms land.
Those two are conditional even though the handover is certain, and that is a statement about the analysis rather than the code: after = prev t the old name is still a legal way to read the live buffer (the stdlib's HKDF expansion does exactly that), and only liveness — which this checker does not compute — could say when it stops being one. Reads of a maybe-moved binding are never flagged; only a second consume is. Flagging the reads instead would have rejected 77 passing tests' worth of correct code.
The interprocedural case reads a new returned-handle summary, g_fn_ret_alias, kept deliberately separate from §2.8's returned-parameter summary: that one answers "may the result be a stack reference?" and drives refdepth propagation, this one answers "may it be an argument's heap handle?" and drives move tracking. Widening one map to answer both would have moved every escape diagnostic that reads it. Each shape names its own reason in the diagnostic rather than sending the reader to hunt for a free on a line that only aliased. (borrow_phi_alias_definite, borrow_strict_phi_alias, borrow_strict_assign_alias, borrow_strict_ret_alias)
sink again. The
borrow checker infers an auto-sink when a body consumes a parameter, so the caller of a wrapper loses the binding and a second call is a use-after-move. That inference is a by-product of compiling the body — and a generic body is not compiled where it is written. It is stored and instantiated later, under a mangled name, so the summary reached neither the generic name nor any call site that preceded the deferred instantiation. The result was a hole with no diagnostic at all:
` @ dispose [T] ( Vec T ) xs → v { ( vec_free [T] xs ) } ( dispose [i] xs ) ( dispose [i] xs ) // compiled clean; double free `
The identical non-generic wrapper was rejected. compute_generic_inout_sink now reads the stored template's body for the auto-sink case as well as the declared markers, so the generic and ordinary forms are checked alike — under the default checker for the unconditional shape, and under --strict-borrowck when the first free is on one arm of a ?. The template scan reads the first argument of a call rather than every argument position, so it can miss, never invent. (borrow_generic_sink_wrapper, borrow_strict_generic_maybe_double_free)
http_server_pipelined no longer races its own client. The test
spawned the client with & from a shell that then exits — orphaning it — and later ran wait $(cat pid) in a different shell, where that pid is not a child. The wait failed instantly (hence the 2>/dev/null hiding it) and only a fixed sleep 0.10 made the test pass, which is not enough on a loaded CI runner. The client now writes a .part file and renames it when done — rename(2) is atomic, so the collector sees all or nothing — and the collector polls for it instead of assuming it has arrived. Verified against the real failure mode: under 32-way CPU saturation the old test dropped its client's output 1 run in 10, the new one 0 in 10.
The honest-failure release. Nothing here is a new capability; every entry is a place where the system already knew what had gone wrong and threw the answer away before anyone could read it. The compiler emitted invalid IR and let clang deliver the news from another function entirely. The package manager mapped curl's exit code to a precise transport error and then flattened four distinct causes into one PubHttp. A cluster read the top byte of a big-endian partial as a status flag and reported every healthy chunk as failed. And nurlpkg packed whatever happened to be lying in the directory, so the same commit published different bytes from different clones.
The through-line is that each of these failures pointed away from itself. That is the expensive kind: a wrong answer costs one debugging session, an answer that indicts the wrong component costs several.
httpc_request_timeout
caps one exchange in seconds, and httpc_default_max_time names the 300 s default that was previously an unexplained literal in the curl argument list. A server that calls an external service on a request path needs this: with only the default, a hung peer is indistinguishable from a hang of your own program, and 300 s is far past the point where an interactive caller should have given up.
packages/nurlpkg-smoketest — a dependency-free ~1.8 KB packagewhose only purpose is to exercise the registry end to end (pack → publish → index → resolve → download → verify → extract → build → run) and to act as the canary for packer determinism: two clean clones must produce one checksum. It also retires an old hazard — probing the publish endpoint used to mean uploading a real package, and a garbage body once became a real published version.
nurlpkg publish says which transport failed. PublishErr gains
PubTimeout, PubConnect, PubDns and PubTls, each with its own hint. The information was always there — http_cli maps curl's exit code to HttpcErr — but pkg_publish collapsed every variant into PubHttp, which reads identically whether the registry is down, a proxy is eating the request, or the upload stalled mid-flight.
The SSE event: ready payload carried a hardcoded {"server":"nurl-mcp"} from inside the stdlib, which was wrong for every other server built on it; it now reports {"transport":"streamable-http"}.
| or & emitted IR that
named a block in a different function.** A closure is lifted into its own define whose first block is entry, but the body inherited the enclosing function's __cur_lbl__. The first construct to phi over the block it started in then wrote an incoming label belonging to the parent — phi i1 [ 1, %divok_11 ], … inside a closure that has no divok_11 — and the user met clang's "use of undefined value" pointing at neither the closure nor the cause. The bug hid from reduced repros because it only appears once the enclosing function has left its own entry block; below that, the stale name coincides with the closure's entry and the IR happens to be valid. The regression test seeds a branch before the closure so the shape actually reproduces.
# name expr where name is a binding compiled to a phantom type.
llvm_type maps any unknown identifier to the named type %name, so # d + d 1 — a mistyped = d + d 1 — passed with rc=0 and a discarded- value warning, then emitted insertvalue %d undef, i64 … against a type the module never declares. It is now a pointing diagnostic that names the likely typo. The check fires only when the cast target is a bare %ident that is neither a declared struct nor an enum and is a live binding, so every legitimate cast is untouched.
nurlpkg's file filter was a fixed name blacklist (deps, nurl.lock, target, build, dotfiles) with no notion of compiler output, so a stray wasmbuilder.ll left by a local build was swept into the tarball: 269 147 bytes from one checkout against 35 847 from another, at the same revision, under different checksums. Build output is now excluded by extension (.ll, .o, .obj, .a, .so, .dylib, .dll, .exe). The filter is still extension-based, so an extensionless compiled binary left in the package root is packed as before — build/ and target/ remain the intended homes for it.
--max-time alone cannot tell a stalled transfer from a slow one, so a dead upload held the terminal for five minutes and then reported PubHttp. The client now passes --speed-limit 1 --speed-time 30: a transfer that stops moving gives up in about half a minute, while a genuinely slow upload keeps its full deadline. Measured against a real fault, 300 s → 37 s.
open/fcntl/printf
failed to link.** wasmbuilder mirrored a declare's parameters into a libc shim and copied the vararg marker in as a named parameter (define i32 @__nurl_open_shim(i8* %a0, i32 %a1, ... %a2)), which is not legal LLVM. The shim had no call sites at all. Dropping the ... in __wb_ir_decl_params restores the whole generated-kernel family (gpu_smoke.sh 7/7, was 0/7). Shipped as wasmbuilder 0.1.5, with nurl-mcp 0.10.1 and swarm-mcp 0.24.0 requiring ^0.1.5 — a ^0.1.0 requirement could still resolve the broken 0.1.4.
worker was never evicted.** swarm-mcp 0.24.0: the retry plan read body[0] as an "ok" flag, but an expression chunk answers with a bare [partial:8] whose top big-endian byte is almost always 0, so every successful chunk looked like a failure; frames are now parsed by job kind. Because dist/job forwards a job whose key the receiving node does not own, expiring members on the coordinator alone made workers bounce re-dispatched chunks back to a dead peer forever — every node now ages its own roster, and never itself (a node hears no HELLO of its own, and without the exemption would time itself out of its own ring).
The machine-width release. NURL could always describe what a CPU does one word at a time; what it could not describe was the two shapes every fast numeric kernel is actually written in — a 128-bit product and a vector register. Both are now in the language: nurl_umulhi gives the high half of a 64×64 multiply, nurl_addc/nurl_subb/nurl_mac give carry chains the backend recognises, and v128 is a first-class by-value type over ~27 primitives that each cost exactly one machine instruction. None of it is a target-feature gamble — 128-bit vectors and a 64×64→128 multiply are baseline on every 64-bit target NURL supports.
The point is what came downstream, all of it in ordinary high-level NURL: ChaCha20 at 1.84×, HTTP head parsing sixteen bytes at a time, and a pure-NURL TLS 1.3 handshake at 2 470 → 4 894 handshakes/s — with no assembly, no AES-NI-tier intrinsics and no OpenSSL anywhere in the path. Alongside: the build now caches (an empty program links in 99 ms instead of 305, a warm json_parse rebuild is cheaper than that row's cold C compile), the unikernel boots off a USB stick on real PC hardware, and the MCP layer speaks the tasks extension.
stdlib/ext/mcp_tasks.nu. The
io.modelcontextprotocol/tasks extension lets a server answer a tools/call with an asynchronous task handle instead of a final result, so expensive work no longer has to hold a JSON-RPC response open. The new module is the whole protocol side of it: a task store (unguessable 32-hex ids, TTL sweep, bounded retention), the wire shapes (CreateTaskResult with resultType: "task", the DetailedTask body tasks/get and notifications/tasks share), per-request capability negotiation, and handlers for tasks/get, tasks/update and tasks/cancel — including the two rules the spec makes non-negotiable: a CreateTaskResult may never go to a client that did not declare the extension on that request, and a non-declaring client issuing a tasks/* method gets −32003 with data.requiredCapabilities. Multi-round-trip execution is supported through inputRequests/inputResponses, with responses to unknown or already-answered keys ignored as the spec requires.
The module deliberately does not run the work: a server that wants tasks already owns a job engine, so a task carries an opaque link integer to tie the handle back to it, and the server drives mcp_task_complete / mcp_task_fail / mcp_task_request_input itself. packages/swarm-mcp 0.23.0 is the first consumer — its compute_submit family now hands a task-capable client a CreateTaskResult it polls over the protocol instead of through the bespoke compute_result tool.
Note: the extension draft pins MISSING_REQUIRED_CLIENT_CAPABILITY to −32003, whereas the base 2026-07-28 spec renumbered its own reserved codes into −32020…−32099. The extension is normative for its own methods, so mcp_tasks_err_missing_capability is −32003 while mcp_err_missing_client_capability stays −32021.
native Linux, nurl.sh now links with ThinLTO by default and keeps three caches under ~/.cache/nurl: the link's per-module backend codegen (stdlib/runtime.o is thin bitcode now, so the runtime — re-optimised from scratch by every full-LTO link since the language existed — is a cache hit for every build after the first, of any program), the pre-link -c object keyed by content hash of the emitted IR (a rebuild whose IR didn't change is a file copy, per split part), and the driver's toolchain probes (~190 ms of trial compiles that answered questions about the toolchain, not the program). What made thin-with-cache safe as the default rather than a dev-mode trade is running the ThinLTO backend at O3: plain thin at O2 loses full LTO's second optimisation round — bench/sort_window's compare/exchange mill came out 2.3× the instructions — and the O3 backend restores every such case measured, improving one outright (bench/nbody retires 36% fewer instructions). Measured: empty program 305 → 99 ms, json_parse 840 → 156 ms (rebuild) / 574 ms (rebuild after a one-line edit), http_server 1.96 s → 0.67 s. bench/RESULTS.md §2 now carries a "NURL rebuild" column — a warm NURL rebuild (129 ms on json_parse) is cheaper than that row's cold C compile (144 ms) — with the cold columns still measured against a wiped cache so the C and Rust comparisons stay honest. NURL_LTO=full + NURL_SPLIT=0 remains the maximal-inlining release build; NURL_CACHE=0 opts out; NURL_SPLIT_MIN=16384 is the opt-in fastest-edit-loop knob (json_parse edit rebuild 574 → 310 ms, at +7% run-time instructions). Scope: native Linux clang; the zig-cc, macOS ld64 and Windows paths keep the previous behaviour. One runtime fix rode along: the fiber test hooks' context structs are now extern — a ThinLTO link may import nurl__ctx_bounce into another module, and the asm string inside it names those structs, which LTO cannot see (the used-is-load-bearing comment in runtime_ctx.c, one clause further). Details: docs/BUILDING.md → The ThinLTO cache.
v128 type and its primitive layer.
NURL now has a vector register. v128 is a first-class by-value type (bindings, parameters, returns) lowered to LLVM's target-independent <4 x i32>, with ~27 nurl_v128_* primitives — load/store, lane arithmetic, shifts, rotates, lane permutes, byte-compare bitmasks and ASCII case folding — each emitted alwaysinline so it costs exactly one machine instruction. 128 bits is baseline on every 64-bit target NURL supports (SSE2 is part of the x86-64 ABI, NEON part of AArch64), so a vector kernel needs no CPUID probe, no target-feature attribute and no fallback path; it lowers to simd128 on wasm and scalarises anywhere else. Scalar bit primitives ship alongside — nurl_ctz, nurl_clz, nurl_popcnt, nurl_bswap32/64, nurl_rotl/rotr32/64. See docs/spec.md §4.1b.
nurl_umulhi — the high half of a 64×64 multiply. NURL's *
already gave the low 64 bits; together they are the 64×64→128 multiply the language could not spell, and its absence was the reason every wide-radix numeric kernel in the stdlib had to pick a narrow limb size that kept partial products inside an i64. Defined in stdlib/runtime_core.c over unsigned __int128 (with a 32-bit-halves fallback under NURL_NO_INT128) and emitted as a linkonce_odr/alwaysinline i128 definition in nurlc's preamble, exactly like nurl_peek — so it inlines to one mul/umulh with or without LTO, on every backend, while runtime.o's C definition still wins at link. Every crypto speedup below is downstream of it.
nurl_addc, nurl_subb, nurl_mac. The
companions to nurl_umulhi — the high half of a sum, of a difference, and of a multiply-accumulate (a·b + c + d, which cannot overflow 128 bits and is exactly one limb step of a Montgomery multiply). The language could already spell a carry; what it could not spell was a shape the backend recognises. Measured on a four-limb add: 15 instructions through these, against 26 for the hand-rolled wrap test they replace — the four limbs now legalise to add; adc; adc; adc.
image booted QEMU, Firecracker and cloud-hypervisor because they read its PVH note; no PC firmware does — a BIOS reads a boot sector, UEFI reads a PE binary off a FAT partition. The same ELF now carries a second door, a Multiboot2 header and _mb2_start beside the PVH note, and build_bootable_image.sh packages it into a hybrid BIOS+UEFI disk that dd writes straight to a stick. The two entries meet six instructions later — they hand over in the same machine state — so kmain and mem_init are untouched and the PVH gate stays 20/20. Four things a PC has that a microvm does not, each fixed at the root: no serial port (boot/console.c draws the same bytes on the screen — EGA text under BIOS, a linear framebuffer with an 8×16 font under UEFI, armed only on a Multiboot2 boot); nobody states the TSC frequency and on an AMD part neither CPUID leaf exists (pit_calibrate_khz counts TSC ticks against PIT channel 2 for 10 ms, and nurl_clock_source reports which of five sources answered); GRUB re-quotes the command line, so build_argv now finds the key and lets the quoting decide where the value ends; and the UART wait loop was unbounded, so a chipset answering 0x00 would hang before printing what would have said why. grub-mkrescue is deliberately not used — its -d takes one platform directory and derives nothing, so outside /usr/lib/grub it silently omits one firmware's image; the two boot images are built with grub-mkstandalone and assembled with xorriso, so the build runs unprivileged and in CI. demos/baremetal.nu is what goes on the stick first, and tests/baremetal_gate.sh checks the header structurally everywhere and boots the hybrid disk under SeaBIOS where the tooling exists (three mutations caught). Verified end to end under both SeaBIOS and OVMF. Font glyphs are generated from Spleen (BSD-2-Clause); NOTICE carries the notice.
stdlib/std/p256_scalar.nu — fixed-width arithmetic mod the P-256
group order.** The point multiply already ran on the fast Montgomery field mod p; the SCALAR arithmetic mod n (r·d, z+r·d, and above all k⁻¹) still ran on variable-length std/bigint.nu, where k⁻¹ is a 256-iteration Fermat modpow that clones a heap magnitude every step. The new module gives n the same treatment p had: four 64-bit limbs, Montgomery CIOS via nurl_umulhi, no allocation in the multiply, Fermat inversion over that field. n has no `p ≡ −1 (mod 2^64)` shortcut, so the reduction carries the general Montgomery constant n0 = −n⁻¹ mod 2^64. It exposes reduce / mulmod / addmod / inv over 32-byte big-endian scalars — the shape ECDSA already carries them in.
bench/run_http.sh measured only plaintext and printed a table someone pasted into a hand-maintained bench/HTTP_RESULTS.md. It now runs a second HTTPS pass against all three peers — NURL through the same HttpApp facade's http_app_listen_tls, Rust through tokio-rustls (the stack axum/warp use), Node through the built-in https module, each switched at startup so the plaintext path is unchanged — with a self-signed EC P-256 cert generated per run. The measurements feed bench/gen_http_results.py, which overwrites HTTP_RESULTS.md wholesale, giving it the same "do not edit by hand" contract bench/RESULTS.md has. .github/workflows/http-bench.yml runs the same install → build → run → commit flow as bench.yml, deliberately workflow_dispatch only: HTTP load numbers on a shared CI VM are far noisier than the wall-clock suite, so a refresh is opt-in.
tools/check_windows_paths.sh — a CI gate for paths Windows cannotrepresent.** Reserved DOS device names (CON, PRN, AUX, NUL, COM1–9, LPT1–9, at every directory, in any case, with any extension), reserved characters, trailing dots or spaces (Win32 strips them, so the file that arrives is not the one committed), and paths differing only in case. Catching these on Windows is catching them too late — the failure lands at the checkout step, upstream of anything that can explain itself, and the log names neither the rule nor the file. The gate reports it on Linux, naming both. 4/4 mutations caught.
handshakes/s.** A profile of the handshake put its cost in four places, and each was taken in turn — all in high-level NURL, with no assembly and no AES-NI-tier intrinsics. nurl_umulhi (above) is what made every one of them expressible.
- Poly1305 to radix 2^44. The accumulator was five 26-bit limbs, a radix chosen only so each h·r partial product stayed under 2^63 and plain i64 arithmetic sufficed. Rewritten as poly1305-donna-64: three 44/44/42-bit limbs, each h·r term a full 128-bit product accumulated as an explicit (lo, hi) pair — nine multiplies a block instead of twenty-five. Poly1305 alone 1 206 → 1 617 MB/s (+34%); ChaCha20-Poly1305 seal 397 → 432 MB/s, open 398 → 435 MB/s. - P-256 field multiply to 4×64 CIOS. __p256_mul_d was Montgomery CIOS over eight 32-bit limbs — 64 partial products a multiply. It now works in four 64-bit limbs (16 full 64×64→128 products) while the field's external representation stays 8×32, so add / sub / conditional-subtract, every constant table and the whole point-addition and inversion layer are untouched. P-256 ECDH keygen 654 → 375 µs/op (−43%). - X25519 and Ed25519 field to 5×51 donna-c64. The shared GF(2^255−19) field was the TweetNaCl 10-limb radix-2^25.5 form, whose limbs are narrow precisely so ten accumulate inside an i64. Rewritten as curve25519-donna-c64: five unsigned 2^51 limbs, so the multiply is 25 products against 100 and the square 15. The ladder, Fermat inversion and public surface are untouched. X25519 scalar mult 137 → 69 µs/op (2.0×); Ed25519 sign 387 → 225 µs/op (−42%). - ECDSA signing off generic bigint. ecdsa_p256_sign's tail swaps its five bigint operations for p256_scalar (above), dropping the generic path and its allocation churn out of the hot path entirely. Handshake-crypto microbench 47.4G → 12.3G instructions; live handshake rate 2 470 → 6 079/s single-core. - Fixed-base combs for k·G and x25519_base. Both are fixed-base multiplies — the base is the same constant every time — so the doublings a variable-base ladder pays can be precomputed away. p256ct_scalarmult_base is a 4-tooth Lim–Lee comb over a baked 15-entry table: ~64 D + 64 A against ~256 D + 64 A, still fully constant-time (it reuses the masked table scan the secret-path window ladder already uses). For X25519 the Montgomery x-only ladder cannot precompute — x-only has no complete addition — so x25519_base instead computes scalar·B on twisted Edwards, which does, and converts to the Montgomery u-coordinate via u = (Z+Y)/(Z−Y); self-contained in x25519.nu on its own donna-c64 field, so it adds no dependency on ed25519.nu. Both variable-base paths (ECDH shared secret) are unchanged. Handshake-crypto microbench 12.3G → 7.9G instructions; x25519_base alone 73.5 → 50 µs (1.47×).
Every one of these was ported Python-first: a reference oracle reproduced the exact limb arithmetic and was diffed against a bigint or reference implementation before a line of NURL changed. That is how the P-256 reduction's carry bug (a wrap flag read against the just-overwritten accumulator limb, always false) was found at the source rather than worked around. RFC 8439 / RFC 7748 / RFC 6979 vectors, crypto_evp, noise_handshake and minisign_verify pass throughout, and the changed paths are ASan-clean. All compiler-clean apart from nurl_umulhi itself — no bootstrap movement.
pure NURL on the v128 primitives, two blocks interleaved. The one-block kernel was the instructive failure: a quarter of the instructions but only a twentieth of the time, because a single ChaCha block is one dependency chain and retires 1.5 instructions per cycle where the scalar path's four independent quarter-rounds run at the machine's 4.2 issue limit. Two chains fill the pipeline. Output is byte-identical to the scalar path, which remains as the reference and as the big-endian implementation (compiler/tests/chacha20_simd_agree.nu pins every length 0–300 at three counters and offsets).
__find_head_end,
__bindex_crlf, __bindex_byte, __skip_ows and __string_eq_ci now run on stdlib/std/simd.nu's vector scanners instead of per-byte loops (and one libc memmem). The CRLF and \r\n\r\n searches are a single pass: compare against CR and LF, then AND the two bitmasks against themselves shifted, so a bit survives only where the sequence actually begins. Case-insensitive header matching folds only A–Z, so ^ and ~ — both legal HTTP token characters, and both folded together by the usual x | 32 trick — stay distinct.
primitives.** The CIOS Montgomery loops in __p256_mul_d and __sn_mul are unrolled and register-resident; __sn_mul no longer allocates and frees a 48-byte accumulator on every scalar multiply. The five hash modules take their rotations from nurl_rotl/rotr rather than a shift pair and an or.
the same host's hyper / tokio-rustls): plaintext 20.90 → 20.10 µs CPU per request, TLS 27.60 → 26.40 µs. NURL was already cheaper per request than hyper on plaintext (22.70 µs) and is now cheaper than tokio-rustls on TLS too (26.70 µs).
architectures.** v128 is the one part of the language whose codegen genuinely differs per ISA — nurl_v128_eqmask8 is a pmovmskb on x86-64 and a shift-and-narrow sequence on AArch64, and an align 1 vector load is a different instruction on each — so a lane or bit order that came out backwards would be silent everywhere the tests did not look. run_qemu_tests.sh, run_qemu_arm64_tests.sh and run_qemu_riscv64_tests.sh now boot simd_scan, wide_arith, chacha20_simd_agree and http_request_parser against the same hosted goldens, alongside the existing corpus. Four seconds under TCG. Verified: AArch64 19/19, RISC-V 20/20 (both including httpd, the guest HTTP server against a host client), x86-64 boots the four new ones too. The hosted cross-builds were driven as well — the bench/http_server.nu binary cross-linked for aarch64-linux-musl and riscv64-linux-musl serves correct plaintext responses under qemu-user, and negotiates TLS 1.3 TLS_CHACHA20_POLY1305_SHA256 with a real OpenSSL client on both.
trustworthy.** The high-concurrency latency cells were a closed-loop coordinated-omission artifact: at C=200 NURL's 10 blocking workers saturate, the other ~190 connections queue inside oha and never reach the server, so a p50 of 0.06 ms describes the handful in flight — not the 200 offered. Every closed-loop cell now carries its mean latency, the renderer computes the in-flight count by Little's law, and a cell is marked ‡ when that falls ≥2 connections below the offered concurrency. Throughput stays — it is the server's true saturation figure — but latency winners are now chosen only among non-starved cells, so a starved 0.06 ms no longer bolds or beats a real one (Rust's genuine 0.63 ms at C=200 wins that row). A new connection-setup section measures what the keep-alive tables amortise away: HTTP conn/s and, for HTTPS, TLS handshakes/s — the cost a short-lived-connection edge deployment actually pays — which also corrects an old note that claimed the TLS tables measured the handshake and then contradicted itself. A "planned rigor" section names what the harness does not yet do (open-loop latency, core isolation, µs/req via getrusage, large-body record throughput), so the limits are on the page instead of left for a reader to discover.
Checkout, before a line of the build
ran.** unikernel/boot/con.c — the obvious name for a console — is a path Windows cannot create: CON is a reserved character device at every directory, in any case, with any extension, so git refuses to try. Renamed to console.c/console.h with a console_* prefix so nothing is left that tempts the name back, and the class as a whole is now gated on Linux (see tools/check_windows_paths.sh above).
The diagnostics-hardening release. A corpus-wide mutation probe (one realistic mistake injected into each of the ~790 test programs, ~5 400 mutants per run) drove six root-cause series (#874–#879): at the start it found 401 broken programs the compiler accepted and only the LLVM verifier/linker rejected, plus 9 inputs that hung the compiler forever; at the end it finds zero — every mutant either compiles correctly or dies with a single anchored file:line:col: error: naming the rule and the cure.
} cascade now leads with the real cause, and the
no-generic error carries a full location.** A function declaration swallowed into the previous body by a missing } parsed as a struct-literal opener and died with "expected '{' but found 'b'" — blaming a missing operand on the wrong line entirely (263 of the probe's drop-close-brace mutants cascaded from that misdiagnosis). An @ name … statement whose tail reads like a function header (a → before any {) now says exactly that: declarations do not nest, the } above never closed the previous function. And the "call to generic function … but no generic of that name" error gained its column (file:line:col: error: like every other diagnostic).
a diagnostic, not a silent no-op.** = fails + fails 1 1 inside a ? … { } statement parsed as = fails + fails 1 plus a bare literal 1, which the dangling-operand check exempted as "the block's value" — but the conditional's value is discarded, so the literal was dead and the mistake invisible (85 of the mutation probe's extra-operand mutants compiled silently through exactly this hole). The exempted literal is now recorded instead; a value join (phi) consumes the record — : i x ? c { 1 } { 2 } stays legal — and a ?/?? statement that finishes void with the record still set dies with the caret on the surplus literal and the arity cascade named.
file and line — and a missing import is a real diagnostic.** A diagnostic raised while re-parsing a generic instantiation used to read <generic scale2__i64 from file:N>:1:C: … — a "filename" with spaces no tool can parse, pointing at line 1 of a one-line buffer nobody can open. Template capture is now line-preserving and the re-lex buffer is padded to the template's own start line, so the same error reads stdlib/std/sort.nu:37:9: error: …; the instantiation half of the story (mangled name, first call site, "may depend on the concrete type arguments") is appended to the message via a nesting-safe context suffix every die/warn emitter understands. The no-generic-found error gained its missing error: tag, and a $ import naming a nonexistent file — formerly a bare nurlc: cannot open '<path>' from the C runtime with no location — is now anchored at the $ directive in all four places imports are read (the three pre-scans and the parser), with the resolution order spelled out.
closed.** Four more ways broken source reached the LLVM verifier (rc 0 from nurlc, a .ll line number, no source location) are now front-end diagnostics, and one is now simply correct output:
* a top-level declaration with type and name swapped (: PI_FIXED i 314) emitted an undefined-type global; with a type keyword in the name slot the diagnostic names the swap and spells the corrected declaration; * a generic template's bare name used as a type (Pair p field, JArr Vec enum payload) emitted unsized %Pair / %Vec; both sites now run the known-type check, which learned to say "a generic names a family of types" with the instantiation spelling; * an aggregate value as a binary-operator operand (= cnt + cnt swallowing the ?? below it) emitted add i64 …, { i1, i64 }; rejected with both facets named — no aggregate arithmetic, and the arity cascade that put one there; * a surplus value in an anonymous aggregate literal (@ ?i { T + n 2 2 }) emitted an insertvalue past the last slot (or silently overwrote a result's sibling arm); anonymous shapes now get the arity check named structs already had; * statements after a terminator (^ 1 then a call; cleanup + a defensive ^ after an infinite loop) rode LLVM's implicit-block-after-terminator quirk and were invalid IR whenever the dead run did not itself end in ^; dead statements now compile into a fresh unreferenced block — valid IR on every shape, identical semantics, and LLVM's DCE drops it.
} in a generic template or a closure body no longer
hangs the compiler.** collect_fn_body (generic function/struct template capture) and simple_capture_analysis (closure capture pre-scan) walked braces with no EOF guard, so nurlc file.nu spun forever on an unclosed body — the drop-close-brace mutation hung on every generic-using corpus program (9/9). Both loops now stop at EOF with a hard error anchored at the opening { that never closed, naming the cure (add the }; an extra { inside skews the count the same way) and pointing at nurlfmt, whose re-indent makes the runaway nesting visible.
implicit fall-off tail and closure tails included, and the integer→pointer hole is closed at all four value positions.** A corpus-wide mutation probe (one realistic mistake per program, 5331 mutants) found 401 broken programs that nurlc accepted (rc 0) and only clang/the LLVM verifier rejected — three build stages later, with a .ll line number and no source location. 390 of them were one hole: an integer value where a pointer/string type is declared (^ n from a → s fn emitted ret i64 %n out of a define i8*). The old carve-outs for a "null idiom" (^ 0, : *T p 0, @ P { 0 }) protected IR that was itself invalid — store i64* 0 / insertvalue %P …, i64 0 never linked, so no working program used them. Implicit integer→pointer conversion is now rejected at return, binding init, assignment, and struct-literal fields, each with the cure spelled out (# *T expr; the null pointer is # *T 0), and the spec's null-idiom paragraph is rewritten to match reality.
The return-agreement battery (float/pointer/width/aggregate/nominal/ enum checks + the enum wrap) moved into one shared helper (ret_ty_agree) that every return path runs — explicit ^, the function fall-off tail, and closure tails. Fall-off consequences:
a value-returning body that falls off with no value* used to emit ret i64 undef — valid IR returning garbage; it is now the "function body ends without a return value" error (with the count-your-braces hint, since an extra } truncating a body is the other way to arrive there); a bare enum variant or a *?? whose value arms are all variants of the declared enum** returned by fall-off used to emit ret %Color <i64 tag> — invalid IR; both now wrap correctly (gen_match learned to publish the join-variant proof gen_cond already had); * an exhaustive ?? whose arms all ^-return now terminates its end label (unreachable) even without a fallback edge, so the two-way T/F result dispatch counts as a closed path; * a tail call into a noreturn chain is exempt: nurl_exit / nurl_panic seed a registry, functions whose bodies contain no ^ and whose tails diverge are inferred noreturn (private-name mangling respected), and a ~ T loop with no break targeting it counts as diverging — so die-style helpers and loop-only-return functions (stdlib's Hoare partition) need no dead ^.
llvm_to_nurl also learned to spell pointers back as NURL types (i64* → *i), so the new messages name types the way the user wrote them. Re-running the probe: 0 of the 488 wrong-return-type mutants reach the linker; each dies with one diagnostic naming the rule and the fix.
sites — nurlpkg install nurllama works again.** On v0.37.1 the install died inside the stdlib:
` <generic vec_free_with__String from stdlib/std/path.nu:352>: error: method 'drop' has no impl for receiver type 'String' `
vec_free_with [A] v ( @ v A ) drop calls its callback parameter as ( drop . buf i ). Function-typed parameters resolve via the __param marker, not __ptr — and impl-method dispatch only excused __ptr/__arity/__ffi names. So the moment any file with a % Drop <T> impl entered the import closure (nurllama 0.16.0's new history.nu imports ext/sqlite.nu, which impls % Drop Database), every call through a parameter named drop was treated as trait dispatch. Worse than the error: a receiver type that did have an impl would have silently called the impl instead of the parameter. The callee is now checked for a local callable (closure-struct or fn-pointer typed __ptr/__param binding) before impl dispatch, exactly as locals already shadow the bare-@fn arity check; scalar and named-struct locals sharing a method's name still dispatch. Gate: a differential sweep of all 1437 tracked .nu files — byte-identical IR/stderr/exit everywhere except the two files that flip from compile-error to compiling (nurllama/history.nu, registry/db.nu — the same sqlite + path combination). (#868)
?-arm used directly as a call argument no longer
leaks the picked literal per evaluation.** ( f ? cond a b ) materialised the chosen literal as an owned heap temporary that no drop was ever emitted for — ~32 bytes per evaluation, which a 4-billion-parameter LoRA finetune turned into ~3.6 MB/step of host leak (the per-launch kernel-name prefix had exactly this shape) and the kernel OOM killer ended two 5-hour training runs. Literal/literal joins are now uniformly borrowed with zero allocations; literal/owned mixes are repaired at the join block (copy the picked pointer, free the owned original through a null-select); owned/literal keeps a correctly-gated eager copy. Covered by a test running every ownedness mix through both paths. (#865)
server_run_async no longer leaks ~192 bytes per accepted
connection.** The accept loop spawns a fire-and-forget closure per connection, and spawn BORROWS its env — there was no correct place to free it (LSan: 101 allocations for 101 connections). The loop now uses the new spawn_owned (below). (#867)
spawn family is now noinline — closing a
TLS-under-LTO miscompile window.** Adding a new spawn entry point made the async HTTP test hang deterministically: LTO inlined it into NURL code and mislowered the __thread scheduler-worker access (the exact class the existing noinline on nurl_fiber_current / yield / park documents), so the spawned fiber was enqueued onto a queue no worker drains. The existing entry points dodged this only by the optimizer's inlining choices. (#867)
spawn_owned — a fire-and-forget fiber that owns its closure
env.** ( spawn_owned \ → v { … } ) transfers the env to the fiber; the runtime frees it right after the body returns. Use it for per-item inline closures nothing else holds a handle to (one fiber per accepted connection); plain spawn still borrows, which is right for a long-lived closure the spawner frees later. (#867)
Profiled under load (perf + strace -c + per-thread CPU accounting) and removed every per-request cost that was structural rather than essential: the keep-alive loop built **and freed a full fallback-500 response on every request** (now hoisted to the connection, rebuilt only after a panic actually consumed it); the Connection-header checks allocated an owned String copy per request even on a miss (now an allocation-free in-place scan); carry-buffer compaction was three per-byte copies (now one memmove); header serialisation indexed with nurl_str_get — strlen per byte, O(n²) — and pushed byte-at-a-time (now one scan + one memcpy). The async net wrappers toggled O_NONBLOCK per operation — **4 fcntl syscalls per request, 46 % of the async server's syscall time** — now memoized on the handle; an HTTP request is served in 2 syscalls. (#866)
Under an HTTP load the work-stealing path ran **26 steal attempts per request with a 0.008 % hit rate** — ~24 futile victim-lock acquisitions per request; each worker now keeps an exact queue length read lock-free by the probe, and stealing skips empty/singleton victims without touching their lock. Every reactor park also calloc'd/free'd its wait entry across threads; entries now recycle through a freelist under the existing lock. Async hello server: 44.4 → 38.4 µs CPU/request, C=200 throughput 237k → 261k rps. (#867)
bench/http_server.nu now serves through the packages/http
HttpApp facade** — the surface a real NURL service deploys — and bench/HTTP_RESULTS.md is refreshed (the old table was months stale). Fresh medians (i7-5930K, oha, 3×10 s): **NURL 15.3k / 166k / 144k / 151k req/s at C=1/10/50/200 — ahead of Rust hyper at C=1 and C=10** with p50 flat at 0.06 ms; the C≥50 gap is the blocking-pool ceiling, and the async path's remaining reactor serialization is recorded in the backlog with measurements. The packages/http facade itself (0.3.2) dropped a recover layer that duplicated the stdlib server's unconditional panic→500 guarantee — one throwaway 500-response build per request for zero added safety. (#866)
json_parse back to fastest in the benchmark table — the RFC 8259
control-byte scan was accidentally quadratic.** The conformance fix in 0.36.0 (#817) added __jp_span_ctrl, a linear scan over the escape-free fast path's span, and indexed it with nurl_str_get — whose bounds check re-runs strlen on every call. The span is a pointer into the middle of the document, so every one of those strlens walked to the document's end: the "linear" scan was O(span × rest-of-document), and the benchmark's json_parse went from fastest of the five languages (8.8 ms) to slowest of the compiled three (42 ms). The scan now reads through a raw pointer, the way the accessor's own documentation says every parser loop must. Conformance is untouched — the same tests pass — and the row is NURL's again.
reaches users.** During the v0.37.0 image rollout play.nurl-lang.org served `Error proxying request to container: The container is not running, consider calling start()` — the Durable Object proxied to an instance that no longer existed, and the Worker's recovery policy, which knows the not listening wedge and the transient restart strings, did not know this one, so it passed through as a 500 instead of triggering a teardown and cold-start retry. The string now classifies as wedged: destroy clears the stale instance state and the replay lands on a fresh container. Covered by test:recovery with the literal production body.
tools/check_diag_coverage.sh — a diagnostic is not finished when it
is written, it is finished when something has read it.** The ax work scored diagnostics by scanning compiler/nurlc.nu: does the message name a cure, state the rule, show a spelling. That instrument is blind to the one thing that decides whether a message is any good — whether it is ever REACHED. The new gate compiles the corpus, matches every emitted diagnostic back to its source site, and reports the sites nothing made speak. It found 88 of 220 sites unread on its first run. At this release: **191 of 229 exercised, 17 never fired, 14 ambiguous, 7 unmatchable by text** — the three categories after "exercised" are the tool declining to claim more than it can prove.
An unfired message is unverified in every way that matters. Its wording has never been read next to the program that caused it, which is how the one FALSE message found in ecc058c survived a source scan that scored it as excellent. Its caret has never been checked. It may be dead outright, preempted by a looser check upstream — so the good message exists and the user still gets the generic one.
The count ratchets against tools/diag_coverage_baseline.txt: existing gaps do not fail the build, a new die site with no test that fires it does. Adding a diagnostic and adding the program that triggers it are one change, not two.
docs/PLATFORMS.md used to say macOS was "expected to build from source with Homebrew LLVM; unverified". Expected-to-build was a claim nobody had run, and running it found five real defects — four of them invisible from Linux and FreeBSD, because those two share a heritage macOS does not (GNU-ld-family linkers, timeout(1) in base).
The new macos-tests workflow runs the full host path on every push to main and every PR: ./build.sh (bootstrap fixed point + the corpus), the nurl.sh driver end to end, and the examples gate. The corpus runs against the same compiler/tests/outputs/ goldens as Linux and FreeBSD rather than a divergent outputs-macos/ tree, so a macOS-only miscompile cannot hide as a platform difference — it passed 641 of 641 when the leg landed, and the corpus has grown to 780 files since.
Apple Silicon only. macos-13, GitHub's last Intel image, never left the queue across ten runs, and a required check that cannot schedule blocks every PR — so macOS x86_64 stays tier 3 rather than being claimed on a gate that never reports.
tools/check_diag_anchor.sh — a diagnostic must point at themistake.** Coverage answers "has anything ever printed this?"; it cannot answer "did it point at the right thing?", and the two are not equally bad. A precise message against the wrong line is worse for a reader than a vague one against the right line, because it sends them somewhere real to look for a problem that is not there.
No annotation is needed to catch the mechanical case. Many checks can only fire once their operands are consumed, and by then the lexer sits past the statement — so die lex anchors on whatever came next. When the mistake is a function's LAST statement, what came next is the closing brace, and the compiler prints a caret under a }. The gate reads the committed goldens (they already hold the location, the echoed line and the caret) and rejects two shapes: an anchor line holding nothing but closing delimiters, and a caret column past the end of the line it echoes. Unterminated-construct messages are exempt — there, end-of-input really is the subject.
It found nine on its first run, and a tenth once the column rule was added. Fixed and held at zero, so this one gates rather than ratchets.
tools/diag_mutate.py — one realistic mistake in a real program.Every negative test in the corpus is minimal by construction: the mistake is the program, so there is nothing after it to go wrong. A model writes a hundred lines and gets one of them wrong, usually not the last one. This probe takes working programs, injects a single mistake a model plausibly makes, and counts what comes out — one diagnostic (good), several (a cascade to triage), or none.
Not a gate: the mutation set is a judgement about what models get wrong, and the interesting result is a program to go and read, never a number to enforce. Its first run reported six programs silently accepted; every one was the mutation landing inside a comment, which is the same masking lesson diag_coverage.py already carries, learned the same way.
&/| arity trap is a hard error by default;
--no-strict-arity demotes it back to a warning.** As a warning the trap was the worst possible outcome: ? & a b c d { … } { … } compiled to status: ok and a binary whose conditional logic is wrong, and nothing downstream could tell — the whole reason --strict-arity and its CI gate existed. The first-party tree has been strict-clean since that gate landed, so the default now matches what the repo already enforced. --strict-arity stays accepted as a no-op for compatibility.
The check has two known legitimate-but-flagged shapes — a ~ loop whose condition is a bare-arm ? (~ ? flip < n 3 < n 5 { … }), and a bare { … } scoping block immediately after a bare-arm ? — both with trivial rewrites (fold the ternary into &/| logic; drop or move the scoping block). A tree that hits them faster than it can fix them builds with --no-strict-arity, and the error message names that escape hatch.
long.** Picking the longest literal at a site is the obvious rule and the wrong one: the longest run is usually the shared explainer tail two sibling messages both end with, while the sentence that differs — the one naming which construct was rejected — is shorter and got discarded. Nine sites read as indistinguishable for that reason alone, which was the instrument's fault and not the compiler's. The tool now prefers a literal no other site carries. **Six real gaps came out from behind a sibling's hit**, having been counted as exercised for as long as the tool has existed.
Matching is by message text, so two sites emitting the same sentence are indistinguishable — one test made both read as exercised. Eleven such groups existed, covering 30 sites; the worst prints cannot store a value of type … into an element of type … from six places. Those are reported as ambiguous rather than folded into "exercised". A number that flatters the instrument is worse than a smaller one that is true, and this instrument had been reporting its own best case for eight passes. The correction cost 28 sites at the time; the entry above carries the figure as it stands at release, since a later pass sharpened the matching again.
merged after 0.36.0 was cut and each shipped its own tests, but none wrote a CHANGELOG entry, so the release would have understated what it contains. Recorded here from their commits:
Trait dispatch.* Calling a trait method with a receiver whose type has no impl fell through dispatch and emitted a call against an undefined symbol — an error only clang reported, far from the call. Impl-method calls, static and through the vtable, never checked arity at all: ( speak d ) against speak Dog d i volume assembled, and the callee read an unset register. Five pre-existing leaks in the dyn path went with it. FFI call arity, float casts, and `u8`.* ( sin 1.0 2.0 ) against a one-parameter declaration assembled and ran with the surplus ignored; ( pow 2.0 ) read an unset ABI register. # f x emitted sitofp from a struct, an enum or a string — invalid IR only clang saw. And the documented u8 spelling had never worked: it was missing from llvm_type's ladder, so every : u8 x carried a phantom %u8 type. Generic call type-argument arity.* A surplus type argument compiled clean and resolved to a monomorph no other site shares — silently meaning something else. A deficit died deep inside instantiation with an unrelated substitution error. Aggregate, enum and payload shapes.* Returning a ?f out of a → ?i, a differently-signed closure out of a declared closure return, a number into an enum parameter, and a float payload into a declared integer payload — the last bitcast the bits into the slot, so the ?? arm read 4609434218613702656. *Dead-store clash checks fired on a ? join whose arms both return.** A store that can never execute was diagnosed as a type error, which broke the playground image build.
compiled.** = n + n followed by ( side ) took the call as its second operand; a v-returning call's value is undef, so nurlc emitted add i64 %r2, undef with status 0. The program linked, ran, computed garbage, and still performed the swallowed call as a side effect of an addition. die_if_void could not see it — it tests the operand's value against void, and a void call's value is undef. The rule now lives at the binary-operator site alone: an initialiser may legitimately take a ? whose arms both return, which also yields undef.
stdlib/ext/json.nu emitted the : separator unconditionally.
Two lines read ? + k 1 < n {, which in prefix form is the condition k + 1 — always true — followed by a dead < n { … }. The IR shows it plainly: icmp ne i64 %r4, 0 as the branch test, and icmp slt i64 %r6, undef computed and discarded. Written `? < + k 1 n {`. Found by the new probe, via the guard above: the object emitter had been taking the value branch whether or not a value followed.
member said which construct it meant.** A closure's parameter list and a declaration's printed the same sentence; a repeated variant inside an or-pattern was reported as "a match arm already covers" it; and the unresolved-operand message for & and for | did not name the operator. Each now identifies itself — which a reader needs independently of any tooling, since the two members are reached by different mistakes.
was.** They named the value's type and then said "into this element", leaving the reader to find the container's declaration to learn what was wanted. All six had the expected type in scope at the point of the message. They print it now.
# S { 1 } was accepted silently for every single-field struct.The guard that catches `'#' is the cast operator; struct/enum literals use '@'` tested whether the name is a struct by reading the key for field index 1 — the second field. A struct with one field failed that test and the literal went through unremarked, while a two-field struct was caught all along, which is what kept the gap invisible. A single-field struct is the newtype wrapper, one of the commonest shapes there is.
→ on a guarded match arm was reported as a
non-exhaustive match.** The guard scan ran to the next → at paren depth 0, and braces do not affect that depth — so `A v ? > v 0 { ^ v } B → 0` swallowed the arm's body and the whole next variant, and the failure surfaced as "no arm covers variant 'A'" about the arm the writer had just finished writing. A guard is one expression and never contains a brace at depth 0, so a { there is the missing arrow and nothing else.
runs first has no symbol table to test enum-ness with, so it described every named-type mismatch as a struct one. Its parenthetical was already accurate; the lead was not, and struct is the word a reader takes away. Both kinds are nominal, which is the rule that matters and is true of either.
and the wrong one.** Returning a call whose declared return type is v is the commonest way to reach that error and went unmentioned. It is named first now.
IR, silently.** A by-value struct parameter has no alloca to GEP from, so = . s a 5 printed getelementptr %S, %S* , i32 0, i32 0 — with an empty pointer operand — and nurlc exited 0. Only clang objected, as "expected value token" against generated IR the author never wrote. Rejected at the source now, naming both cures: take the argument as inout if the caller should see the write, or copy it into a : ~ local first.
i8. The narrow-operand
literal check accepts the signed range union the unsigned one, because it does not read the operand's signedness — so the window is a fact about eight bits, not about i8, which holds -128..127. Stating it as the type's range taught a rule the language does not have. The semantics are unchanged; only the claim is.
% Speaker : Dog { @ speak Dog self → i { ^ 1 } } consumes Dog as a supertrait name, which leaves the lexer on { — so the declaration is read as a re-declaration of the trait, and the impl body vanishes. No speak was emitted at all, and the program still compiled and linked. The supertrait-on-impl message that exists for this can never fire for that shape, because by then it is not being read as an impl.
The hole underneath was that structs, enums and impls each guard against a duplicate declaration and traits did not — so a second % Name { … } quietly replaced the first. Traits now carry the same position-keyed guard, which makes import replay idempotent and this shape an error that names both the duplicate and the impl spelling.
-> where NURL's → belongs said "expected a type". The ASCII
pair does not lex as an arrow at all: - and > are two separate operators, so the parse died asking for a type and never mentioned the arrow. A model that cannot emit U+2192 had no way to learn that from the answer. The token table was telling the same lie from the other side — it rendered TT_ARROW as '->', naming a spelling that cannot produce that token, so "found '->'" sent the reader looking for two characters their source does not contain. Both now say '→', and the minus-then-greater-than pair gets a message of its own.
printed a caret under a bare } — closure and fn-pointer call arity, dyn and impl method arity, the no-impl dispatch miss, three return-type mismatches and enum arithmetic — and one printed a caret two columns past the end of the line it echoed. All ten are the same cause the three fixed in the previous release had: the check runs after gen_expr has consumed the operands. Anchored with die_stmt, which now has 17 callers, up from 7.
: | { A B } answered "expected '{' but found 'A'" and blamed the fixed-arity operand trap, which is not what happened: gen_enum_decl took the name on trust, registered whatever { renders as, and then failed at the brace it had already eaten. parse_type_enum had carried the right message all along, but it only sees : | in TYPE position — the top-level declaration is a different path and never consulted it. Both spellings now answer alike.
check_type_known already guarded parameter, struct-field, return and FFI types — and not the place a type is written most often. So : *Foo p … against an undeclared Foo leaked %Foo into the IR, nurlc exited 0, and clang objected a stage later about generated code. Same helper, same wording as the parameter case, so the two now agree instead of disagreeing by omission.
: n i 0 — the name first, then the type — said "use of undefinedidentifier 'i'".** That is how Go and Rust spell a binding, so it is a mistake worth expecting; the old answer named the token and then called it the wrong thing, sending the reader to look for a binding that was never the problem. A bare type keyword yields no value and so can never open a legitimate initialiser: in the inference branch it is this swap and nothing else, and the message now says which order NURL uses.
inout field diagnostic ended mid-quote. `struct 'S' has no
field 'zz — its nurl_str_cat4` had no room left for the closing quote, so the message trailed off exactly where the field name ended.
rejects.** The binding is three tokens — type Elem i — as docs/spec.md §4.9, docs/LIMITATIONS.md and __parse_assoc_binding's own comment all say. All three diagnostics about it said type Name = ConcreteType, and the one that fires on a malformed binding said it *while rejecting the very form it asked for*: write type Elem = i and the compiler answers "must be bound to a simple type name — write type Name = i". A model that did what the message said hit the same message again, with nothing in the text to break the loop.
Found by probing the never-fired list, which is what it is for. The coverage gate then flagged all three rewrites as unread — a rewritten message has been read by nobody, which is why the ratchet keys on message identity and not on a count.
[T] was told about the default arm.
The default-arm branch tested only "is this an identifier", so ints → oi { … } was accepted as the _ marker and the parse then failed one token later with "expected '{' to open the default arm's body" — a message about a construct the writer never used, offering a cure (_ → { body }) with no bearing on the mistake. The marker must literally be _; anything else is a channel arm missing its brackets, and the arm-shape message that was already written for exactly this now gets to say so.
produced a message with no die site of its own — the compiler fell through to something generic — so both were invisible to the source scan and to the coverage gate alike, and only writing the wrong program found them:
* @ Point { x : 3 y : 4 } (named struct-literal fields) died as "use of undefined identifier 'x'", pointing at the field name as though it were a typo. It now states that literals are positional and shows the declaration beside the literal that builds it. * @ E A 1 (the variant outside the braces, as in E::A(1)) died as "expected '{' but found 'A'", blaming the fixed-arity operand trap — which is not what happened. It now names the variant and shows where it goes. The check fires only when the ident really is a declared variant, so a genuine stray token still gets the arity message it deserves.
named __kw_default_or_die.** NURL has had default parameter values since kwargs landed (@ box s label i width = 10 → v), and this error fires precisely when the skipped parameter has none. The message asserted the opposite as a language rule, so a model that read it learned to never use a feature the language has. It now names the parameter, states the real rule (optional iff the declaration gives it a default), and offers all three cures including adding one.
The first thing the coverage gate found, and the second message of this shape after the bool-widening one in ecc058c — both shipped because nothing ever printed them.
The bool-mix checks in gen_logical_or / gen_logical_and and the missing-argument check in __kw_default_or_die all fire only after gen_expr has consumed the operands, by which point the lexer sits on the NEXT statement — die lex blamed it. Anchored with die_stmt, which exists for exactly this and already had four callers. A model reading a precise message about the wrong line is worse off than with a vague message about the right one.
macOS.** Bringing the platform under CI turned up:
`fcntl`, `open` and `ioctl` were declared fixed-arity* though all three are variadic in C. x86-64 SysV and Linux AAPCS64 pass variadic arguments in the same registers as fixed ones, so the wrong declaration is indistinguishable from the right one there; Apple's arm64 convention passes them on the stack. So fcntl(fd, F_SETFD, FD_CLOEXEC) set some other value, silently: __set_cloexec did nothing, the exec-errno pipe's write end survived execvp, and process_spawn waited forever for an EOF that could not come — while __set_nonblock, the same call, left sockets blocking so every async server stalled its worker thread instead of parking on the reactor. Ten corpus failures, one wrong arity. The ... marker (already used by ext/sqlite.nu for exactly this reason) now says so. aarch64 and riscv64 hosts ran fibers on `ucontext`.* The backend gate named NURL_CTX_X86_64 alone, so two ISAs whose context switch runtime_ctx.c has long provided — and whose AArch64 backend passes the unikernel gate in CI — fell through to the libc path. Harmless on glibc, fatal on Apple's deprecated arm64 makecontext. Both gates now ask one name, NURL_CTX_AVAILABLE, defined where the coverage is decided. `fs_glob` would not descend a symlinked directory.* glob(3) resolves links in intermediate components; the helpers were using the lstat-based classifier that dir_remove_all needs (so an rm -rf cannot follow a link out of its tree) and rejecting type 3. macOS made it total — /tmp is a symlink there — but it reproduces on Linux, which is what fs_glob_symlink.nu pins. The "is this clang new enough" gate read a version string.* Apple numbers its releases independently, so Apple clang 15 passed a check meant to admit LLVM 15 and then failed inside LLVM's IR parser, naming our bootstrap snapshot for what is a toolchain problem. A capability probe decides now, and names Homebrew LLVM in the cure. `-Wl,--as-needed` reached Apple's ld64*, which errors on an unknown flag rather than ignoring it. -dead_strip_dylibs is the same intent, picked by uname in both build.sh and nurl.sh — both, because the driver carries its own link line.
Supporting plumbing: run_tests.sh resolves timeout/gtimeout rather than assuming the former, and build.sh exports the compiler and link flags it resolves so split_equivalence.sh and the tools/*/build.sh scripts stop each working them out again — that drift is how a run reached for the system Apple clang in the middle of a Homebrew-LLVM build.
through a call, and the boundaries around it.** Found live via the playground: ( twice y ) with y a float against @ twice i x compiled clean and returned garbage — under opaque pointers a call carries its own function type, so call i64 @twice(double …) is textually valid IR, clang assembles it, and the callee reads a register the caller never wrote. Probing that class systematically found the same hole in nine coats:
positional calls* — float↔integer in either direction, now rejected with the register-level consequence spelled out; FFI calls* — the width-coercion block handled ints and pointers but let ( labs y ) hand a double to C's long parameter; generic calls* — checked after tparam substitution (( vec_push [i] v 1.5 ) dies). Root cause was two-layer: the call-site checks skip generic callees by design, and any [T]-generic's parameter roster was silently abandoned because T lexes as a boolean literal and scan_skip_type refused it at a type position; kwargs calls — the `name:` reorder path bypassed every* per-argument check and spliced default values verbatim (u k = 5 put a raw i64 5 in the call's argument list); it now runs the same battery, and stores lowered types so a u-typed argument no longer prints an internal spelling into the IR; closures* — a (@ f f) closure passed to a (@ i i) parameter invoked it with the declared signature and reinterpreted every argument; now compared whitespace-blind and rejected; `inout` arguments* — passed by address, so ( bump n ) with : ~ i n against inout f x had the callee storing a double's bits into the caller's integer; the binding's type must now match the declared parameter type exactly; `b` parameters* — a wider integer used to be emitted raw (mismatched signature); the fix surfaced that toml_basic.nu itself passed 0/1/2 into a b kind parameter and silently double-printed through two ? == kind N arms. Narrowing int→b is now rejected (a parameter is a contract; the truncation keeps only the low bit), while b still widens losslessly into integer parameters — and an i1 now always zero-extends (T is 1, never the -1 a sign-extend produced); float widths* — f32 ↔ f now coerce at call boundaries via fpext/fptrunc (the binding law; previously a mismatched call signature), and a return must match the declared float width exactly, like integer widths (^ x of f32 from → f emitted ret float out of a define double); literals in operators* — + 10 flag printed add i64 10, %c with %c: i1 (invalid IR, clang-only error). A bool operand now widens (10 or 11, the C reading), an integer literal evaluates at the typed operand's width and must fit it (+ 300 u8val is rejected, not wrapped), an f32 register widens against a float literal, and two registers of different float widths are rejected like their integer duals.
The same law now guards the remaining aggregate boundaries: slice-literal elements ([ i | 1 2.5 3 ] used to emit store i64 2.5 — invalid IR with a .ll line number and no source location; widths coerce, float↔int and pointer↔scalar die) and struct-literal fields (a pointer value into a scalar field died only in clang; the @ P { 0 } null idiom and handle-stash coercion stay legal).
Conditions grew a type. ? / ~ accept b or an integer (tested non-zero) — that part is unchanged — but a float, string, enum, or aggregate condition used to become icmp ne double %r, 0 / icmp ne i8* %r, 0: invalid IR only clang reported. Each now dies at the source with the comparison to write instead (floats get the NaN caveat, strings get both readings — emptiness vs null-ness, enums get "which variant would be false?"). # b of a float — poison for any value but 0/1 — is rejected the same way.
Every new diagnostic names the argument position, both types in NURL spelling, the exact runtime consequence being prevented, and the cure with the operator to type. All are pinned as diag_* goldens so the text itself is regression-checked (16 new), plus a behavioral test pinning the legal coercions (call_scalar_width_coercions). net_inet.nu (an i result into a b parameter) and toml_basic.nu were fixed where the new checks caught them red-handed, and toml_basic's golden no longer bakes in the double-print. The misleading '( f inout v )' call-syntax suggestion in three inout diagnostics — a form the language never accepted — now shows the real shape and where the inout marker actually lives.
break and continue (grammar v2.4). Every parsing loop in this
tree was : ~ b run T plus = run F plus a condition that reads the flag — three lines for one, and three places to forget the reset. Both are reserved identifiers, classified by the lexer like pub, not symbols: every two-character prefix spelling collides with a program that already exists. ~> in particular would have swallowed the 28 loops written ~ > cond { … }, turning ~ > i idx { into continue i idx {. Two identifier names is the cheaper price.
Both terminate the block they appear in, exactly as ^ does, and both bind to the innermost ~ body. Using either outside a loop is a compile error naming the reason rather than a silent no-op.
The subtlety is ownership. A jump leaves the block without running the code the normal path would, so the compiler emits the loop body's whole drop sequence at the jump before branching. Getting that wrong is invisible in a functional test: the first implementation leaked because the drop emitters rewrite the owned-value lists they walk — correct at a function's single exit, wrong at a branch, because the fall-through still runs on every other iteration and still needs its own drops. A continue taken once left 49 of 50 iterations' closure environments unfreed. The lists are now snapshotted and restored around the jump, and the ASan+LSan gates cover both jumps over a capturing closure.
--strict-arity, and a CI gate that runs it over the whole tree.
The n-ary &/| foot-gun is the language's one remaining source-level trap, and its outcome is the worst available: `? & a b c d { … } { … } reads as ? (& a b) c d`, so the last two comparisons become the bare then/else values, both blocks run as ordinary statements, the conditional logic is wrong — and the compiler emits a working binary with status: ok, so nothing downstream can notice. nurlc --strict-arity makes it an error; the default stays a warning (now naming the flag) so existing trees keep building. tools/check_strict_arity.sh runs the strict compiler over every first-party .nu file and is wired into CI.
The gate found the shape it was written for on its first run. examples/audio_sparcles2.nu had `? & >= x 0 < x W & >= y 0 < y H {} { = . plife i 0 }` for "kill particles that wander off-screen": the condition was only the x test, the y test was swallowed as the bare then-value, and the kill ran unconditionally — culling every particle on every frame. Fixed with the third & it always needed.
--lint reports a Vec or String nobody releases. Those twoare the handles the compiler deliberately does not auto-drop (docs/MEMORY.md §7.4), so forgetting one leaks with no diagnostic anywhere — and it is the mistake a language model makes most, because the type looks owned and the code looks finished.
Ownership leaves a binding by the routes the compiler already models, and the report fires only when none of them was taken: a *_free destructor, a sink argument, an alias copy into an immutable binding, a ^-return, a store into an aggregate literal, a store into a struct field, or an argument the callee embeds.
Two of those the compiler could not previously see, so they are new summaries — deliberately separate from g_fn_escapes rather than folded into it. Escape means the value outlives the whole call chain, so a stack reference handed there must be rejected; embedding a value in an aggregate does not imply that (wrap puts a closure in a Slot the caller may consume in the referent's own scope, which ret_escape_agg_ok exists to keep legal). Conflating them turns that negative control red — which is how the distinction was found.
Measured against stdlib + examples + the corpus while it was built: 645 → 126 warnings, stdlib 97 → 14, each step a root cause rather than a suppression. What remains is dominated by forward references — a callee that frees its parameter, defined after its call site, so the sink summary is not yet known — a limitation the existing inference already documents for itself.
nurl_docs reads a section instead of a document. Answering "who
frees a String?" cost the whole 44 KB of MEMORY.md, and spec.md (63 KB) did not fit the per-call cap at all. Documents are already carved into headings, so: query= searches every section of every document — whole-word, ranked by coverage, headings outranking body prose — and returns the best few with the keys to fetch them; outline=true is one document's heading map (1.5 KB for MEMORY.md); section='7.4' or section='manually-managed' returns one section (3.4 KB). Search and retrieval deliberately use different boundaries: retrieval is hierarchical, so asking for §2 includes its §2.x children, while search treats each heading's own prose as the unit — otherwise a document's H1, having no same-level sibling, spans the entire file, outscores every real subsection, and hands back exactly the 44 KB the feature exists to avoid.
nurl_build_native run=true — compile and run in one call. The
hosted playground could build a native binary and hand back a download link, but the only way to see a program's OUTPUT was nurl_build_unikernel: boot it as its own kernel under QEMU and read the guest console. That works and stays the sandboxed path, but it is a heavyweight answer to "what does this print".
run=true executes the program under the same timeout(1) wrapper every build tool uses and returns exit code, stdout and stderr.
It ships off. Executing a freshly compiled binary is unsandboxed code execution against the container's filesystem, network and environment, and the hosted instance is an unauthenticated public compile farm. NURL_ALLOW_RUN=1 is the operator saying otherwise — the same switch, and the same reasoning, as nurl-mcp --allow-run over HTTP. Asking for a run while it is off returns an error naming both the switch and the sandboxed alternative, rather than a build with no output, which a caller would read as "it printed nothing".
The compiler is the only teacher an agent has, so a message that names what it wanted without naming the cure costs a whole iteration. This release swept the 173 die sites; **170 now carry an explanation, up from 95**.
Severity came first, and centrally: the four die emitters printed file:line:col: message with no error:, while the borrow checker's diagnostics carried one, so a single build printed two lines that looked like different kinds of output. All ~160 sites gained it at once.
Then the messages. Parser errors name what was found as well as what was expected, and the correct form with an example — arms, bindings, declarations, closure bodies, field access, select, or-patterns, traits, inout. Type and borrow errors state the rule that transfers: a heap handle has exactly one owner; *NURL has no overloading and no file-scope shadowing; every parameter must be supplied. The use-after-move error now names what* consumed the value (by string_free, by an alias copy) rather than only the line.
Two messages were not terse but wrong, and both were found by writing the mistake and reading the answer rather than by scanning the source:
* ^ f 1 — a call with the parentheses left off — led with the closure reading and told the writer to wrap working code in a closure. The likely cure leads now. returning a bool from an `→ i` function asserted "NURL has no implicit conversions"*, which is false: a bool widens into an integer binding, deliberately. An agent that believed the sentence would have learned a rule the compiler does not enforce. It now states the real one.
A 30-case probe suite over realistic mistakes backs the sweep, and it is what found the missing check listed under Fixed below — a program that compiled clean is the worst outcome of all, because no wording can teach what the compiler never says.
windows-tests
workflow ran on main pushes only, reasoning that PRs were already gated by the Linux+FreeBSD corpus and that "a main breakage surfaces here within one push". That rationale was falsified twice in one batch: run_tests.ps1 is a separate implementation of *how do I run this test*, so two corpus additions carrying a compiler flag (arity_strict_* → --strict-arity, lint_* → --lint) passed every PR gate and turned main red on merge. Surfacing within one push is not the same as being caught, and each cost a second PR to undo.
It now runs on PRs as well, with a changes classify job so a docs-only PR does not spend an hour of the slowest runner, and a concurrency group so a superseded run is cancelled. docs/PLATFORMS.md moves Windows from tier 2 to tier 1 on both tables, which is what the tier definition has always said: tier 1 is "every push and PR".
RELEASING.md claimed no test corpus ran on Windows in CI at all. That stopped being true when the workflow landed and is now corrected.
vec_free_with frees its Vec. The
destructor rule keyed on a _free SUFFIX, so vec_free consumed its argument and vec_free_with did not — even though it releases strictly more: the container, and every element through the dropper it is handed. That is how the stdlib frees every Vec of owned elements, so the blind spot sat on the most common shape. A Vec read after vec_free_with compiled clean, with no diagnostic, and ran on freed memory. It is now a use of moved value error like every other use-after-free, with a borrow_vec_free_with regression.
operand check exempted all comparisons, for a reason that is only about equality: == ptr 0 is a null check and pointer-to-pointer compares in i64. Written as "comparisons", the exemption also covered < > <= >=, where a pointer against an integer means nothing — so ? > 1 \s`` compiled clean, linked, and produced a running binary that silently compared an i64 against an address.
Found by the diagnostic probe suite, in the group that compiled with no diagnostic at all. A missing check is the worst AX outcome there is: no wording can teach what the compiler never says.
Ordering comparisons are checked now; equality keeps the exemption exactly as before, pinned from the other side by cmp_ptr_null_ok.
ext/json string parsing now follows RFC 8259, and the module's own
promise holds.** json.nu states "the serializer's output is guaranteed valid JSON". Two string paths falsified it. §7 forbids unescaped U+0000..U+001F inside a string; a raw one was accepted — by two routes, because the escape-free fast path memcpy'd its span without looking at it and only strings that also carried a backslash ever reached the decoder. Both are checked now, so a future optimisation that reintroduces a blind copy fails the test rather than the spec. §8.1 requires the text to be UTF-8, and a lone surrogate half has no UTF-8 encoding: ["\uD800"] decoded through the 3-byte path to the bytes ED A0 80 (WTF-8) and json_stringify handed them straight back, so a round-trip produced output no conforming parser accepts. Lone halves now become U+FFFD, substituted at the single choke point every code point passes through, so no future caller can reopen the hole. A valid surrogate pair is untouched — 😀 still decodes to U+1F600. Found while fixing this: json_basic asserted that a raw newline inside a string parses, because a NURL backtick literal turns \n into a real 0x0A; the test was encoding the bug, and now uses the conforming spelling for the same value.
?, not at the } that revealed
it.** ? & a b c d { … } { … } — the n-ary foot-gun the language documents — can only be detected once the { after the conditional is reached, and the diagnostic reported the lexer's position at that moment. For a multi-line body the caret landed on the closing } {}, several lines below the mistake, pointing at the consequence. gen_cond already captured the ?'s line for the borrow checker; it now keeps the column too and reports there, via a new warn_pos (the non-fatal twin of the existing die_pos, which exists for exactly this reason).
nurl_docs — the documentation tree an agent could not
reach.** nurl_api answers "what is the signature"; it never answers "who frees this", "which cipher suites ship", or "does this target have threads". Those answers live in docs/MEMORY.md, docs/CRYPTO.md and docs/PLATFORMS.md, and until now exactly one of the fourteen documents had a tool (nurl_read_gotchas) — so a model driving NURL through MCP guessed at precisely the questions the project has already written down. nurl_docs with no argument lists the tree (path, size, title); with name= it returns one document. The name is matched forgivingly — MEMORY, memory.md, docs/MEMORY.md and ./memory.md all reach the same file, and a bare basename reaches a nested one (COMPILER_INTERNALS → dev/COMPILER_INTERNALS.md) — because a model that has to guess the exact spelling will spend calls guessing it. Documents are capped at 48 KB per call and the truncation line prints the exact offset to pass for the rest, so spec.md is reachable in two calls instead of being silently cut. A name that matches nothing answers with the index rather than a bare error, which turns a wrong guess into the right next call. Both servers expose it: the hosted playground (NURL_DOCS_DIR, default /opt/nurl/docs) and the local nurl-mcp package ($NURL_DOCS, else $NURL_STDLIB/docs) — install-toolchain.{sh,bat} now ship docs/ into the prefix, so an installed toolchain carries its own documentation.
nurl_api answers concept queries instead of shrugging at them.
The query search AND-matches its terms, which is right when a model names one thing two ways and wrong when it names a concept: nothing in the stdlib is one declaration that contains all of string, builder and append, and vec_push new string_new is three different functions. Both used to return zero and jump straight to examples and the package registry — past string_push_str, which was sitting right there. Now a zero-hit query is re-run as an OR over the same declaration blocks, with two rules that keep the result small. Terms match as WHOLE words (the adjacent byte must not be a letter, so string hits string_push_str, string_new and "a string", and misses substring), and each hit is ranked by how much of the query it covers — terms weighted by LENGTH, since new is three characters half the constructors contain while vec_push is eight that name one function, and a term in the declaration's own name outranks one in its prose. The top twelve come back labelled [2/3 terms]. Only if the OR pass finds nothing does the reply widen to examples and the registry as before, so the corpus fallback no longer buries a real answer. Shared engine (stdlib/ext/mcp_search.nu), so the playground and the local nurl-mcp gained it together.
(unikernel/boot/virtio_rng.c). This architecture has no entropy INSTRUCTION — the seed CSR is the optional Zkr extension and QEMU's rv64 CPU does not implement it — so the answer comes from a device, at the same layer x86 answers with RDRAND and AArch64 with RNDR. With -device virtio-rng-device an RSA-2048 TLS 1.3 handshake against the guest completes (84 s under TCG — the interpreter's price, not the protocol's); without it the machine refuses BY NAME rather than inventing anything, because "your TLS handshake failed" is a much worse way to learn a device is missing.
The driver is C in the boot layer and deliberately not the NURL virtqueue: getrandom must work with no NURL module linked, for a program that never touches the socket layer. One queue, one buffer, no negotiation beyond the version bit. It trusts the device for bytes and not for the count — one claiming to have written more than the buffer holds is refused. The gate checks both halves, because a fake source passes the obvious one: with the device a draw must be neither all zeroes nor equal to the next draw, and without it the refusal must name the device.
too** — as a flat Image, the container those two take on this architecture where x86 gives them the ELF's PVH note. boot/boot_arm64.S carries the format's 64-byte header at offset 0 and build_unikernel_arm64.sh emits prog.Image beside prog.elf; POST /build_unikernel returns it as image_artifact. One program, two wrappers, no second build: code0 is a branch over the header, so a loader jumping to offset 0 and an ELF loader jumping to the entry point arrive at the same instruction. text_offset is 2 MiB because the image is not position-independent — the field is what makes a loader place it where its absolute addresses already point — and image_size covers .bss. The hypervisor gate checks the header the way it checks the PVH note (magic, code0 really a branch, text_offset equal to the link offset, image_size ≥ the file), with four mutations caught; QEMU boots the flat Image, which is what proves the branch and the load address agree. Booting the other two on this container needs an AArch64 host — neither emulates — and the gate says so instead of implying coverage.
architecture, and the one that proves the second was not a coincidence. QEMU's virt board with OpenSBI underneath, four files and a linker script (boot_riscv64.S, platform_riscv64.c, tls_guest_riscv64.c, nolibc/setjmp_riscv64.S, link_riscv64.ld) plus an RV64 fiber switch in runtime_ctx.c. Gate unikernel/run_qemu_riscv64_tests.sh is 15/15 — the same corpus programs against the same hosted goldens, the device demos, faults reported with exit 126, and an HTTP server in the guest answering curl on the host. CI builds and boots all three architectures on every commit.
Three things this machine made explicit rather than let pass: fp IS s0 on this ISA, so parking the device-tree pointer in s0 and ending the frame-pointer chain three instructions later zeroed it — the guest reported "no device tree", which was a true statement about a register the boot code had just cleared, and an assembly probe of the firmware handover is what told the two apart. The firmware talks: OpenSBI prints on the same UART before the kernel runs, so the guest marks its own first byte and the run script drops everything before it — a rule that belongs to us rather than to whatever the banner looks like this year. And **there is no entropy source**: RISC-V's seed CSR is the optional Zkr extension and QEMU's rv64 CPU does not implement it, so this port panics on a request for randomness, naming what is missing, instead of inventing it.
plan phase U6). QEMU's virt board, and the port is the size the design predicted: four files differ — boot/boot_arm64.S (EL2→EL1, FP/SIMD, vector table, stacks, .bss), boot/platform_arm64.c (PL011, device tree, MMU, generic timer, RNDR, PSCI), boot/tls_guest_arm64.c (the thread pointer) and nolibc/setjmp_aarch64.S. Everything above that bottom edge is the same code the x86_64 image runs, which the gate proves rather than claims: unikernel/run_qemu_arm64_tests.sh is 15/15 — the same corpus programs against the same hosted goldens, the device demos, faults reported with exit 126, and an HTTP server in the guest answering curl on the host. A hello image is 131 784 bytes, within a kilobyte of the x86_64 one; the memory floor is 5 MiB. CI builds and boots it on every commit, with zig as the cross toolchain — the same zig the ecosystem installs.
stdlib/runtime_ctx.c gained an AArch64 fiber switch (AAPCS64: x19–x28, x29/x30, d8–d15 and FPCR) beside the x86 one, so fibers, channels and the M:N runtime work there natively rather than falling back to ucontext.
Three machine differences are absorbed in the port rather than pushed upward: virt announces its virtio-mmio devices in the DEVICE TREE, so platform_arm64.c parses the tree and synthesizes the same virtio_mmio.device= entries the x86 guest reads off its command line; virt defaults to LEGACY virtio-mmio exactly as microvm does (the guest read version=1 and refused every device — correctly); and the clock states its own frequency in CNTFRQ_EL0, so the tsc_khz= handshake turns out to be an x86 quirk rather than part of the contract. The TLS image's offset from the thread pointer is MEASURED, not assumed: the boot code places the image, reads a canary __thread back through the compiler's own addressing, and refuses to run if the value is not there — a thread-local block off by sixteen bytes reads plausible garbage.
nurl_build_unikernel (plan phase U5). An agent builds
a bootable image and — because the tool's boot defaults ON, and an agent cannot run qemu — gets the guest console and exit status back in the tool result: write program → build → read the boot log → iterate. files bakes a read-only filesystem, args is the guest argv, and the ELF comes back as a download link, never inline bytes. Listed in tools/list, /mcp-info and the server's instructions text.
The target dropdown gains Unikernel x86_64 · bootable image: one Build click compiles the editor's program into a PVH image, boots it in the playground, and renders the guest console with its exit status, the image download, and both ready-to-paste QEMU commands. A guest-args field appears for the kernel command line's args="…". The examples dropdown greys out what cannot boot — **measured, not listed**: unikernel/measure_capability.sh links every bundled example at image-build time exactly as a request would (42/64 capable today) and GET /examples serves each verdict with the missing symbols as the reason, so the tooltip says why (signals, canvas/audio, hosted-HTTP FFI). A deployment that never measured skips the annotation instead of faking it.
boot: true — the playground proves the image boots (plan phase
U3). POST /build_unikernel can now boot the image it just built, inside the container: TCG (no /dev/kvm there), no network device, its own wall-clock bound via timeout(1) (NURL_UNIKERNEL_BOOT_SECS, default 20 s), concurrency bounded by the compile permit the request already holds. The reply's boot_result carries the guest console (CR-stripped, capped at 64 KB), the parsed [nurl-exit] status, timed_out, and qemu's own stderr — a failed boot is a diagnosis, not an empty log. A server program that would listen forever still proves it boots: the log holds what it printed, exit stays null. qemu-system-x86 adds ~72 MB to the image. e2e: the booted guest's print and exit 0 are asserted through the HTTP surface; a deployment without qemu answers ran: false with a reason instead of failing.
POST /build_unikernel — the playground builds bootable unikernel
images** (plan phase U2). Body: source (+ filename), optional args (the guest's argv — returned inside the boot command, never executed server-side), optional files ({relative/path: base64} baked into the image as a read-only filesystem; keys are refused on a leading /, a backslash, or any empty/./.. segment, because the tar is built with tar -C dir .). The reply carries the ELF artifact + size and a boot object with ready-to-paste QEMU commands — the exact flags the guest gate boots 20/20 with, plus a networked variant — verified by pasting them verbatim: the built image boots and prints. Rate limiting, the compile semaphore, body caps and artifact GC all apply automatically (/build* prefix). End-to-end tests cover build, download, initfs bake, all four traversal shapes, and that a broken program answers with the compiler's own diagnosis.
U1). The nurlapi Docker image ships unikernel/ (477 kB), plain binutils, and a pre-warmed boot-object cache (205 kB), and its build now ends with a smoke check that compiles two images with the exact toolchain a request will use — a hello, and a listener that pulls the NURL socket layer through the nm seam. If either does not link, docker build fails: the playground can never ship an image whose unikernel toolchain is broken. Building an image inside the running container as the unprivileged server user takes ~1 s warm (132 KB hello ELF). The HTTP endpoint over this is the next phase.
(unikernel/boot/fdt.c). AArch64 and RISC-V need the same three answers out of the same format, and the third port is the right moment to stop copying the second's. Generalising it found a real narrowness in the AArch64 original: it classified nodes at a hard-coded depth, and RISC-V's virt puts its devices under /soc where AArch64's puts them at the root — so the walker found nothing and the guest reported "no virtio-net device" about a machine that had one. Nodes are now classified by NAME at any depth, with address/size cells tracked per level because a bus node may state its own.
so** (plan phase U7). Firecracker and cloud-hypervisor both boot an x86_64 kernel by reading the same XEN_ELFNOTE_PHYS32_ENTRY note QEMU reads, so the artifact needs no repackaging for either. unikernel/tests/hypervisor_gate.sh checks the note structurally on any machine — present, owned by Xen, type 18, four-byte descriptor, and the address in it equal to the ELF's entry point, which are set by two different files and whose disagreement is a boot that works under one loader and not another — and then boots the image under cloud-hypervisor and Firecracker where /dev/kvm exists, skipping loudly where it does not (neither has an interpreter fallback). Three mutations (entry moved, note type changed, section renamed) are all caught. On AArch64 the gate gives the honest answer instead: PVH is x86-only, QEMU's virt board loads the ELF directly, and the other two want a PE-format Image there — a packaging step not taken rather than a property claimed.
/build_unikernel takes arch ("x86_64" default, "aarch64"`), the MCP tool takes the same field, and the target dropdown gains Unikernel AArch64 · bootable image (QEMU virt). Everything the x86_64 path already had follows the field: the smoke boot runs qemu-system-aarch64 -M virt, the returned boot commands name the right machine, and the AArch64 ones carry no tsc_khz= because that clock states its own frequency. An unknown arch is a 400, never a silent build for the default — an image that builds, downloads and does not boot is the worst possible answer. e2e covers both architectures end to end, including the guest console from each.
unikernel/build_unikernel.sh is a tool now, not a repo-rooted
script** (unikernel-in-the-playground plan, phase U0). It takes --out-dir, compiles the program-independent objects once into a locked, stamped cache (NURL_UNIKERNEL_CACHE; warm rebuild of a hello image: 0.14 s), names every per-program artifact after the program so concurrent builds cannot clobber each other, and with both directories pointed elsewhere never writes the repository at all. compile_nu.sh defaults NURL_STDLIB to the repo it lives in — a package build from a foreign cwd used to die with "cannot open stdlib/core/string.nu", and die SILENTLY, because compile errors went to a file nothing printed; failures now carry the compiler's message on stderr (NURL_COMPILE_QUIET=1 restores the silence for the corpus runner, which classifies by exit code). All four properties are pinned by a new unit gate (unikernel/tests/build_tool_gate.sh): foreign-cwd build, read-only repo, concurrent byte-identical ELFs, loud failure.
getchar and nothing noticed, because
glibc's <stdio.h> defines it as a macro (getc(stdin)) — every x86 build resolved the call in the preprocessor and never reached the library. A libc whose completeness depends on which headers happen to be installed is not complete; the AArch64 port, whose headers declare it as a function, found it at the link line. nolibc/math.c's sqrt likewise assumed sqrtsd; it now selects the architecture's instruction and refuses to compile (rather than answer differently) on one where neither is known.
PRs #780–#792). unikernel/build_unikernel.sh prog.nu produces a PVH ELF that QEMU's microvm machine boots directly — no host OS, no libc, no interpreter: the same runtime_core.c, the same nolibc, and exactly three files that talk to the machine (boot/boot.S, boot/platform_x86.c, boot/tls_guest.c). What the image contains, in the order it was earned: real guard pages under every coroutine stack and a clock that is told (tsc_khz=, wallclock= on the kernel command line) rather than guessed (#781); the virtio-mmio transport with the ring logic host-tested before it ever met a device (#782–#784); virtio-net and DHCP, so the guest asks the network who it is (#785–#787); a filesystem baked into the image as a tar and program arguments from the command line's args="…" key (#788); TLS 1.3 served from the guest — RSA certificate read from the image, RDRAND entropy, X.509 validity checked against the told wallclock (#790); and an MCP endpoint that IS the machine, answering initialize / tools/list / tools/call from a client on the host (#792). Measured, under TCG (an interpreter — the floor, not the ceiling): 354 KB plaintext image, 378 KB with TLS; hello answers on a 3 MiB guest, the HTTPS server on 4 MiB; cold VM to first HTTP answer 2.5–6.6 s. The unikernel CI job runs the boot gates on every commit (#789), and the guest gate stands at 20/20.
(unikernel/runtime_bare.c, PR #774). One coroutine type carries both thread_spawn and spawn on a single vCPU, cooperatively. The property the design buys: deadlock is DECIDABLE — with no coroutine runnable, no timer armed and no device that could deliver unprompted, the runtime prints which wait can never be satisfied and exits, instead of hanging. nolibc also grew a libm whose coefficients are generated, not copied, with Payne–Hanek reduction — accurate on purpose, gated differentially against glibc.
PRs #776–#779). stdlib/net/ gains ethernet, ARP, IPv4, ICMP, TCP (connection table, retransmit, persist probes, TIME_WAIT), UDP as a mailbox that preserves datagram boundaries, and DHCP — none of it touches an fd, a clock or a device, which is what lets it be tested under a scripted clock and fuzzed frame-by-frame (net_frame_fuzz, 5 injected parser bugs caught). On top of it unikernel/net/sockets.nu implements the socket ABI (nurl_tcp_*, nurl_reactor_*) in NURL, so the ordinary corpus — async servers, websockets, HTTP — runs with **no libc and no kernel sockets: 452 tests pass** against their unmodified goldens. A socket wait parks on its fd (#778): a machine whose every waiter is parked is provably idle rather than merely quiet.
layer takes an fd ceiling (sock_max_fds, default 1024, sock_set_max_fds to change, 0 = none), checked before accept dequeues so a refused connection is never half-adopted. Reported as an accept error, not EAGAIN — EAGAIN plus a still-readable listener is a reactor spin.
unikernel/README.md (#794): what this machine promises, whatit refuses (fork/exec, signals, a writable filesystem), and what it costs, with the measured numbers above pinned in one place.
std/net.nu's async paths parked on the reactor with a hard-coded "for ever", so tcp_set_timeout was silently discarded the moment a read happened inside a coroutine — invisible on hosted targets (threads are OS threads there), fatal on the bare runtime (a thread IS a coroutine there). The socket now keeps its deadline in the handle (nurl_tcp_timeout_ms, both runtimes) and the fiber paths honour it. Fixing it uncovered three more, each real on its own: the bare scheduler's idle sleep waited for the next coroutine timer regardless of the caller's own deadline; the bare reactor had no timeout_ms == 0 POLL semantics (0 fell into "no deadline" and blocked — it had always been wrong, and passed only because the deadlock detector's −1 read as "not readable"); and the HTTP/2 client's readiness probe counted that poll's honest "not ready" as READABLE (>= 0 where the contract is == 1), sending its drain loop into a blocking frame read with no frame coming — this last one lived in the hosted stdlib too. End to end: the swarm-mcp unikernel appliance now forms its cluster, serves MCP over HTTPS, and completes a submitted compute task with the correct result.
spawn can no longer hand back a fiber that does not exist
(PR #795). When a fiber stack cannot be allocated, both runtimes abort with `cannot create a fiber — out of memory for its stack (N live)` instead of returning a phantom handle that made a 200-fiber program quietly compute with 11. A fiber costs 68 KiB (64 KiB stack + 4 KiB guard); the figure is now in docs/ASYNC.md.
pass — every item is a bug the previous gates could not see). No IDT meant every CPU exception was indistinguishable from a clean shutdown: 256 stubs now report vector/err/rip/rsp/rflags/cr2 and exit 126, with #DF and #PF on their own interrupt stack because the fault worth surviving is a stack that hit its guard page. The boot stack had no guard page and page 0 was mapped, so a null store quietly succeeded — both unmapped. munmap was a no-op over a bump pointer, so alloc/free cycles ran a 256 MiB guest out of memory at ~250 iterations — replaced with a real page allocator, fuzzed on the host against a model. virtio-net leaked a 2 KiB buffer per transmitted frame (5.2 MB per 200 requests) — the soak gate now proves the heap flat. And DHCP took yiaddr/mask off the wire unchecked, so a hostile or broken server could configure the guest off the network silently.
it** (PR #797 — five bugs found by running the real swarm-mcp package as an image, three of them in the hosted runtime's own contract). sleep_ms in the main context was a nanosleep that froze every coroutine on the cooperative runtime — it now runs whatever is runnable while the clock catches up (nurl_runtime_is_cooperative is the twin seam). The stack's transmit path had no source-address parameter, so a guest with a NIC checksummed loopback replies against the wrong pseudo-header and dropped them without moving a counter. A guest with a device was declared deadlocked while every coroutine legitimately waited on the network — a device gets a poller coroutine, and loopback-only programs keep the deadlock proof. access() refused everything, so file_exists could not see the baked-in certificate. And the guest console was fully buffered, so a server that never exits never printed a byte.
another** (in PR #794's run-up). Four argument-clash families existed; the hole was exactly two different named types with a pointer on at least one side — ( takes_alpha b ) with b : *Beta compiled clean and reinterpreted memory at run time. Now a compile-time mismatch, with zero false positives over all 1202 .nu files in the tree.
nurl_fast_atof
— the parser the formatter's round-trip guarantee is stated against — stopped at digits and answered 0.0 for inf, -inf and nan, so a CSV float column holding an infinity came back as zero silently. The three conversions now round-trip, and the stdlib's own paths go through the one parser instead of through libc.
(PR #775): a definition supersedes the declare instead of colliding with it — the rule that lets unikernel/net/sockets.nu BE the socket ABI without std/net.nu changing a line.
(PR #793): compile_nu.sh ran nurlc from the repository root, so the first real package handed to the unikernel build stopped at "cannot open deps/…" — true statement, wrong directory.
unikernel/nolibc — NURL programs that link no libc at all
(unikernel plan A3). The freestanding libc subset the runtime calls: mem*/str*, an allocator, buffered stdio over six syscalls, exact %f/%e/%g, setjmp/longjmp, and the thread-pointer setup __thread needs. **394 corpus tests build and run with -nostdlib and glibc nowhere in the link line**, matching their ordinary goldens (394 once the libc surface NURL programs call — atoll, strstr, strchr, the mechanical syscall wrappers, real readdir and stat — was added on top of the 49 the runtime itself needs); 85 more still call into runtime_ffi (sockets, threads, entropy) and unikernel/run_nolibc_tests.sh prints the missing symbols for each, so the remaining work is measured rather than guessed. A hello-world built this way is a 75 KB static binary that makes four syscalls in its whole life.
Gated by three differentials, not by "it didn't crash": mem*/str* against glibc swept over every alignment and length (645 440 checks), %f/%e/%g against glibc at every precision 0–20 over random and adversarial doubles (1.2 M conversions), and a 400 000-op allocator fuzzer that keeps a pattern in every live block — ASan cannot supervise an allocator it has replaced, so the data is the oracle. Everything except syscall_linux.c and the two .S files is portable C the guest will run unchanged.
tcp_listen accepts port 0, and tcp_local_addr reads back what
the kernel picked** (stdlib/std/net.nu, stdlib/runtime_ffi.c). Binding an ephemeral port is the POSIX contract and the only way to take a listening port without racing whoever else wants it. nurl_udp_bind had always accepted it and nurl_udp_local_addr had always existed to read the result back; the TCP half of the same runtime section had neither — nurl_tcp_listen rejected port == 0 as invalid and reported NetBind, a bind failure that never happened. New nurl_tcp_local_addr / tcp_local_addr return an owned "ip:port" from getsockname, mirroring the UDP pair including the ownership rule (caller frees with string_free).
times** (stdlib/runtime_core.c §2b, stdlib/nurl_pow5_table.h, tools/gen_pow5_table.py). nurl_str_float used to find the shortest round-tripping text by printing with snprintf("%.*g") and parsing it back with strtod, binary-searching the precision — five of those pairs per number. It now computes the shortest digits directly (Ryū, over generated 125-bit power-of-five tables): 28–55× faster (4398 → 80 ns per value on full-precision doubles, 1853 → 67 ns on short "data" floats, measured on an idle box), and snprintf, strtod and floor leave runtime_core's libc surface entirely (52 → 49 undefined symbols), which is what the freestanding/unikernel target needs from this file.
The text is unchanged for every double except **46 of the 2098 powers of two**, where the old search could not find the shortest form and printed one digit too many (e.g. 7.1202363472230444e-307, now 7.120236347223045e-307 — same double, one digit shorter). A power of two's rounding interval is asymmetric, so the shortest decimal inside it is not always the one a correctly-rounding printf produces at that precision, and a search that only ever sees printf output cannot reach it. Verified byte-for-byte against the old implementation over systematic (every power of two, both neighbours of each, denormal and overflow edges) and random doubles, with every output independently re-parsed to prove the round trip; float_shortest pins it.
read Built by and hung a role line under each name — editorial text that had to be written by hand in nurlweb/contributors.json for everyone who shipped something, and that fell back to a raw commit count for everyone who was not listed yet. The strip is now headed Contributors and carries nothing but the avatar and the name, so a new contributor appears complete rather than tagged with a number.
order used to be the order logins were written in contributors.json, with everyone else appended by commit count — an editorial ranking that had to be maintained by hand and that said nothing a reader could check. It is now most commits first, full stop, and new min_commits (default 2) keeps a single merged typo fix off the page; limit (6) still caps it. Both cuts are reported by name-count on stdout rather than applied silently, and GitHub's own contributor page remains the exhaustive list. contributors.json shrinks to a login → display-name map — listing someone there no longer places or moves them, it only spells their name for when they qualify. limit and exclude are unchanged.
nurl_fast_atof is correctly rounded — NURL could not read back the
floats it wrote** (stdlib/runtime_core.c §2c). The parser behind stdlib/ext/csv.nu's typed float columns accumulated digits in a double (r = r*10 + d, then r += d * scale with scale *= 0.1) and applied the exponent by binary powering of 10.0. Measured against strtod: **33.5 % of ordinary six-to-nine-significant-digit values came back at least one ulp off**, every value below ~1e-308 parsed as 0 (the multiplier overflows to inf and r / inf is 0), and the largest finite double parsed as inf. A CSV of doubles written by NURL's own formatter did not survive its own round trip.
Replaced with a correctly-rounded parser — same double strtod returns, ties to even — that is also 4.5–8.4× faster than strtod and faster than the inexact loop it replaces (12 ns vs 26 ns per value on plain decimals, strtod 101 ns). Three paths ordered by the work the answer needs: exact double arithmetic where one IEEE operation settles it; one 128-bit product against the same power-of-five table the formatter uses, taken only when the rounding is provably identical for every value the product could stand for; and an exact big-integer comparison for the rest, which needs no division and is reached by 0 of 900 000 values on realistic data. Correctness rests on the fast path declining what it cannot prove — including every exact tie — not on it usually being right. Verified against strtod over 16 M formatter round trips, 8 M random digit strings, long (> 19-digit) inputs and **exact midpoints between adjacent doubles**, with and without __int128, under ASan/UBSan; float_parse pins it.
select. gen_select builds the
construct's real body as source and compiles it through a sub-lexer; gen_stmt returns that statement's IR value, which for a block is an owned string, and the call was left unbound — 6 bytes per select compiled. The self-compile leak gate could not see it: nurlc.nu contains no select, so gen_select never runs on its own input. select_basic — the only test in the corpus that compiles one — is now pinned in the CI leak gate, which leak-checks nurlc's compile as well as the program's run. (Its own producer closure, escaped through thread_spawn, is freed the way §7.4 says a consumer must.)
The listener-shutdown bridge woke the accepting thread by calling shutdown(2) on the listening socket — a Linux extension. Linux wakes a blocked accept/poll; BSD returns ENOTCONN and leaves the thread parked, so the signal arrived, the flag was set, and the server slept on. The listener already carries the self-pipe that cross-thread nurl_tcp_shutdown uses, and both a plain store and write(2) are on SUSv4 §2.4.3's async-signal-safe list, so the signal path now uses the same mechanism (with the shutdown(2) call kept as the Linux fast path and as the fallback when pipe() failed at listen time).
std/unixsock.nu built struct sockaddr_un with the Linux layout — a 2-byte sun_family at offset 0. BSD and macOS put a 1-byte sun_len there and sun_family at offset 1, so the Linux encoding landed AF_UNIX in sun_len and left sun_family as AF_UNSPEC: every bind and connect failed. The layout now comes from posix_const (SOCKADDR_UN_FAMILY_OFF / _FAMILY_SIZE / _PATH_OFF / _PATH_MAX), which the C side computes with offsetof and sizeof against the platform's own header rather than enumerating OS macros — a platform this list has never heard of gets the right answer without a change.
__dret_skip_add built its new skip list with ( nurl_sym_set … ? c w ( nurl_str_cat3 … ) ), a value-level ternary joining an untracked ident (a parameter) with an owning call result. The join publishes "not owned" — s also spells an opaque handle, so the ident arm is deliberately never copied — and the cat3 buffer leaked. Two statement arms instead. Invisible until the sanitized runner stopped sending nurlc's own stderr to /dev/null.
Both runners consulted hand-kept lists of test names while the facts lived in the tests, and the lists had drifted: net_basic binds 127.0.0.1 and ran by default while net_loopback binds 127.0.0.1 and was gated; http_date/http_jwt open no sockets yet were held back for having an http_ prefix; and exactly one test in the corpus contacts a third party (http_basic → httpbin.org) while the env var named for it held back everything else. The cost was the whole live surface of the runtime — threads, channels, semaphores, select, signals, unix sockets, async TCP, the HTTP server, its TLS path, websockets, the package-install e2e — never running in CI. Tests now declare what they need on a // requires: line (live, internet, or the name of a tool that must be on $PATH); pure is the default, so a test that forgets to declare fails loudly instead of silently never running. Sixteen tests gained goldens they never had. The per-test second gate (env_get NURL_NET_TESTS inside each live section) is gone with it — per-test goldens are byte-exact, so a half-run test could never have matched one, making those "skipped" branches unreachable dead code.
run_tests.ps1 reads the same declarations, so Windows and POSIX now share one contract instead of three copies of a name list. One token differs there by capability, not policy: fibers is off, because NURL has no Win64 context-switch backend, so a test pairing a fiber-side server with a blocking client would wait forever for a peer that cannot exist (async_tcp, async_http_server). **The Windows goldens for the tests whose live sections now run need one regeneration pass** — windows-tests.yml's update_goldens workflow_dispatch, the canonical way those goldens are produced. compiler/tests/run_tests.bat was deleted rather than carried: it compared against a monolithic correct.txt that is not in the repo, so its first run wrote its own baseline and declared success — a runner that could not fail — and nothing invoked it.
net_basic asserted the port-0 bug it should have caught
(listen_port_0=NetBind, with a comment explaining the rejection as intended) — a test written from the implementation, with a golden pinning the defect. It now asserts the bind succeeds and that tcp_local_addr reports a real, non-zero port.
timeout -k 5s. Plain timeout sendsSIGTERM only, and a test spinning in a tight loop with no handler ignores it indefinitely — four such processes from an earlier mutation-testing run were found burning a core each 12.5 hours after the run that started them. Declaring a hang is not the same as ending it.
signal_basic's live shutdown assertion is no longer racy.
NetAccept and NetClosed both mean "the listener went away cleanly" and which one surfaces depends on thread timing; the test collapses the pair instead of printing a racy name into a byte-exact golden. Its listener also binds port 0 now, so parallel runs cannot collide on a fixed port.
The MCP release. The Model Context Protocol shipped revision 2026-07-28, which removes the initialize handshake and protocol-level sessions in favor of a stateless core — a backwards-incompatible change to the protocol every NURL MCP server and client speaks. NURL now implements it dual-era: one endpoint serves both the new stateless clients and every existing handshake-based one, so nothing that works today stops working.
now dual-era.** The new spec revision removes the initialize/notifications/initialized handshake and protocol-level sessions: a modern request carries its protocol version, client identity and capabilities in params._meta (io.modelcontextprotocol/protocolVersion & co), and servers MUST implement server/discover. NURL's stack now serves **both eras on the same endpoint**, per the spec's own dual-era compatibility matrix — nothing was removed, so every existing legacy client keeps working:
* mcp_registry.nu — server/discover dispatch; requests declaring an unsupported _meta version get the spec-shaped UnsupportedProtocolVersionError (−32022 with data.supported); results for modern requests carry _meta serverInfo; initialize now echoes the client's requested handshake-era revision when supported (previously it always pinned the latest). The stdio serve loop was unified onto mcp_registry_envelope — which also fixed a latent double-free (json_free after the consuming mcp_send_message). * mcp.nu — mcp_protocol_version is now 2026-07-28; mcp_protocol_version_initialize (2025-11-25) is what handshake responses advertise. New: mcp_version_supported, mcp_supported_versions_json, mcp_request_protocol_version / mcp_request_is_modern, mcp_client_meta, mcp_discover_result, mcp_result_set_cacheable / mcp_result_set_server_info, mcp_response_error_data / mcp_unsupported_version_response, and error-code constants −32020…−32022. Every result built via mcp_response_result now carries resultType: "complete" (required in 2026-07-28, additive for older clients), and mcp_tools_list_result fills the now-required CacheableResult fields (ttlMs, cacheScope) — as do the registry's list/read dispatchers. * mcp_http.nu — validates the 2026-07-28 header-routing headers (Mcp-Method, Mcp-Name) against the body when present (mismatch → HeaderMismatchError −32020; absent headers stay legal for legacy clients) and allows them through CORS. * mcp_client.nu — modern era client calls: mcp_call_modern / mcp_tools_call_modern (per-request _meta + routing headers), mcp_discover, and the dual-era probe mcp_server_is_modern. mcp_stdio.nu grew mcp_stdio_discover (the spec's stdio backward-compat probe). Both initialize wrappers now send the handshake-era revision instead of the latest one. * examples/mcp_echo_server.nu demonstrates the server/discover handler; new offline regression compiler/tests/mcp_dual_era.nu locks the dual-era wire shapes byte-for-byte.
nurlapi's /mcp hard-coded protocolVersion: "2025-03-26" (chosen for FastMCP parity). It now negotiates: a client asking for 2025-03-26 still gets exactly that, newer handshake clients get theirs, and modern clients get server/discover plus per-request _meta. Its static tools/list, resources/list, prompts/list and resources/read results carry cacheScope: "public" with a 1-hour ttlMs, so shared intermediaries can cache them.
packages/nurl-mcp 0.8.0 and packages/swarm-mcp 0.22.0
adopt the dual-era layer in their hand-rolled dispatch loops (server/discover, the −32022 version gate, _meta serverInfo, handshake-revision echo).
serverInfo.version was a hand-written 0.20.0 while the package was at 0.21.1. The handshake, server/discover, and the --version banner now all read one sm_version source.
called json_free on the response after mcp_send_message, which already consumes its argument. Unifying the loop onto mcp_registry_envelope removed the second free.
A one-fix patch release: TLS servers did not work on FreeBSD at all, and the reason was a socket flag, not the cryptography.
accept()
inherits O_NONBLOCK there and does not on Linux.** nurl_tcp_listen puts the listening socket in non-blocking mode — the accept-wakeup pipe needs it — and POSIX leaves it unspecified whether accept() hands that flag to the new socket. Linux does not; the BSDs do. So on FreeBSD every accepted connection came back non-blocking.
net.nu's tcp_read_chunk survived that (it loops on EAGAIN), which is why plain TCP servers worked and the bug stayed hidden. But tls.nu's __fill deliberately issues **one raw blocking nurl_tcp_read** — its comment explains why — so it got EAGAIN instead of the ClientHello, and every pure-NURL TLS handshake died with TlsRead before a single byte was parsed. Nothing to do with the crypto: the same box passes every RFC and FIPS vector.
nurl_tcp_accept now clears O_NONBLOCK on the accepted socket, so every platform behaves the way the net stack was written against. A caller that wants non-blocking still asks for it through nurl_tcp_set_nonblock.
Found by installing the toolchain on an OPNsense box and watching swarm-mcp's HTTPS control surface refuse every connection; reduced to a 30-line reproducer (x509_selfsigned_p256 → tcp_listen_tls → tcp_accept). Not a regression — v0.30.0 fails identically.
This had no test. compiler/tests/http_server_tls*.nu are gated behind NURL_NET_TESTS=1 and use an openssl-generated RSA certificate, so the pure server's EC P-256 path — the one x509_gen produces and every NURL-native TLS server uses — was never exercised in CI on any platform.
A cryptography release, plus the compiler change that made the rest of the cycle bearable to iterate on. Every asymmetric primitive in the stack was rewritten around the same three findings, in this order: the representation was chosen for a machine that no longer exists, the hot loop allocated its own temporaries, and the ladder paid for bits it could have consumed in nibbles. A P-256 key generation went from 4.77 ms to 0.62 ms and from 44124 allocations to 100; X25519 from 0.58 ms to 0.135 and from 6146 allocations to 26; Ed25519 signing from 735 µs to 360; AES-GCM from 0.3 MB/s to 113; SHA-256 from 112 MB/s to 222. A TLS 1.3 client handshake — which pays for a key share in both offered groups before one application byte moves — spent 57.4 ms in pure arithmetic when the cycle opened; the same client now completes a whole handshake against a stock TLS 1.3 server, network round trips and certificate verification included, in about 2 ms.
None of it changes what any of these primitives computes — every step is pinned to RFC or NIST vectors — and none of it weakens the constant-time properties: the ladders keep fixed step counts, the window tables are read in full and merged under arithmetic masks rather than indexed by a secret, and docs/CRYPTO.md records the argument primitive by primitive.
On the compiler side, nurlc --split=N emits the module as N independent ones so clang can lower them at once, taking the compiler's own build from 12.0 s to 2.8 s, and the runtime now recycles small string allocations.
nurlc --split=N — the module, emitted as N independent modules, so
N clang processes lower it at once. 5.6× off the clang step.** After 0.30.0's dead-function elimination, what a NURL build waits on is still clang, and it is one single-threaded LLVM -O2 pipeline over one module: emitting the compiler's own 3.2 MB of IR takes 0.46 s, lowering it takes 11.3 s, and a machine with twelve idle cores finishes no sooner than one with two. No driver flag fixes that — -flto with -plugin-opt=jobs=12 parallelises only the codegen tail and still measures 11.1 s; ThinLTO over a single module measures 7.9 s. The parallelism has to come from the emitter.
--split=N writes the finished module as up to N of them. --split-out=PREFIX says where; --split-min=BYTES (default 131072) is the floor on a part's size. stdout still carries the whole module, so the .ll artifact and everything that reads it are unchanged and one nurlc run produces both. nurl.sh and build.sh lower the parts concurrently and link them with -flto=thin.
| on twelve cores | one module, -flto | split, -flto=thin |
|---|---|---|
compiler/nurlc.nu clang step | 11.3 s | 2.0 s |
compiler/nurlc.nu end to end | 12.0 s | 2.8 s |
examples/claude_agent.nu end to end | 5.7 s | 2.6 s |
examples/static_server.nu end to end | 4.6 s | 2.7 s |
| bootstrap stage-1 link | 11.3 s | 2.0 s |
It is not free, and the drivers are configured around that. ThinLTO imports across a module boundary but not unconditionally, so a caller separated from a callee it wanted inlined stays separated: the split-built compiler retires 3.4% more instructions than the single-module one (3.242e9 vs 3.135e9, perf stat, self-compile). A 5.6× cheaper build for 3.4% of the program's speed is worth it while you iterate and not worth it for what you ship, so nurl.sh splits your program — which you rebuild constantly — while build.sh splits only the throwaway stage-1 compiler and links the stage-2 one it installs as build/nurlc as a single module. NURL_SPLIT=0 restores the old behaviour exactly; use it for a release build. NURL_SPLIT=N sets the ceiling, NURL_SPLIT_MIN moves the floor.
N is a ceiling, not an instruction: nurlc splits only as far as it can keep every part above the floor, so a program under ~256 KB of IR is not split at all. That floor is not a build-time heuristic — cutting a small module up makes the program slower. bench/sort_window.nu (10 KB of IR) runs 20–42% slower split at any part count and bench/hash_join.nu (27 KB) 23% slower at twelve, while the compiler's 3.2 MB is unaffected at every count tried. For the same reason the parts are contiguous runs of the emission order rather than a balanced interleaving: emission order is call-graph order, and the better-balanced partition scatters every caller away from its callee.
The partition is a text-level pass over the finished IR, on the same footing as the dead-function pass it runs on top of — it knows nothing about what emitted any given function, so monomorphs, closures, drop glue and dyn thunks need no special casing. A function body goes to one part and every part declares what it does not define; private globals follow the functions that name them (duplicating one is always sound — it is module-local by definition); module-level globals are defined in part 0 and declared external in the rest; linkonce_odr bodies are replicated rather than declared, since `declare linkonce_odr` is not legal IR. A reference that lands in the wrong part is an unparseable module or an undefined symbol at link time, never a silently wrong binary.
Off for --emit-ir, --emit-asm, -O0, NURL_SAN=1 and --debug/--coverage; nurlc refuses --split together with --g, because DWARF is a per-module metadata graph that a function's !dbg attachments point into. Windows (nurl.bat) links a single module and is unaffected. Documented in docs/BUILDING.md → Parallel lowering.
compiler/tests/split_equivalence.sh — a hard gate that a program
built from N parts is the same program.** Rebuilds a structurally varied corpus (dyn trait objects and vtables, generic monomorphs, closures, % Drop glue, struct and closure return types) both ways and compares stdout, exit status, and the module written to stdout; then builds nurlc itself from four parts and requires the IR it emits to be byte-identical to what the single-module compiler emits. Runs on every ./build.sh. The 598-program compiler corpus and all 64 examples were swept through the partition once by hand as well — every failure was a pre-existing one (-lsqlite3, canvas.o, a module with no main) reproduced identically by the unsplit build.
bench/crypto_hotpath.nu — AEAD record throughput, without a socketin the way.** One "op" is one 16384-byte record, the largest TLS 1.3 allows and so the size the record layer actually works in; it reports ns/op, allocations/op and the MB/s those imply for both suites TLS 1.3 defines. It is what found the cipher-preference bug below: the two suites are three orders of magnitude apart on a CPU NURL cannot reach the AES instructions of, and nothing was measuring that.
allocations instead of 18568.** Two changes, both of them ones std/p256_field already had.
The addition stopped allocating. One twisted-Edwards addition needs nine field temporaries, and __ed_add allocated and freed all nine on every call — 512 calls per scalar multiply, so about nine thousand allocations per multiply, a quarter of the run time spent in the allocator for a routine whose work is nine field multiplies. They come from an EdScratch built once per scalar multiply now. 735 → 501 µs.
The ladder consumes the scalar a nibble at a time. The bit-at-a-time ladder paid two complete additions per scalar bit. A fixed 4-bit window pays four doublings and one addition per nibble, reading digit·Q from a sixteen-entry table that costs fourteen additions to build: **334 point additions instead of 512, and 501 → 360 µs**.
The digit is secret, so the table is never indexed by it — __ed_tbl_get_d reads all sixteen entries every window and merges each under an arithmetic equality mask. One difference from the P-256 version worth noting: gf limbs here are signed, so the mask is a full 64-bit −1 rather than 0xffffffff, which would clip a negative limb's sign extension. Digit 0 reads the identity, which the complete addition formula absorbs.
| before | after | |
|---|---|---|
ed25519_sign_pure | 735 µs | 360 µs |
| point additions per scalar multiply | 512 | 334 |
| allocations per signature | 18568 | 208 |
Verification benefits the same way — it runs two scalar multiplies. This is the signature std/minisign verifies packages with, and the one behind Ed25519 certificates in std/x509 and EdDSA in ext/jwt.
Constant time is unchanged: a fixed 2·len(scalar bytes) windows regardless of the scalar, no branch on secret data, no secret-dependent address. RFC 8032 vectors pass.
(170 → 349 MB/s), because the hash cores stopped going through bounds-checked Vec accessors.** One SHA-256 block made about 380 of them — 64 pushes to build the message schedule, 192 gets to expand it, 128 more to read the round constants and the schedule back in the round loop — plus one allocation for the schedule itself, all for a compression function whose real work is 64 rounds of ALU. Every index is fixed-count and provably in range, so every check was overhead. It showed up as 12% of a TLS handshake in vec_push/vec_get alone.
The schedule now lives in a scratch owned by the hash (allocated once instead of once per block) and every limb — state, schedule, round constants, and the block's own bytes — is reached through a raw *u32 / *u64. The round-constant tables are filled by index too: they are rebuilt on every sha256_init, and TLS's key schedule runs one of those per HMAC leg.
| before | after | |
|---|---|---|
| SHA-256 | 112 MB/s | 222 MB/s |
| SHA-512 | 170 MB/s | 349 MB/s |
| HMAC-SHA-256, short input | 3.47 µs | 2.30 µs |
This is the same change std/p256_field and std/x25519 got, applied to the hash every part of the stack leans on: the TLS 1.3 key schedule and transcript, HMAC and HKDF, package hashing, the CAS Merkle tree, JWT. Ed25519 signing is unchanged — SHA-512 is not where its time goes (18568 allocations per signature are).
of 6146 — because the 25519 field stopped being sixteen 16-bit limbs.** std/x25519 carried TweetNaCl's gf: sixteen signed limbs of about 16 bits each. That shape needs 16×16 = 256 products per field multiply, and _M allocated a fresh 31-limb accumulator for every one of the 1280 multiplies a scalar multiply performs.
A field element is now ten signed limbs at radix 2^25.5 — limb i weighing 2^ceil(25.5·i), alternately 26 and 25 bits — which is the classic 32-bit curve25519 layout. Ten limbs need 100 products, and a 26×26 → 52-bit product still leaves room to accumulate ten of them times the folding factors inside a signed 64-bit limb. The multiply is register-resident: it reads its operands into locals, computes the ten output limbs and carries, allocating nothing.
The two coefficient shapes are folded into the operands rather than the terms, so each of the hundred products is a single multiply: a product of two odd-indexed limbs lands one bit low (because ceil(25.5j) + ceil(25.5k) = ceil(25.5(j+k)) + 1 when both are odd) and is taken against a pre-doubled operand; a product that runs past limb 9 wraps by 2^255 ≡ 19 and is taken against a pre-nineteened one.
| before | after | |
|---|---|---|
| products per field multiply | 256 | 100 |
x25519_base | 0.58 ms | 0.135 ms |
| allocations per scalar multiply | 6146 | 26 |
| TLS 1.3 client handshake | ~4.2 ms | ~3.0 ms |
Ed25519 rides along — std/ed25519 builds on the same field, and its constants are already decoded through _unpack25519, so they re-encode themselves.
Constant time is unchanged: the ladder keeps its fixed iteration count and its branchless conditional swap, and the new field has no branch on data anywhere. Verified against RFC 7748 §5.2 and §6.1 and RFC 8032, and the whole representation — multiply, carry chain, byte decode and byte encode — was checked as a model first, including a full ladder simulation that reproduces the RFC vectors and pins the worst-case intermediate at 2^57 against a 2^63 ceiling.
additions instead of 512, a keygen 0.93 → 0.62 ms.** The bit-at-a-time always-add ladder paid two complete additions per scalar bit — one to double, one to add the base whether or not the bit was set. A fixed 4-bit window pays four doublings and one addition per nibble, reading digit·B out of a sixteen-entry table that costs fourteen additions to build.
The digit is four bits of the secret scalar, so the table is never indexed by it: __p256_tbl_get_d reads **all sixteen entries every time** and merges each under a mask that is all-ones exactly when d == digit. The mask is arithmetic rather than a comparison — d ^ digit is zero only on a match, and subtracting one from zero borrows into the top bit, which nothing else can set for a 4-bit value. The table walk costs about 4% of one point addition per window.
Digit 0 reads the identity, which the RCB complete addition formula absorbs correctly — that is what lets the zero digit stay on the same path as every other one.
Constant time is unchanged, on the same terms as before: a fixed 2·len(scalar bytes) windows regardless of the scalar's value, no branch on secret data, and now no secret-dependent address either.
compiler/tests/p256_ct_field.nu cross-checked its 60 random scalar multiplies against p256_ecdh_keygen — which calls the very ladder under test, so the check was vacuous. It now compares against std/ecdsa_p256's BigInt Jacobian ladder, which shares no code with it (different coordinates, different field, different addition formula); a mutation of the base point makes it fail, as it should. The four reference functions it needs are renamed from __ to a single underscore, which is what the compiler's own deprecation says to do.
keygen is 1.07 → 0.93 ms.** The reduction half of CIOS multiplies m by the modulus limb by limb, and P-256's modulus is [2^32−1, 2^32−1, 2^32−1, 0, 0, 0, 1, 2^32−1] in the radix the field now uses. Every one of those partial products is a shift, a zero, or m itself. -p^-1 mod 2^32 is 1 as well, so m is simply t[0], which makes the first step's low word cancel by construction and the next two limbs fall through unchanged.
A Montgomery multiply is 64 machine multiplies instead of 128, and __p256_mul_d compiles to 8 imul per outer iteration instead of 16. This is what "Montgomery-friendly prime" buys and it was being paid for without being spent.
allocations instead of 44 124 — a TLS 1.3 handshake is 8.5 → 4.9 ms.** Two changes to std/p256_field, the constant-time field behind every ECDHE key share and every ECDSA signing nonce.
The limb radix went from 2^16 to 2^32. The field was transcribed with 16-bit limbs so that a product would fit an i64, which meant a Montgomery multiply ran 16×16 = 256 schoolbook steps and another 256 reduction steps. NURL has a real u64, and a 32×32 → 64 product is one machine multiply, so the same operation is now 8×8 = 64 and 64. The CIOS bound is exactly why 2^32 is the largest radix that works without a double-word carry: `t[j] + a[j]·b[i] + C ≤ (2^32−1) + (2^32−1)² + (2^32−1) = 2^64 − 1, which a u64` holds precisely.
The ladder stopped allocating. Every field operation used to allocate its result, and vec_with_cap is two allocations (control block + buffer), so ~3000 field operations per keygen cost ~44 000 of them — 25% of the run time once the multiply got cheap. Each operation now has a _d form that writes into a destination the caller owns, the six registers RCB point addition works in live in the per-scalar-multiply scratch, and the always-add ladder runs its 512 point additions in registers allocated before it starts. The allocating p256ct_* API is unchanged — it is those workers plus one __mag8.
| before | after | |
|---|---|---|
p256_ecdh_keygen | 4.77 ms | 1.07 ms |
| allocations / keygen | 44 124 | 98 |
| TLS 1.3 client handshake | 8.47 ms | 4.87 ms |
Constant time is untouched: the loops keep their fixed trip counts, stay branch-free in the data, and the field is still never normalized. The _d workers may be called with dst aliasing a source — safe by construction, because each reads all of its operands into the scratch accumulator before writing dst (__p256_inv_d, the one exception, documents it). Verified by compiler/tests/p256_ct_field.nu (400 random field trials against the bigint reference plus 60 scalar multiplies), the ECDH known-answer vectors, the RFC 6979 signing vectors, and the full suite under ASan + UBSan.
./build.sh takes 23.6 s instead of 36.3 s — 35% off every build —
because the bootstrap no longer optimises the two compilers it throws away.** Stages 0 and 1 are scaffolding: neither binary is shipped, neither is benchmarked, and each one does exactly one job — compile nurlc.nu once — before the build deletes it. Both were nonetheless linked at -O2 -flto, and stage 0 is the one link --split cannot help (it links the committed nurlc_lastgood.ll snapshot, which is a single file by definition), so it was ~13 s of single-threaded ThinLTO spent to make a 0.8 s job take 0.35 s.
They are built -O0 now. Stage 2 — the one copied to build/nurlc — keeps -O2, and its link flags are untouched.
| step | before | after |
|---|---|---|
| stage 0 link | 12.89 s | 0.75 s |
| stage 1 link | 1.93 s | 0.30 s |
| stage 1 ir | 0.38 s | 0.88 s |
| stage 2 ir | 0.35 s | 0.78 s |
| stage 2 link | 12.54 s | 12.49 s |
--no-tests, end to end | 36.3 s | 23.6 s |
Twelve cores, clang 18. The scaffolding gets ~2× slower at the one thing it does, and the build still comes out a third shorter.
Nothing about the shipped compiler changes. The IR a stage emits is byte-identical at either optimisation level, which is the only property the bootstrap depends on — and the fixed-point check (stage 1 ≡ stage 2, byte for byte) proves it on every single build rather than taking it on trust. build/nurlc verified to emit the same 3,330,125 bytes of IR, hash for hash, at the same 0.34 s self-compile as before.
--san builds stay -O2 throughout: LTO is already off there, so the link is not the expensive part, and the instrumented self-compile is the stage that runs closest to CI's memory and time ceilings. build.bat gets the same treatment for the two throwaway stages (untested on Windows beyond the flag swap — the structure is identical).
std/aes_gcm.nu computed the AES S-box algebraically, per byte, as Affine(x²⁵⁴) — constant-time, which is the whole point (a table indexed by key bytes leaks the key through the cache), but about a thousand operations for one substitution. Ten rounds of sixteen of those put AES-128-GCM at 0.3 MB/s against ChaCha20-Poly1305's 370, and AES-GCM is the one cipher suite RFC 8446 requires every TLS 1.3 implementation to have. A peer that insisted on it made an 8 MB download take half a minute.
Constant time was never the thing to give up, so the block cipher is bitsliced instead (a port of BearSSL's aes_ct64, Thomas Pornin, MIT). The state is transposed so each of eight 64-bit words holds one bit position of 64 bytes, and SubBytes becomes Boyar and Peralta's 113-gate boolean circuit evaluated on those words: one pass substitutes 64 bytes, so the S-box costs under two operations per byte instead of a thousand — with no table and no data-dependent branch, the same guarantee as before. Four blocks travel together, which is what CTR mode wants anyway.
GHASH was the other half: multiplying in GF(2¹²⁸) bit by bit is 128 iterations of a 16-byte shift-and-mask per block. It is now carry-less multiplication built out of ordinary 64-bit integer multiplies (ghash_ctmul64) — each operand split into four interleaved bit groups so no carry crosses a kept bit, recombined by Karatsuba. After the rewrite the two halves cost about the same (perf: 45% cipher, 47% GHASH), which is where a balanced implementation should land.
| 16 KB record | before | after |
|---|---|---|
aes128_gcm_seal | 48 312 827 ns | 144 591 ns |
| 0.3 MB/s | 113.3 MB/s | |
aes128_gcm_open | 48 296 068 ns | 144 861 ns |
| 0.3 MB/s | 113.1 MB/s | |
| allocations / record | 30 835 | 15 |
The public surface is unchanged (aes128_gcm_encrypt / _decrypt, aes256_gcm_encrypt / _decrypt), and so is the ciphertext: a differential sweep over every plaintext length 0–200 × every AAD length 0–40 × both key sizes, plus a 16 KB record, matches the previous implementation byte for byte in all 33 166 cases, clean under ASan/UBSan/LSan. compiler/tests/aes_gcm_vectors.nu additionally gained the empty, single-block, AAD-only and 300-byte cases — lengths chosen to walk every path through the four-block loop — checked against OpenSSL rather than against the code they test.
Two caveats, both documented in docs/CRYPTO.md: the key schedule still uses the old per-byte S-box (it runs once per key, ~2.5% of a record, and is not worth bitslicing), and GHASH's constant-timeness now rests on the CPU's integer multiplier being data-independent — true of every mainstream 64-bit core, not true of some small in-order ARM cores. TLS keeps preferring ChaCha20-Poly1305, which is still 3× faster; the comments in tls.nu / tls_server.nu explaining that preference by a 700× gap have been corrected to the 3× one.
22% off the handshake's instruction count.** With the accessor calls gone (below), what was left in front of the arithmetic was the allocator: vec_with_cap is two allocations — a 24-byte control block and the data buffer — so a Montgomery multiply that built the modulus, the CIOS accumulator, the difference and its result made eight of them, and one complete point addition performs about thirty field operations.
The modulus and the two temporaries now live in a P256Scratch built once per scalar multiply and threaded down through the point layer, so only each operation's result is allocated. The public field API (p256ct_mul / _add / _sub / _inv / _to_mont / _from_mont) is unchanged — each is a wrapper that makes a scratch, calls the scratch-taking sibling and frees it. A scratch is never shared between scalar multiplies, so there is no state to race over, and no arithmetic changed: the same limbs are written in the same order.
p256_ecdh_keygen | before | after |
|---|---|---|
| allocations | 163 986 | 44 124 |
| time | 6.2 ms | 4.7 ms |
| handshake, instructions retired | 437.4 M | 341.0 M |
(Instructions rather than wall clock for the handshake: the loopback harness has a Python peer in the loop and cannot resolve 20% — the count is reproducible to four significant figures, the wall clock is not.)
5.5×.* The ClientHello carries a key share for both* groups, so every connection runs an X25519 and a P-256 keygen — and then a third scalar multiply for the shared secret — before a single application byte moves. Short-lived connections (a package fetch, an API call, one HTTPS request) are dominated by that. Both curves were built out of bounds-checked Vec accessors: 61% of the whole handshake was vec_get / vec_set / vec_push calls, not arithmetic.
std/p256_field.nu and std/x25519.nu now index their limbs through a raw *i in the hot routines — the Montgomery multiply, the conditional subtract, add/sub/merge/clone on the P-256 side; _M, _A, _Z, __car25519, _sel25519 and the gf copies on the X25519 side. Every one of those loops is fixed-count over a 16- or 31-limb magnitude, so the bounds check was provably redundant, and **constant time is unaffected**: the trip counts stay fixed and the data still takes no branch. Two smaller ones came with it — the modulus is passed into __p256_cond_sub_p instead of being rebuilt there (it was being reconstructed on each of the ~3000 field operations a scalar multiply performs), and the fixed-size magnitudes are filled by index rather than through a per-limb vec_push capacity check.
| before | after | |
|---|---|---|
| TLS handshake (loopback, OpenSSL peer) | 57.4 ms | 10.5 ms |
x25519_base | 3.1 ms | 0.59 ms |
p256_ecdh_keygen | 15.7 ms | 6.4 ms |
u32 instead of masked i64 limbs —
another 26% on the AEAD.** The kernel held the sixteen state words as i and put & 4294967295 after every add and every rotate, and wrote each rotate as shl | shr | and because a 64-bit value cannot say rol on 32 bits. NURL has a native 32-bit integer that wraps, so the masks are gone and all thirty-two rotations in a double-round are now single rol instructions. Only the widening at the block's edges needed adding.
| 16 KB records | before | after |
|---|---|---|
aead_encrypt | 302 MB/s | 375 MB/s |
aead_decrypt | 305 MB/s | 377 MB/s |
tls_write over loopback | 261 MB/s | 307 MB/s |
bench/crypto_hotpath.nu grew the two key-exchange rows, so the handshake side is measured too and not just the byte-throughput side.
slower at — 24.9 s → 0.15 s for an 8 MB response.** tls_server.nu walked the ClientHello and took the client's first acceptable suite. Chrome, Firefox and Go's crypto/tls all list TLS_AES_128_GCM_SHA256 ahead of ChaCha20 when the CPU has AES instructions, so those clients got AES-GCM — and NURL has no AES-NI path. std/aes_gcm.nu derives every S-box byte through a constant-time GF(2^8) inversion instead of a cache-timing-visible table, which is the right call for security and costs about four thousand cycles a byte. Measured on 16 KB records (bench/crypto_hotpath.nu): **AES-128-GCM 0.5 MB/s, ChaCha20-Poly1305 306 MB/s.** The server now prefers ChaCha20-Poly1305 whenever the client offers it at all. Downloading 8 MB from a NURL HTTPS server with `openssl s_client -ciphersuites 'TLS_AES_128_GCM_SHA256:TLS_CHACHA20_POLY1305_SHA256'` went from 24.87 s to 0.151 s. Clients that offer only AES-128-GCM still negotiate it and still work — RFC 8446 makes that suite mandatory, so it cannot simply be dropped; a software-only stack preferring ChaCha is what OpenSSL itself does on a CPU without AES instructions.
through a bounds-checked byte loop — TLS reads 150 → 214 MB/s.** aead_decrypt extracted the ciphertext from the ciphertext‖tag buffer with one vec_get and one vec_push per byte before either the MAC or the keystream would look at it: a quarter of the whole receive path, and the same shape a comment three functions up records having already fixed once. Both consumers now take a range — chacha20_xor_range is the new entry point, __mac_data takes an offset and length — so nothing is copied out of the record at all.
Two more in the same file. chacha20_block was a second, accessor-based copy of the round function, ~960 bounds-checked Vec calls a block, and the AEAD ran one per record for the Poly1305 one-time key; it is now the register-resident kernel applied to a zero plaintext, and the duplicate quarter-round helpers are gone. And the kernel XORs a whole 64-byte block through eight 8-byte loads and stores when both buffers are 8-byte aligned — a Vec's storage is malloc'd, so they are — instead of sixty-four of each with their shifts and masks. The byte path stays as the fallback for unaligned offsets and big-endian machines, which the file now probes for rather than assumes, and a new vector pins the two paths to the same output.
| 16 KB records | before | after |
|---|---|---|
aead_encrypt | 282 MB/s, 15 allocs/op | 313 MB/s, 12 allocs/op |
aead_decrypt | 198 MB/s, 17 allocs/op | 306 MB/s, 12 allocs/op |
tls_read over loopback | 150 MB/s | 214 MB/s |
tls_write over loopback | 218 MB/s | 259 MB/s |
recycling allocator — 27% off nurlc's own run time.** 0.30.0 put a per-thread size-class freelist behind nurl_alloc/nurl_free, and String and Vec buffers have used it ever since. The string primitives never did: nurl_str_cat, nurl_str_slice, nurl_str_int, and every strdup in the symbol table took their block straight from libc, while the compiler-inserted auto-drop handed that same block back through nurl_free. The loop was open at one end. Nothing was ever taken out of the cache, so the classes filled to their 1 MB budget once and from then on every free paid a usable-size query only to hand the block to libc anyway — the cache was pure overhead on the program that allocates hardest, and turning it off with NURL_ALLOC_CACHE=0 made a self-compile faster.
Closing the loop is one rule applied everywhere: allocate what nurl_free will free with nurl_alloc. The primitives in compiler/nurlc.nu and stdlib/core/{string,symtab}.nu call nurl_alloc; copies go through a new nurl_strdup (same block, same lifetime, an ordinary libc chunk — so a site that frees one with plain free() still works); and the code generator emits @nurl_strdup rather than @strdup for the copies it mints — an owned string reassigned, a match arm's result, an owned struct field — so a user program's copies recycle too.
nurlc compiler/nurlc.nu | before | after |
|---|---|---|
| wall clock | 469.9 ms | 346.2 ms |
| retired instructions | 3.137e9 | 2.101e9 |
libc malloc calls | 6 006 395 | 96 199 |
| peak RSS | 25.9 MB | 18.0 MB |
98.4% of the compiler's allocations no longer reach libc at all, and the ones that do stop fragmenting an arena that was being handed six million short-lived blocks — which is where a third of the peak RSS went. NURL_ALLOC_CACHE=0 still turns the cache off, and with it the self-compile goes back to 0.43 s: the whole gap is the cache, now that something finally takes blocks out of it.
It is worth what it is worth and no more: this moves programs whose time is string-primitive churn, and not others. A loop of nurl_str_int + two concatenations + a slice, 300 k iterations, runs 2.07× faster (49.6 ms → 24.0 ms). bench/json_parse, whose Vec and String buffers were already on the cache, measures unchanged, as does nurlfmt — neither is allocator-bound. nurlc is the outlier because it is a program that does almost nothing but build small strings.
nurl_str_int formats its digits by hand. The runtime's hottest
formatter — nurlc names every SSA register through it — spent its time in snprintf("%lld"): a format-string parse, a locale check and an internal output buffer, to produce at most twenty digits. glibc's printf machinery measured 3% of a self-compile on its own. The replacement writes digits backwards into a stack buffer and takes the one allocation it needs from nurl_alloc. The magnitude is accumulated in an unsigned long long, so LLONG_MIN — whose absolute value has no signed representation — formats like any other value; the output is byte-identical to snprintf across the full i64 edge set and 400 000 consecutive values.
nurlc
emits no target triple, so clang announced that it was supplying the host one on every single build. There was never anything to act on, and under --split it was printed once per part.
A compiler-efficiency release. Everything in it is about what nurlc spends — memory, syscalls, and the work it hands to clang — and nothing in it changes what a NURL program means. The self-compile went from leaking 2.8 million allocations to leaking none and from 115.9 MB peak RSS to 18.5; the borrow checker's state representation was rewritten and took 20.7% of the compiler's instructions with it; printing stopped paying a write syscall per fragment; and nurlc now emits only the functions a program can actually reach, which is 30–40% off the clang step for anything that imports a stdlib module. Four new CI gates keep each of those from decaying back.
nurlc compiler/nurlc.nu must leak nothing,
and CI now enforces it.** The ownership campaign took the self-compile from 2,793,226 leaked allocations / 33.4 MB to zero (peak RSS 115.9 → 18.5 MB), and nothing was watching while those leaks accumulated one unowned helper at a time — they decay back the same way. tools/leakgate.sh runs the self-compile under ASan+LSan with detect_leaks=1 and fails on ANY report: no budget to raise, unlike the peak-RSS gate, because the number a leak gate accepts is zero. ASan/UBSan findings in the same run fail it too, and it refuses to run against an uninstrumented build rather than passing while checking nothing. It is a step in the existing sanitizers job (already a required check), so it reuses that job's ./build.sh --san and costs ~3 s; it runs when compiler/nurlc.nu — the source that owns the allocations in question — or the gate script itself changed. Verified in both directions: green on the current compiler, and it fails as designed on a pre-campaign build (2.8M allocations reported).
nurlc emits only the functions main can reach — 30–40% off the
clang step on any stdlib-heavy program.** An import brings in a whole module: a program that imports stdlib/ext/json.nu for json_parse also got json_clone, json_eq, the pretty-printer and the float formatter. clang then ran its entire -O2 pipeline over all of it and LTO deleted the unreachable half at link time — after the optimising was already paid for. On examples/static_server.nu that was 947 of 1,416 emitted functions, 60,000 of 94,244 IR lines, and 2.7 s of a 6.5 s clang step.
| before | after | |
|---|---|---|
examples/static_server.nu | 7.19 s | 4.32 s |
examples/ws_echo.nu | 6.70 s | 4.04 s |
examples/h2c_server.nu | 6.64 s | 4.41 s |
examples/wordcount.nu | 291 ms | 178 ms |
bench/json_parse.nu | 815 ms | 611 ms |
test corpus wall clock (./build.sh) | 1 m 11 s | 42 s |
(nurlc + clang -O2 -flto, best of three. The self-compile is unchanged at −0.6%: a compiler uses nearly all of itself.)
The pass is a linker-style collection over the finished IR text, not a source-level call graph — closures, generic monomorphisations, drop glue, % Drop impls and dyn method thunks are all just @name references by then, so it needs to know nothing about how any of them got there, and a future construct that emits a function is covered the day it is written. Roots are main plus every function named at module scope, which is where a @__vt.<Trait>.<T> vtable constant names its thunks. A file with no main is a module compiled for something else to link against and is left whole; --no-dce disables the pass outright.
Two properties make it safe on by default: the output is a sub-sequence of the unfiltered IR — nothing is rewritten, reordered or renamed, only dropped — and dropping something still referenced is an undefined symbol at link time, never a silently wrong binary. The final binaries are byte-for-byte as capable as before; LTO was already removing the same code, just later.
tools/dcegate.sh (new, wired into CI) pins the half the corpus cannot see. 567 passing tests prove nothing live is dropped — that would fail to link — but a pass that decided everything was reachable would also stay green while costing nothing but wall clock, which is exactly what an off-by-one in the block scanner did during development: a define following a closing brace with no blank line went unseen, its body counted as module scope, and static_server went from 947 droppable functions to 0 with every test still passing. The gate asserts unreachable code is gone, that the four indirect reachability routes survive, and that both builds behave identically; it is verified to fail against a compiler without the pass.
Enabling this needed the output-buffer stack in runtime_core.c to become offset-based: a frame now records where its bytes start instead of snapshotting and restoring the whole enclosing buffer, so nesting one frame per function inside a module-sized outer frame is free rather than quadratic. The buffer also grows on demand now — it was a fixed 8 MB that nurl_print silently dropped writes past, which would have truncated a large module into invalid IR with no diagnostic. Self-compile peak RSS 18 → 24 MB (budget 600 MB).
nurl_print stops paying a write(2) per print — 16.7× faster
output, 19–25% off every nurlc run.** The runtime flushed stdout after every print call. NURL code prints in small pieces (nurlc emits one IR line as ~8 separate prints), so the flush, not the formatting, was the cost: compiling examples/static_server.nu issued 188,713 write syscalls, and a program printing 300k lines spent 16× longer in the kernel than in its own loop. stdout is now block-buffered when it is a pipe or a file and still flushed per print on a terminal — the same split C and Python make, so an interactive prompt with no trailing newline still appears before its read (verified against a pty).
| before | after | |
|---|---|---|
nurlc examples/static_server.nu — write syscalls | 188,713 | 725 |
nurlc compiler/nurlc.nu (self-compile IR) | 601 ms | 449 ms |
nurlc examples/static_server.nu | 693 ms | 556 ms |
nurlc examples/ws_echo.nu | 637 ms | 517 ms |
| 300k-line print loop | 435 ms | 26 ms |
Buffered bytes are never lost or reordered: exit flushes, every abort() path (nurl__oom, nurl_panic, the wasi panic stub) flushes stdout first, stdlib/std/process.nu flushes before its two forks so the child cannot re-emit the parent's pending bytes, and nurl_eprint/nurl_eprintln drain stdout before writing — so anything merging the streams (a terminal, 2>&1, a CI log) sees them in the order the program produced them. That last rule is stricter than before: compiler/tests/outputs/mcp_hardening.txt recorded a stderr line jumping ahead of a puts printed earlier, and the golden now shows true program order. The one case the runtime cannot see — a raw write(2) on fd 1, which bypasses stdio — needs an explicit ( flush ), exactly as mixing printf and write(1, …) does in C; stdlib/core/io.nu documents both that and the follow-my-redirected-log case, and compiler/tests/stdout_flush_order.nu pins all three ordering rules.
module.** A compiled module returned as base64 in a tool result lands verbatim in the calling model's context — hundreds of KB spent on bytes the model can't read. The playground MCP tool nurl_build_wasm now returns wasm_artifact + ll_artifact download URLs (backed by a new links_only:true field on POST /build_wasm; the default REST response keeps the inline wasm_base64 the playground consumes, and now carries the artifact objects too — ll_artifact even when the link step failed, which is exactly when the IR is wanted). The local nurl-mcp server (0.7.4) does the file-system equivalent: inline-source nurl_build_wasm keeps the module and its .ll on disk and returns wasm_path / ll_path, and nurl_build_project returns binary_path / ll_path instead of binary_base64.
array — 20.7% off the whole compiler's instruction count.** The state was a space-separated name=digit token string: every lookup memmem'd the whole thing (11.2% of a self-compile sat inside libc memmem), every update rebuilt the string token by token, and a join probed each token of one side against the whole of the other — O(na·nb). Binding names are now interned once per function into dense ids at bck_explode time (generation-stamped entries on g_bck, so there is no clearing pass; rv_<id> keeps the reverse map for diagnostics), and a state is a byte string indexed by id: get is a byte load, set a memcpy plus one store, join and the loop-carry seed pointwise passes.
Self-compile, pinned, perf stat -r 5: instructions 3.318 G → 2.631 G (−20.7%), cycles −13.2%, wall 0.528 → 0.459 s, peak RSS 125 → 115 MB. The borrow checker's own overhead — the delta against --no-borrowck — drops 44%. Gated by a differential sweep over all 1,654 .nu files in the tree: IR, diagnostics and exit codes byte-identical.
self-compile → zero, peak RSS 115.9 → 18.5 MB.** A single-owner language whose own compiler leaks a third of its address space is making an argument it does not believe, and every step of this campaign found leaks that had sat unnoticed for months because nothing was looking. About thirty commits, each closing a class rather than a site — the recurring roots were worth naming:
- Ownership hidden behind a cast. The strdup-returning getters (nurl_sym_get/get2, nurl_lex_val/filename/peek_val, nurl_llty, …) returned ^ # s ( strdup … ), and the cast hid the freshness from return-site inference, so no caller ever freed the copy. nurl_sym_get alone leaked 1.77 M strdups per self-compile. - Ownership as a per-path guess. Return inference is now all-paths-or-nothing: any i8* return site that cannot prove freshness sets a poison that vetoes the marker. One owned ^ ( slice … ) beside a borrowed ^ param tail used to mark the whole function owned — and callers then freed the borrow. - The def-order gap. A helper defined below its caller had no ownership summary yet at the call site, so its result went uncollected. Fixed both by hoisting and by a forward-call ownership channel that lets a call ask a not-yet-compiled callee, at run time, whether the pointer it returned was fresh. - Copies nobody consumes. A ?/?? arm whose value merely aliases a binding was copied like any other tracked value, while the binding's own scope-exit drop already owned that buffer — so every ? c { = x y } {} minted a buffer with no consumer. The single biggest remaining class. - Mixed joins. A join with a borrowed parameter on one side and a tracked local on the other never collected the tracked arm's copy: 71 k leaks on one ws_echo compile, through subst_source alone.
Two of these were live-memory bugs, not just leaks: reassigning a tracked mutable string could free a .rodata literal or a borrow, and an over-eager owned marker made callers free a value they did not own. arm_local_trailing_drop and ret_owned_propagation pin the copy contract as regressions.
The self-compile is the workload nurlc.nu exercises, and it has no $ imports and instantiates no generics — so the whole mangling/substitution path was untested by it. Compiling an import-heavy program was taken the same way: 96 k → 7.6 k → zero leaked allocations, at -O0 as well as -O2 (an -O0 ASan build elides no allocations and was the honest measurement). tools/leakgate.sh above is what keeps all of it at zero.
A discoverability release, cut from one field report about seams a newcomer hits in the first hour: the grammar's own import examples didn't compile, the print function every language model reaches for didn't exist, and the API-search tool couldn't see the runtime surface the compiler itself provides. All four changes close the gap between what the toolchain documents and what it accepts.
stdlib/core/builtins.nu).
Field report: nurl_api query='float string convert' returned zero stdlib hits even though nurl_str_float exists — it is a C-runtime builtin the compiler pre-registers, not a stdlib declaration, so no indexed surface mentioned it. The whole pre-registered surface (114 functions: printing, stdin, conversions, byte scanning, files, process/args/env, output capture, allocators, raw HTTP/subprocess/TCP-TLS handles, panic/recover, the libc pass-throughs) now lives in a documented module that nurldoc renders, nurl_api indexes, and nurl_grep / nurl_read_stdlib see. Hand-written prose, machine-checked list: tools/check_builtins_doc.sh gates it against the compiler preamble in CI, both directions.
& c` @
nurl_str_float f x → s` used to make LLVM reject the whole module with "invalid redefinition of function", because the compiler re-emitted a declare its own preamble already carried. The preamble now marks each symbol it declares with the same dedup key user FFI declarations use, so the duplicate is skipped — which is also what lets core/builtins.nu be imported at all.
$ stdlib/core/string — extensionless — while the compiler required .nu and rejected the documented spelling with a bare nurlc: cannot open. The resolver now retries every lookup tier (importer-relative, cwd, $NURL_STDLIB) with .nu appended, but only after the path as written missed all of them, so all existing imports — including a file that really has no extension — resolve exactly as before. Both spellings dedup to the same file.
nurl_println — nurl_print plus a trailing newline, callable
everywhere without an import, next to the long-standing nurl_print / nurl_eprintln. Language models writing NURL routinely assume it exists; now it does. Routed through nurl_print so output capture (nurl_print_buf_*) sees it too.
A field-report release. Everything here was found by running the thing — a Windows user on a machine shaped unlike any CI runner, a browser pulling a 100 MB point cloud through the pure-NURL TLS stack, a port whose bool-returning wrapper compiled to IR the verifier rejected three stages later — and each fix lands with the regression that would have caught it.
ply view --host/--addr and --tls (ply 0.2.x, registry): the viewercan bind 0.0.0.0 or one adapter and serve HTTPS under a fresh self-signed P-256 certificate (std/x509_gen) — listed here because the enabling stdlib work ships with this toolchain.
nbody — the benchmark suite's first row over floating point.
Every other row is an integer or byte kernel, so nothing in the suite touched f codegen, and nothing exercised the FPU's long-latency, non-pipelined sqrt and divide units: 500k symplectic-Euler steps of the Sun and four gas giants put ten sqrt/divide pairs on the critical path per step. It is also the one row where JavaScript is not handicapped. The u64 rows force that port onto BigInt because JS has no integer type, and Node loses them by 30–50×; here JS's single numeric type is the IEEE-754 double the benchmark is written in, so Node runs the same arithmetic as the compiled backends and lands near 2× C. A table in which one language loses every row by two orders of magnitude invites the suspicion that the corpus was picked to make it lose — this is the row that answers it.
The checksum is the final energy's bit pattern, not a rounded decimal, so the gate asserts bit-exactness across all five languages rather than approximate agreement. That is achievable because +, -, *, / and sqrt are all correctly rounded by IEEE-754, and it costs three constraints, documented in bench/nbody.c: no -ffast-math, no multiply-add contraction (moot at the suite's baseline x86-64 target, which has no FMA — but a future -march= flag would need -ffp-contract=off here), and the same operation order in all five ports. The five also share one data layout, struct-of-arrays: an array-of-structs C port is ~6% faster (274.0M retired instructions against 290.1M), but mixing layouts would have had the row reporting a 6% spread that has nothing to do with floating point. Pinned to one layout the three compiled backends land within 3.5% of each other on instruction count. Native suite only for now — wasmbench.sh reads the same manifest but is not run in CI.
^ ( ffi )
in a → b function lowered ret i64 … out of an i1 LLVM function (and ^ == a b from → i, the mirror image, ret i1 out of an i64 one): invalid IR that nurlc accepted with rc 0 and only the LLVM verifier rejected, three build stages later, with a line number into the .ll. The return-type agreement check now dies on integer width like it already did on float↔int and pointer↔scalar, with the cure per direction (^ != 0 ( … ) to get a b, ^ ? cond 1 0 to widen one). No codegen change: accepted programs lower identically, and a program hitting the new diagnostic could never have linked.
download goes 27 → ~170 MB/s.** The quarter-round made twelve bounds-checked Vec accessor calls per invocation (~960 per 64-byte block) with three Vec allocations per block and a per-byte vec_push output loop. The sixteen state words are now locals, key/nonce words are hoisted out of the block loop, full blocks XOR through raw pointers, and a block allocates nothing; Poly1305 reads full 16-byte blocks off the message pointer. Single-core: chacha20_xor 296 MB/s, poly1305_mac 1.3 GB/s — past gigabit wire speed for the whole served path, which is what a LAN can carry. RFC vectors and the TLS corpus tests pass unchanged.
nurlpkg install closes by naming what landed. A tool install ends
with <name> <version> installed → <path> (after the postinstall hint, so it is genuinely the last line); a project's dependency install ends with <name> <version>: dependencies installed — instead of a bare "done." either way.
affine_mix is gone; the suite is back to fifteen rows. It was the
closest pair in the corpus to lcg — both 20M-iteration serial xorshift mixing chains — and chaincheck.sh recorded the giveaway: the two rows carry the same documented floor of 6 cycles per iteration (imul(3)+add(1)+shr(1)+xor(1) against lea(1)+and(1)+shr(1)+xor(1)+lea(1)+and(1)). Different instruction mix, identical critical-path length, and so identical cells: the last run had the C column at 40.6 ms for lcg and 40.0 ms for affine_mix. That is the same objection that retired stream_lcg — it made the table longer without making it say more. ring_write and histogram_bins share that 6-cycle chain too but each bolts something on it (an off-chain store, a read-modify-write), so they stay.
hash_join now runs 5M probes, up from 500k. By the suite's own
reading advice — "the rows worth comparing are the ones in the tens of milliseconds and up" — the row did not qualify: it measured 4.9–6.2 ms against a 1.8 ms process floor, so roughly three quarters of the cell was start-up. It was the one row the report told you to discount. The build table, capacity, Bloom width and probe pattern are unchanged, and the query count stays above the use_partitioned threshold, so the algorithm and its shape are the same — there is simply ten times as much of it. The compiled cells now land near 38–44 ms. The checksum changes with the query count, as expected.
runtime.mingw.o could not link std/net or ext/http.
clock_gettime / nanosleep / readlink and ten friends lived in the MSVC-only compat tier on the theory that mingw-w64 ships them — its headers do, but the functions live in libwinpthread (or nowhere), and the bundled zig links neither, so thirteen symbols came up undefined. Field-reported on v0.27.0 from a machine with no system LLVM — the exact path CI's runners, which all have LLVM, never walked. The compat code is now split into a shared _WIN32 tier (what neither ABI provides) and an MSVC-only tier (what mingw genuinely has), verified by cross-linking the wide stdlib surface with zig cc -target x86_64-windows-gnu, and the Windows smoke test now imports that surface on both compiler paths instead of core/io alone.
and nurlpkg reported it as "package not found".** The HTTP shim staged request/response bodies under $TMPDIR-or-/tmp; Windows spells its temp dir TEMP (or TMP) and has no /tmp, so the tempfile creation died first on machines without a stray C:\tmp — zero network calls, masked as a missing package. The temp dir now resolves TMPDIR → TEMP → TMP → /tmp, and pkg_fetch_index reports a transport failure as a transport failure, with the URL and a hint; only a genuine 404 stays silent.
nurlpkg test and nurlpkg bench only worked inside a toolchain
checkout.** Both resolved their build driver to a bare ./nurl.sh, a path that exists in the NURL repo and nowhere else — so in a package built against an installed toolchain every single test failed identically with /bin/sh: 1: ./nurl.sh: not found, reported as (compile error), which reads like the package is broken rather than the runner. They now resolve the driver the way build, install and the publish gate already do: $NURL_CC first, then a checkout's own ./nurl.sh / nurl.bat when you are standing in one (so a toolchain build keeps testing with the compiler it just built), then $NURL, then the installed nurl on PATH. Both commands also resolve deps/ first, like build does — a test importing deps/<pkg>/src/... no longer needs a separate nurlpkg install to compile.
suite re-measures on every merge to main, the commit it pushes back — two data files, no code — re-triggered ci.yml (three required checks, one of them a FreeBSD VM) and windows-tests.yml, neither of which has a paths filter. Not a loop: bench.yml already excludes the results files from its own filter, so it never re-triggered itself. Just a doubling of CI cost per merge, against code that did not change. The results commits now carry [skip ci]; the numbers in them come from a run that passed the correctness gate and chaincheck before committing.
destroyed the run's numbers.** Two defects in the freshly-landed direct-push path, both caught on its first real run. actions/checkout writes an Authorization header for GITHUB_TOKEN into the local git config, and that header wins over credentials embedded in the remote URL — so the push went out as github-actions[bot] and was refused with GH013 even though BENCH_PUSH_TOKEN was set. The header is now unset before the push. And the retry replayed with reset --soft + commit --amend, which rewrote the tip commit that had landed while the suite ran; the remote refused that as a non-fast-forward, turning one refusal into three. Worse, the replay stashed the results by path — but they are committed by then, so nothing was stashed, stash@{0} did not exist, and a successful replay would have published the old numbers. The files are now copied outside the worktree before the first push and restored onto whatever tip arrived; a replay that finds the new tip already carrying them exits clean. Verified against a real contended push.
workflows fire on a v* tag: web-deploy.yml checks out the tag and renders the landing page's table from bench/results/latest.json as of that commit, while bench.yml starts at the same moment and needs tens of minutes. A tag is immutable, so it can never contain numbers measured after it was cut — and once bench.yml finally landed its results, nothing redeployed the site. Routing those results through a pull request (which is what changed) guaranteed they arrived after the deploy every time; before that they were usually already on main, which is why this looked like a regression rather than a design gap.
Fixed from both ends. bench.yml now re-measures and **commits on every merge to main**, not only on tags and manual runs, so main is continuously current and a tag carries correct numbers by construction; the results files are already excluded from its own paths filter, so it does not re-trigger itself, and the push replays onto the tip if another merge lands during the run. And web-deploy.yml now takes the two results files from main rather than from the checked-out ref, so the deploy publishes the newest measured numbers however it was triggered — tag push or manual — instead of depending on when the tag was cut. Everything else the page is built from still comes from the checked-out ref, so a tag deploy still describes that tag.
The committed table this release shipped with was measured at fe2fc2930, two releases back.
A WebAssembly release. The toolchain could already compile NURL to wasm32-wasi and run it on a runtime written in NURL; what was missing was an honest measurement of what that costs — and building one turned up the reason nobody had noticed the cost was wrong.
bench/wasmbench.sh compiles every benchmark in the corpus to native and wasm in three languages and runs each module on two runtimes: the external reference wasmtime and packages/nwasm. Running both is what earns its keep. A JIT re-optimises whatever it is handed, so it reports about the same number for a good module and a bad one; an interpreter executes exactly what is in the module. That asymmetry exposed zig cc silently dropping -O for LLVM IR inputs — **every wasm module the toolchain had ever produced shipped unoptimised**, invisible in the reference column and a 5.5× gap in the interpreter's.
With that fixed, the numbers became worth acting on. NURL's steady-state wasm throughput is 1.0–2.1× native on most rows, at or better than clang's own wasm output. The start-up gap that remained was all dead code, so --gc-sections became the link default — after re-testing the closure hazard that had blocked it at exactly the documented scale, where a --gc-sections nurlc.wasm self-compiles the 65k-line compiler byte-identically. And the interpreter itself went from walking raw bytes to a predecoded register form: **191 → 56 host instructions per wasm instruction**, and a full compiler self-host on it from 5m45s to 24s.
Also here: nurl upgrade, so the toolchain updates itself instead of telling you to re-run a curl one-liner; --version on every binary; and several toolchain seams that had been failing quietly — a missing realpath stub that broke every wasm build reaching path_canonical (nurlc.wasm included), the served installer still wiping the install prefix, and ./build.sh never building nurlpkg.
packages/nwasm: linear-memory access in words, frames from a
pool.** Two follow-ups to the register core, measured separately. Memory first: __mem_load/__mem_store did a bounds-checked Vec access per byte — an i64.load was eight of them. Now one bounds check up front, then raw word access: any access that fits inside one 8-byte word (every naturally-aligned load and store) is a single machine load or one read-modify-write, and memory.copy/fill are memmove/memset on the raw buffer. Worth ~5 % on store-heavy rows — the dispatch loop, not the memory path, is what remains. Second: every call allocated and zero-filled a fresh slot array and freed it on return. Frames now recycle through a per-function pool; a reused frame only re-zeroes its declared locals (parameters are overwritten and register form writes stack slots before reading them). fib — pure call/return — drops 5.7 s → 2.3 s, json_parse 2.1 s → 1.6 s, and the self-host compile 30.5 s → 24.0 s, still byte-identical. The pool's high-water mark is the deepest recursion into that one function, already capped by max_depth.
packages/nwasm executes in register form — the value stack is gone
from the hot path.** wasm validation guarantees a static stack height at every instruction, so predecode now assigns the value at height h to slot (locals + h) of one flat per-frame array and resolves every operand to an absolute slot index. local.get/set/tee become register moves, block/loop/end emit nothing at all, and branches are direct jumps carrying their statically-computed result moves — there is no runtime control stack and no push/pop; a hot op is three raw word accesses. Code made unreachable by br/return/unreachable is parsed structurally (alignment survives hostile bytes; the hardening suite passes as-is) but emits nothing. Cold ops — floats, conversions, the 0xfc family, imports — bridge through the old value stack, so their semantics are the previous executor's arms verbatim.
The interpreter now retires **56 host instructions / 18.9 cycles per wasm instruction**, from 191 / 54 at the start of this cycle. Same modules, predecode core → register core: sort_window 13.3 s → 5.4 s, matmul 3.5 s → 1.5 s, collatz 2.1 s → 0.9 s, lcg 1.6 s → 0.9 s, json_parse 4.0 s → 2.1 s; fib is flat — its cost is now frame allocation per call, the next known target. The compiler self-hosting on the interpreter: 1m22s → 30.5 s, still byte-identical (5m45s at the start of the cycle — 11x across the two rewrites). All 7 package suites pass unchanged.
packages/nwasm executes predecoded function bodies. The
interpreter used to walk raw bytes: every operand LEB128-decoded again on every execution, and — much worse — every block/loop/if *scanned forward through its own body* to find the matching end each time it executed, because the byte stream stores no widths. Function bodies are now decoded once, on first call, into flat fixed-width records (opcode, operands, branch targets as record indices, plus the original byte offset so trap backtraces still point into the module image), and the execution loop reads those records with raw loads. The scans and the re-decoding are gone; __find_end/__find_else no longer exist.
Measured on the wasm suite's modules, old core → new core: json_parse 31.4 s → 5.75 s (5.5× — recursive descent is nested blocks all the way down), fib 15.9 s → 10.6 s, binary_search 18.2 s → 15.9 s, and the straight-line rows (lcg, collatz) ±5 % — their loops contained nothing to scan. The compiler self-hosting on the interpreter drops from 5m45s to 1m22s, still byte-identical to the native compiler. All 7 package test suites pass unchanged, hardening included: predecode keeps the same bounded-input discipline (__skip_imm still steps over unsupported 0xfd/0xfe immediates so record alignment survives hostile bytes; execution still traps on them).
What this deliberately does not fix: the remaining floor is the value stack — every push/pop is a bounds-checked Vec call, and at 2.5 IPC the loop is now latency-bound on that memory round-trip, not instruction-bound. The next real step is a register-form predecode (static stack-slot assignment, direct-jump branches, no runtime control stack), which this record layout was shaped to grow into.
wasmbuilder links with --gc-sections by default. The previous
default, --no-gc-sections, guarded against a real trap: NURL closures store function-table indices, section GC renumbers that table, and nurlc.wasm was observed to call_indirect-trap under it at >150 functions. Before flipping, that blocker was re-tested at exactly the documented scale — a --gc-sections-linked nurlc.wasm **self-compiles the 65k-line compiler byte-identically to the native binary** under both the reference wasmtime and the pure-NURL nwasm, and the closure corpus (test_05_closures_and_capture, test_06_torture_chamber) passes. Whatever produced the historical trap, today's wasm-ld relocates address-taken functions correctly through GC.
What the old default cost, measured by bench/wasmbench.sh: ~25 % of every module (lcg: 1064 → 820 KiB) and ~83 % of an empty program's module-load floor on the reference JIT (~60 → ~10 ms) — the single largest start-up gap between NURL wasm and C wasm. --no-gc-sections (library: WbOpts.no_gc_sections, replacing the short-lived gc_sections field) remains as the escape hatch; an indirect-call trap that appears only under the default would be the renumbering bug resurfacing and should be reported. The suite now measures the escape hatch as its tenth cell instead of the GC build, so its price stays a number. nurlapi's /build_wasm still links --no-gc-sections — its wasi-sdk container path has not been re-validated the same way; the comment there now records the evidence for flipping it.
nurl upgrade — the toolchain updates itself. Until now the only wayto move to a new release was to re-run the website's one-liner, which the "a newer NURL toolchain is available" notice duly printed at you. There is now a command, and it is the one the notice prints:
` nurl upgrade # install the newest release, in place nurl upgrade --check # report only; install nothing nurl upgrade --version v0.25.0 # pin a release (or go back to one) nurl upgrade --force # reinstall this version / replace a dev build `
nurl update, nurlpkg self-update and nurlpkg upgrade are the same command — the spellings people guess all land in one place. `nurlpkg update` deliberately keeps its existing meaning (a project's dependency requirements); quietly repurposing it would have been worse than any naming win.
It does not reimplement the installer: the release archive's checksum gate, signature gate and "replace the toolchain's files, keep the rest of the prefix" rule live in one script, and that script now ships inside the toolchain at <prefix>/libexec/get-nurl.sh. So an upgrade runs the installer that came with the version you already trust, rather than downloading a script and piping it to a shell — and there is no second implementation to keep in step. On Windows the same command drives the bundled get-nurl.ps1.
--version / -v on every binary in the toolchain. nurlfmt had no
version flag at all; nurlc only had the long form; nurl and nurlpkg accepted --version but never mentioned it. All four now answer to --version, -v and (where it isn't ambiguous) the bare word version, and say so in their usage text.
bench/wasmbench.sh → bench/WASMRESULTS.md.
The sibling of bench.sh, same roster and same protocol, one axis rotated. bench.sh asks how fast NURL is against four other languages; this asks what targeting wasm costs, and what running that wasm on NURL's own runtime costs. Every benchmark's NURL, C and Rust sources are compiled twice — native and wasm32-wasi — and each module is run on two runtimes: the reference wasmtime (Cranelift JIT) and packages/nwasm, the WebAssembly interpreter written in pure NURL. Ten timed cells per row, all gated on printing the same line as the native NURL binary, the interpreter inside the gate rather than beside it — a runtime that gets the wrong answer quickly is not a fast runtime.
- C and Rust are the control, not decoration. A NURL-only wasm-vs-native ratio cannot tell "wasm is slower here" from "NURL's wasm pipeline is slower here". And modules emitted by two other LLVM frontends are the only honest test of a runtime developed against NURL's own output: packages/nwasm runs all 15 benchmarks from all three languages with output identical to native. - Everything under test is built from the working tree — nurlc, stdlib/runtime.o, packages/wasmbuilder and packages/nwasm — so the numbers describe this repo and not whatever is in $NURL_HOME. - Running both runtimes is what found the bug. A JIT re-optimises whatever it is given, so it reports roughly the same number for a good module and a bad one; an interpreter executes exactly what is in the module and reports the difference. The -Xclang -O2 defect below had been shipping in every wasm build and was invisible in the reference column — it showed up as a 5.5× gap in the interpreter column. - The reference runtime's compiled-module cache is turned off (-C cache=n). Its CLI enables that cache by default, which made a cell mean "Cranelift ran" or "Cranelift did not run" depending on what happened to be in ~/.cache/wasmtime — including for the floor row, whose whole job is to be subtracted from the others. Measured across that boundary, lcg came out ten times faster than its own empty program. Off, both runtimes are measured doing the same work: read the module, translate it, run it.
wasmbuilder --no-gc-sections, and WbOpts.no_gc_sections for
embedders — the escape hatch for the new --gc-sections default (see Changed). Section GC renumbers the wasm function table, and NURL closures store indices into it; if that ever produces a call_indirect trap again, this switches the old behaviour back on. bench/wasmbench.sh builds every benchmark both ways and holds both to the same output, so what the hatch costs stays a measured number: ~25 % more module and most of a JIT runtime's module-load floor.
nwasm --version / --help. The package had neither, while its
sibling wasmbuilder had both, so nothing that shelled out to it could record which runtime produced a result.
./build.sh never built nurlpkg, so a developer's build/nurlpkg was
whatever they last built by hand while the same script handed them a fresh nurlc — the local toolchain could be months out of step with itself and look current. Only .github/workflows/release.yml ran tools/nurlpkg/build.sh. This is not hypothetical: a months-old nurlpkg predating publish --dry-run (and predating the unknown-flag rejection that was added to catch exactly this) silently ignored the flag and published for real. build.sh now builds it as a soft step, like nurlfmt.
wasmbuilder's version stayed at 0.1.3 while its behaviour changed under
it.** 0.1.3 is already published; the --gc-sections default flip, the -Xclang optimisation fix, the realpath stub and the declare-stripper fix all landed without a bump, and the README and --help text had already been written against "0.1.4". Now 0.1.4 in the manifest and in --version together — the same trap as the earlier 0.1.1/0.1.3 mismatch, in the other direction: there the literal lagged the manifest, here both lagged the code.
path_canonical
failed — including nurlc.wasm itself.** Two stacked defects, one seam. wasi-libc ships no realpath(3), and it was missing from wasi_ir.nu's POSIX stub list, so the symbol became an env wasm import and the reference wasmtime refused to instantiate the module (`unknown import: env::realpath`). Adding the stub exposed the second defect: the stub machinery removes the now-renamed declare line by reconstructing it with single spacing, but nurlc has two declare emitters — the FFI path prints declare i64 @fork(), the column-aligned prelude prints declare i8* @realpath(i8*, i8*) — so the rebuilt line missed the padded kind, the stale declare collided with the appended define, and clang rejected the module ("invalid redefinition"). The stripper now locates the actual line by symbol (__wb_ir_decl_line) instead of guessing its spelling. The stub returns NULL, which path_canonical already maps to None — the honest wasm answer. find_clone (whose import graph spans the whole POSIX-stub surface) joins the wasmbuilder corpus so this seam stays covered.
prefix.** tools/get-nurl.{sh,ps1} stopped doing that in July — an upgrade had been deleting the registry token, models/, share/ and every program installed with nurlpkg install. But `nurlweb/public/ install.{sh,ps1}`, the copies actually downloaded by curl … | sh, are made by a manual npm script that nobody re-ran, so the one-liner on the website kept handing out the destructive version. The copies are re-synced, and tools/check_installer_sync.sh now runs in CI so this class of drift cannot outlive a pull request.
models/ cache and libexec/ are now named explicitly in
the installers' keep/remove rules, and the Windows installer renames a file it cannot delete instead of failing: nurl upgrade is a process upgrading the tree it is executing from, and Windows refuses to unlink a running .exe (POSIX allows it, which is why the same code path is uneventful there).
wasmbuilder ever produced shipped unoptimised code.
zig cc drops -O for LLVM IR inputs — it forwards the level for C, but for a .ll it passes nothing to cc1 and cc1 defaults to -O0. This pipeline hands zig a .ll, so nothing the user wrote was optimised: no mem2reg, so a loop counter stayed a linear-memory load/store pair, and bench/lcg.nu's inner loop reloaded the same value twice per iteration.
nurl.sh has carried the workaround for native builds since it was discovered there (-Xclang <level>, which reaches cc1 and survives); wasmbuilder never got the wasm half of it. Now it does.
A JIT hid this completely, which is why it lasted: Cranelift re-optimises whatever it is handed, so the reference wasmtime timings moved by nothing. An interpreter cannot — packages/nwasm ran lcg at 1M iterations in 1.09 s before and 0.20 s after, and the full 20M-iteration benchmark went 17.86 s → 2.77 s. The gap between those two columns is what made the bug visible at all, and it is the reason the wasm suite runs both runtimes rather than only the fast one.
packages/nwasm: the interpreter loop re-read its frame on every
instruction.** The frame stack only moves on a call, a return, or falling off the end of a body, but the loop did a bounds-checked vec_get, an Option unwrap and a dependent load per instruction, then copied pos/end into the cursor and back out again. The cursor is now the authority on where execution is and the frame is cached until the stack moves; pos is written back exactly where something reads it (a call suspending the frame, a trap printing a backtrace). Worth 3–4 %.
Two other candidates were tried and reverted, both measured: bisecting the opcode dispatch chain (−5 %) and a single-byte fast path in the LEB128 reader (−12 %). The dispatch chain reads like a 177-comparison linear scan in the source but LLVM already lowers it to jump tables — one indirect jump in __exec_op, two in __exec_num — so "reordering by frequency" only split a working table into five. At 3.4 IPC and a 0.05 % branch-miss rate the interpreter is not stalling anywhere; it retires ~191 host instructions per wasm instruction, and that cost is spread across decode-at-execution and bounds-checked container access on every stack and local touch. Bringing it down needs a pre-decoded instruction representation, not local edits.
wasmbuilder --version reported 0.1.1 when the package was 0.1.3. A
version string is what a bug report quotes; a stale one misattributes the bug. Both this and the new nwasm version literal carry a comment saying to keep them in step with nurl.toml.
wasmbuilder's --cflags, --obj and --asyncify-imports were
undocumented** — implemented in the CLI, absent from the README, so the only way to find them was --help or the source.
A measurement release. Nothing here changes the language; what changes is what the project can prove about itself.
The benchmark corpus is rebuilt into one suite — 15 benchmarks, each implemented in NURL, C, Rust, Node and Python, each row timed only after all five implementations print the same result — and the landing page's table is now generated from the last run of that suite instead of being typed by hand. Building it out to five languages turned up two defects in the corpus itself: hash_join had been checksumming to zero in every report it ever produced, because a random 64-bit probe key never matches a 160-row table, so it was timing nothing but a Bloom filter's reject path — and one C draw depended on the unspecified evaluation order of |, i.e. on which compiler happened to build it.
The same instinct runs through the performance work: two hot paths were paying for something nobody read (a lock xadd per allocation) or doing work quadratically that they described removing elsewhere (four borrow-checker scans re-running strlen per byte), and a self-compile is 11.6 % fewer instructions for it. One real miscompile is fixed — an array store at a u64 index emitted invalid IR that only clang rejected, so a program could read at an unsigned index but not write at one.
three-or-four, and the landing page's table now comes out of the measurements instead of out of someone's memory.** bench/ carried two runners with overlapping corpora — run.sh (3 benchmarks × NURL / Python / Rust / Node) and run_micro.sh (10 u64 kernels × NURL / C / Rust) — each with its own protocol, its own report format, and no language in common across the whole set. They are replaced by bench/bench.sh: one runner, one roster (bench/manifest.tsv), one contract, and every benchmark implemented in all five of NURL, C, Rust, Node and Python.
- The contract is now uniform. Every implementation prints exactly one line — its checksum in decimal, masked to 63 bits so the languages without an unsigned 64-bit type can print it. The runner compares all five lines and refuses to time a row whose implementations disagree, which replaces the u64 set's dual-build --verify / -DBENCH_VERIFY / --cfg bench_verify machinery (and halves its compiles). - 31 implementations written to fill the matrix: C for lcg, sieve, fib, collatz, matmul and json_parse; Python and Node for the nine u64 kernels. Where a kernel is genuinely 64-bit wide, the Node port uses BigInt, and where 32 bits suffice it uses Numbers with Math.imul; each file states which and why, and the checksum gate is what keeps "each language at its fastest exact representation" from drifting into "each language computing something else". - hash_join was measuring nothing. Its checksum was all-zero in every report because probe keys were drawn at random from a 64-bit space and a 160-row table never matched one — so the benchmark only ever exercised the Bloom filter's reject path, which the report dutifully noted and nobody acted on. Every second query now probes a key that was actually built, with the LCG stream advanced identically on both sides of the branch. - stream_lcg removed as a duplicate: it was lcg with 32-bit constants, so it made the table longer without making it say anything new. No two rows in the suite now measure the same shape. - Workloads retuned so the compiled languages land in a range wall clock can resolve and the interpreted ones stay finite: matmul 128 → 256, json_parse 5 → 20 parses, bloom_filter 1M → 4M queries, sort_window 5M → 2M, affine_mix / ring_write 50M → 20M, packet_classifier 50M → 25M. Slow cells get fewer repetitions (--budget-ms) rather than a shorter workload, so every language runs the same computation. - Two artefacts per run: bench/results/latest.json (machine-readable) and bench/RESULTS.md (run times, compile times, correctness gate, and a process-start-up floor row to subtract). .github/workflows/bench.yml runs the suite weekly and on demand and commits both. - nurl-lang.org's "Measured, not promised" table is generated from that JSON at publish time by tools/gen-bench-table.mjs, wired into nurlweb's predeploy hook. The page stays 100 % static at serve time and can no longer drift from the measurements: the three hand-typed rows are gone, replaced by all fifteen, with the fastest cell in each row highlighted. - bench/verify.sh now defaults to the genacc/ task list (it is the correctness gate for the generation-accuracy and token studies, whose string tasks are deliberately not benchmarks), and bench/perfstat.sh reads the roster from manifest.tsv and the new one-line checksum.
they counted.** nurl_alloc / nurl_zalloc / nurl_free each bumped a process-wide unsigned long long with a RELAXED atomic, on the recorded grounds that it "costs nothing measurable next to the malloc itself". On x86 a relaxed read-modify-write is still a lock xadd — ~20 cycles plus a store-buffer stall — and every NURL program paid it twice per allocate/free pair whether or not anything ever read the counter. Each thread now counts into a heap block of its own with plain arithmetic and readers sum the blocks, so the totals and their monotonicity are unchanged (verified across threads) and the lock prefix is gone. Measured on a pinned core: bench/stdlib_hotpath.nu's sort_32_desc −10.0 % cycles, string_build_16 −9.7 %, vec_push_64 −3.9 %; parsing bench/data.json −8.1 % cycles / −9.4 % wall clock.
bck_st_set, bck_join_state, bck_field and bck_explode walked their strings with nurl_str_get, which re-runs strlen on every call — so each byte examined cost a pass over the whole string, in the functions whose own comments described removing exactly that cost elsewhere. They now read through hoisted *u pointers, like the neighbouring __bck_st_get_at. A nurlc self-compile drops 3.69 G → 3.26 G instructions (−11.6 %) and 552 ms → 514 ms (−7.0 %); the borrow checker was 42 % of the compiler's run time before the change. Gate: all 1122 .nu files under compiler/tests, stdlib, examples and packages compile to byte-identical IR and byte-identical diagnostics.
refresh step pushed bench/results/latest.json and bench/RESULTS.md straight to main, which a repository ruleset declines with GH013: changes to main must arrive through a pull request. The suite ran, the gate passed, the report was written — and the last step threw it away with a red X. It now commits to a bench-results/<date>-<run> branch and opens a PR (pushing a new branch is allowed), so the numbers reach main the way every other change does. The step documents what is still needed for that PR to merge itself: a PR opened with the built-in GITHUB_TOKEN does not trigger workflow runs, so the required checks never start and --auto cannot complete — either the Actions bot needs a ruleset bypass, or the step needs a PAT.
bench_auto could report a regression for a build that got
faster.** Its iteration ramp multiplies by 4 and stops at the first pass clearing 50 ms, so a body whose cost sits near that threshold lands on opposite sides for two builds and the two results then come from runs 4× apart in length. On sort_32_desc that reported +5 % ns/op for a build that was 8 % faster on both cycle count and wall clock. bench_auto now scales back onto the target window and measures once more, so every reported number comes from a run of about the same length.
= . p idx v
where idx had a sized unsigned type printed the NURL type name into the getelementptr — getelementptr i64, i64* %p, u64 %idx — instead of lowering it like every other type on the line. nurlc accepted it with rc 0 and only clang rejected it, with a bare error: expected type and no NURL source location. The four index-store paths in gen_stmt now lower the index operand through nurl_llty, as the matching load paths already did; before this, a program could read at a u64 index but not write at one. Regression: compiler/tests/gep_unsigned_index_store.nu.
bench/ gains nine u64 kernels — affine_mix, packet_classifier,
ring_write, histogram_bins, prefix_scan, binary_search, sort_window, bloom_filter, hash_join — each a deliberately different shape (branch misprediction, dependent stores, pointer chasing, a compare/branch mill, an early-out plus a rare slow path). They are part of the five-language suite described above, so each ships with a NURL, C, Rust, Node and Python implementation.
bench/perfstat.sh — the same kernels measured in retired
instructions and core cycles instead of wall clock, with a save/compare mode for A/B-ing a compiler or runtime change. Wall clock on this set drifts by several per cent between runs, which is enough to invent a language difference that is not there: the first report showed histogram_bins at 1.15× against C when the two binaries retire the same instructions in the same cycles. Counters make a 1 % change legible and a regression impossible to miss.
A Windows repair release. 0.25.0 taught the driver to prefer the zig the archive has always bundled — the whole point of bundling it being that a machine with no Visual Studio can still build a program — and nothing had ever driven that path end to end. It could not link anything, in three different ways, each hidden behind the one before it. The release workflow would not have caught it either: that runner has LLVM, so the driver fell back to clang and passed.
Nothing here changes the compiler or the language; every fix is in the Windows build and link path, plus the CI that was supposed to be watching it.
archive ships a zig so a machine with no Visual Studio can still build, and 0.25.0 taught nurl.bat to prefer it — but nothing had ever driven that path end to end. It failed three ways, each hidden behind the one before it. zig cc targets x86_64-windows-gnu, so: - bcrypt and ws2_32 were requested only by `#pragma comment(lib, ...) directives inside runtime.o`, which is how the MSVC toolchain finds an import lib without anyone naming it. Under MinGW those directives are mangled to libbcrypt.a, a name nothing in this toolchain produces (zig synthesises bcrypt.lib), so the link died on a file it could never open. The pragmas are now MSVC-only, as runtime_core.c has always had them, and nurl.bat names the import libs the way nurlapi's mingw-w64 cross link always has. - Underneath that, runtime.o is clang-built and therefore MSVC-ABI: it references _setjmp, __chkstk and _fltused, none of which MinGW's CRT provides. The two ABIs cannot share one object, so build.bat now also emits stdlib\runtime.mingw.o with zig when a zig is present, and nurl.bat links whichever object matches the compiler it chose. Programs built through the bundled zig cannot use the canvas FFI or the zstd FFI (both MSVC-only artifacts); the driver now says so instead of leaving it to the linker. - The release workflow fetched its zig after running build.bat, so the archive would have shipped without the MinGW runtime — and its smoke test would not have caught it, because the release runner has LLVM and the driver falls back to clang. Both workflows now fetch zig first, and the release smoke test asserts the object is in the archive and builds a program both ways.
float_extra's expected output was written
in June and never grew the seven erf/erfc lines 0.25.0 added, so Windows CI went red one push later. tools/check_windows_goldens.sh now catches that shape from Linux — a Windows golden missing lines its Linux counterpart has — since the Windows leg only runs on main.
The release that makes packages/torchpt and everything above it installable: the zip64 reading a 4.6 GB PyTorch checkpoint needs landed an hour after 0.24.1 was tagged, so every package built on it has been unbuildable by the released toolchain until now.
Also the largest compiler speed-up so far, and a codegen fix that made vec_extend uncompilable for struct elements.
stdlib/ext/zip.nu
gained zip_open_ptr (open an archive over a memory-mapped range without copying it into a Vec u first), zip_find, zip_csize_at, zip_method_at, zip_data_off, and zip64 central-directory support — the 64-bit size/offset extra field, so an archive over 4 GB is readable at all. A PyTorch .pt checkpoint is a zip, and a 4.6 GB one needs every part of that: packages/torchpt reads the 1342 tensors of a real checkpoint in 0.01 s and 31 MB of RSS by mapping the file and never copying an entry it is not asked for.
erf / erfc in stdlib/std/float.nu — the exact GELU a
transformer wants (x·Φ(x)), not the tanh approximation.
self-compile went 0.83 s → 0.55 s and peak RSS 405 MB → 122 MB, with the IR and diagnostics of all 1121 .nu files in the tree byte-for-byte unchanged. The borrow checker was 56% of a compile; it is now 40%. Four findings, all of them shape rather than constant: - **The symbol table was being used as an accumulator, and def shadows rather than replaces.** bck_record appended one line per statement by fetching the whole accumulated list (a strdup), concatenating, and defining it back (another strdup) — three copies of the entire list per statement, with every older copy left in the table because a define pushes a new entry. That is O(N²) time AND O(N²) live memory per function. nurl_sym_set overwrites in place when the newest entry is at the current depth (which is always, for a table that is never pushed or popped), and nurl_sym_append grows the value with one nurl_realloc. This is where most of both wins came from. - 62% of symbol lookups built their key just to hash it. Metadata queries are keyed <name>__field_count, <name>__variants and so on — 740k per self-compile, each materialising the key with a malloc, two memcpys and a free. nurl_sym_get2 takes the two parts and never joins them: FNV-1a is a streaming hash, so the hash of the parts IS the hash of the concatenation, and the chain compare comes apart the same way. - str_contains_word re-sliced the tail per word. The same shape already found and fixed inside the borrow checker's state lookup was still in the helper 87 call sites use: str_first_word scans with nurl_str_get (strlen per byte) and allocates the word, str_skip_word allocates a copy of the whole remaining tail. O(L²) bytes and 2W mallocs per containment test. It searches for the word and checks the boundaries now, allocating nothing. - The borrow checker's state lookup walked token by token, calling memmem once per token to find the next separator — ~100 calls per lookup on a 1 KB state, 41M of them per self-compile, each scanning ten bytes, where the call overhead is the cost. One search for the name replaces the whole walk. - Also: nurl_sym_len / nurl_sym_len2 answer "is this set?" without copying the value out, at the 77 sites that asked nurl_str_len ( nurl_sym_get … ).
nurl_str_float searched linearly for the shortest round-tripping
decimal.** It tried %.1g, %.2g, … up to %.17g, parsing each back with strtod until one matched — seventeen snprintf + strtod pairs for the worst case, which is the common case: a double that came from an f32 needs ~17 significant digits. That is ~2.5 us per number, and it is on the path of every float any NURL program prints. "p digits round trip" is monotone in p, so it is a binary search: five probes instead of seventeen. Found by profiling a point-cloud writer, where formatting three coordinates per point cost more than the neural network that produced them.
= . p + k 0 v did not compile when the element type was a
struct.** A store through a computed pointer index died with unknown field or variable: +, naming the operator as if it were a field. The READ path had always accepted the same expression; only the store peeked at the token after the pointer and, finding something that was not an identifier, fell through to the error. stdlib/core/vec.nu's vec_extend hits it the moment its element type is an aggregate — the generic monomorphises and fails with a diagnostic pointing at nothing the caller wrote.
rand.The wasm32 ABI shims hardcoded each shim's signature from a letter table, but the signature the CALL SITES use comes from the NURL source. They are derived from the IR now.
rand died on the playground with
"runtime error: unreachable".** doomfire.nu, sand.nu and starfield.nu compiled (BUILD OK, a well-formed .wasm) and then trapped — doomfire and starfield on their first frame, sand on the first mouse-drawn grain. gameoflife.nu declares rand but never calls it, which is why it kept working and made the failure look like a canvas problem.
The wasm32 libc ABI shims in the /build_wasm IR rewriter (nurlapi/main.nu, mirrored in packages/wasmbuilder/src/wasi_ir.nu) hardcoded each shim's signature from a letter table. But the signature the call sites use comes from the NURL source: nurlc's own prelude declares puts / strcmp / fseek with C-accurate i32 returns, while & \libc` @ rand → i emits declare i64 @rand()`. Since v0.9.x the table's i letter meant "shim returns i32", so every FFI-declared → i libc function got an i32 shim under i64 call sites. wasm-ld does not reject that mismatch — it resolves it with a stub whose body is a single unreachable, so the module links and traps at the call.
The rewriter now reads the declare line back out of the IR and mirrors it, converting widths in the wrapper (trunc on the way in, sext/zext on the way out); the letters only describe the wasm32 libc side. This also repairs open / close / read / write, whose shims were mismatched the same way for every std/fs.nu and core/posix.nu caller on wasm, and stops %r = tail call void @exit(…) — invalid IR — from being emitted for the void-returning entries. nurlapi/e2e_test.py now asserts every __nurl_*_shim definition matches its call sites.
A patch for a display bug that read as a transfer bug: model downloads were reported as steadily slowing down when they were not.
was not slowing down.** stdlib/std/progress.nu derived its rate from cur / (now - started) — an average over the whole transfer. Two consequences, both visible on a model download:
On a resume*, the caller sets the already-downloaded bytes with progress_set immediately after progress_new, so the first render divided (say) 400 MB by 0.2 s and printed 3.7 GB/s, then decayed continuously — 763 MB/s, 381 MB/s, 191 MB/s — while the actual throughput sat at a constant 1 MB/s. That is the "starts at full speed and steadily declines" report. * Even from zero, a cumulative average cannot react: a transfer that settles at its real rate after a fast first second spends minutes crawling down toward the truth.
The rate is now the current throughput — an exponential moving average over roughly the last second of samples, the number curl and wget show — measured from the resume point, so bytes that came off the disk never count as network throughput. progress_done reports the average over this session's bytes.
Measured on the same synthetic resume (400 MB present, steady 1 MB/s): before 3.7 GB/s → 763 → 381 → 191 → 127 MB/s; after a flat 975 KB/s.
The transfer itself was never slow: a 4-minute, 195 MB download through the same stdlib path (TLS + file_write_chunk + sha256_update) held 462 → 939 KB/s with no trend, at 15% CPU and flat RSS, against 725 KB/s for curl on the same URL at the same time.
The performance release. No new language surface — this one is about making what already exists fast, and about how the slow parts were found. Three discovery routes, each catching a class the others would have missed: reading the primitives, surveying allocations per operation, and finally profiling a real workload. The last one turned on the compiler itself.
nurlc compiles its own 1 MB source 4× faster (3.43 s → 0.86 s), and the stdlib's byte-level paths — hex, base64, percent-encoding, case mapping, the TOML/XML/URL parsers, regex — are between 2× and 656× faster. Emitted IR is byte-identical throughout: every change here is a same-output, less-work change, and the bootstrap fixed point holds.
One correctness fix rode along: string_eq compared with strcmp, so two Strings differing only after an embedded NUL compared equal.
nurlpkg tells you when your toolchain is behind.
stdlib/ext/update_check.nu prints at most one line to stderr after a command's output when a newer release exists. Best-effort and non-fatal (never blocks the command, never changes its exit code), cached to at most one network probe per day (`$NURL_HOME/ .update-check`), and silent where the notice would be noise: opt-out via $NURL_NO_UPDATE_CHECK, in CI, when stderr is not a terminal, and on a dev/dirty build. This closes the real hazard behind the "publish --dry-run published for real" scare — a stale binary predating --dry-run running against today's registry.
packages/benchmark 0.1.1 — reproduce NURL's performance claims on
your own machine.** sha256 / json / sort / cbor / utf8 / int-loop plus a 1 M-row CSV sort (parse with the stdlib CSV reader, sort by (type, date, uuid)). Renamed from bench — the registry reserves that name. 0.1.1 fixes an O(N²) in the utf8 benchmark that reported 0 MB/s in the wild.
packages/hub 0.1.1 — fetch models from Hugging Face into one
shared, verified cache** (~/.nurl/models). embed 0.1.4, whisper 1.0.4 and nurllama 0.12.10 now accept a Hugging Face ref directly and fetch through it.
string_reserve_at / string_commit (stdlib/core/string.nu) — a
bulk emission cursor.** string_push_char costs a call, three nurl_peeks and a nurl_poke per byte — ~1.7 ns even on its fast path, because the compiler must re-read the control block every time (the store could alias it). An encoder that knows how many bytes it is about to produce can now reserve the room once, write straight through a *u, and commit at the end: measured 565 ns vs 7 061 ns for 4096 bytes (12.5×). The reserved pointer is valid only until the next operation that can grow the buffer — the contract is spelled out at the definition. Committing fewer bytes than reserved is allowed, so encoders whose output size is only an upper bound (percent-encoding) can use it too.
nurl_str_at str len idx (stdlib/core/string.nu) — the O(1) byte
accessor scan loops want.** Same contract as nurl_str_get (0 when idx is outside [0, len), which is what parsers rely on when they peek one or two bytes past the cursor), but the caller passes the length it already knows, so no strlen runs. nurl_str_get stays for one-off reads where no length is at hand.
nurlc — self-compile 1.01 s → 0.86 s.
Found with perf (the previous compiler fix came from gprof), which showed 20% of the compiler's runtime inside libc strlen and named the two callers:
`emit_hoisted` materialised every IR line* with nurl_str_slice, which re-runs strlen over the whole function body and mallocs a copy — per line, and twice, since the pass runs once for allocas and once for everything else. That is quadratic in function-body size (8.7% of total runtime in strlen alone, plus a leaked allocation per emitted line). Lines are now printed in place: write a NUL over the terminating newline, print from the buffer, restore the byte. nurl_print consumes its argument synchronously and nurl_print_buf_stop returns an independent strdup, so the swap is never observable. * __bck_st_get_at — the borrow checker's per-binding state lookup, called 14.8M times per self-compile — scanned its space-separated state string with nurl_str_get, i.e. a strlen per byte examined (17% of the profile, plus 5% more inside strlen). The token walk now runs on memmem + memcmp.
Emitted IR is byte-identical and the bootstrap fixed point holds. Peak RSS is unchanged. memchr was tried in place of memmem for the one-byte needles and made no measurable difference, so it was dropped rather than carried for the extra FFI declaration.
nurlc recomputed the current line number by rescanning the source
from byte 0 on every parser backtrack — 3.3× faster self-compile.** nurl_lex_set_pos counted newlines from the start of the file to derive LX_LINE, so each backtrack cost O(source size). The parser backtracks per closure body, per ?? guard arm and per paren-disambiguation probe, which makes the total quadratic in file size for closure- and match-heavy sources. nurl_lex_new now builds a newline-offset index once (one O(n) pass) and set_pos binary-searches it.
Measured: nurlc compiling its own 1 MB source drops 3.43 s → 1.03 s. Emitted IR is byte-identical, and the bootstrap fixed point still holds. The effect scales with source size — on a synthetic closure-heavy file, 32 KB is unchanged while 264 KB goes 0.58 s → 0.30 s and the growth curve turns from super-linear to linear:
| source | before | after |
|---|---|---|
| 32 KB | 0.05 s | 0.04 s |
| 65 KB | 0.09 s | 0.07 s |
| 131 KB | 0.28 s | 0.14 s |
| 264 KB | 0.58 s | 0.30 s |
| 1 017 KB (nurlc.nu) | 3.43 s | 1.03 s |
Whole-build wall-clock moves less than this suggests: ./build.sh is dominated by clang/LTO, and nurlc is ~10 s of it across the bootstrap stages. Found by profiling (gprof) rather than by reading — the old set_pos was 89% of nurlc's self-compile profile.
engine.** Two distinct costs, same shape — work repeated per byte or per loop iteration that only needed doing once:
Cursor emission. bytes_to_hex, hex_encode, __b64_emit, url_percent_encode, string_to_lower / _to_upper / _reverse and __fmt_emit produced their output one string_push_char at a time. They now reserve the exact (or upper-bound) output length once and write through the new cursor; __fmt_emit additionally copies each literal run between placeholders in one string_push_bytes instead of a push per byte.
Scratch reuse. __rx_run_at allocated **four Vec[i] on every start position** of a search — 8 005 allocations for a 2 KB regex_replace — and re-zeroed them with a per-state loop each time. The four buffers carry no state between runs, so every public regex entry point now builds one RxScratch up front and reuses it for the whole scan. Passing the scratch explicitly (rather than caching it inside the Regex) keeps a compiled Regex reentrant.
Measured (std/bench.nu, -O2, ns/op):
| before | after | ||
|---|---|---|---|
string_to_lower 4 KB | 14 325 | 366 | 39× |
hex_encode 4 KB | 25 573 | 1 004 | 25× |
bytes_to_hex 4 KB | 25 342 | 1 058 | 24× |
regex_replace 2 KB | 557 647 | 77 382 | 7.2× |
b64_encode 4 KB | 16 349 | 4 270 | 3.8× |
url_percent_encode 1 KB | 6 902 | 2 157 | 3.2× |
regex_test 2 KB | 1 924 | 595 | 3.2× |
fmt1, 512 B template | 1 758 | 548 | 3.2× |
regex_replace drops from 8 005 to 5 allocations per call and regex_test from 28 to 4. Byte-for-byte output is unchanged: the encoders are covered by the RFC known-answer vectors in the corpus (blake2b, chacha20poly1305, x25519, ed25519, aes-gcm, hkdf, pbkdf2, ecdsa-p256), and the whole suite is ASan/UBSan-clean — the check that matters for an API that hands out a raw write cursor.
nurl_str_get scan loops are gone from the parsers — 278 of the
426 call sites migrated to nurl_str_at.** Follow-up to the hot-path work in the previous entry: nurl_str_get re-runs strlen per call, and in any loop that also writes (i.e. every parser) LLVM cannot hoist that strlen, so the scan is quadratic in input size. Migrated `ext/{yaml,toml,xml,nurldoc,websocket,http_router,http_request, http_multipart,regex,tar,csv,json,semver}` and std/{url,time,path,fs,decimal,encode}. Measured (std/bench.nu, -O2, ns/op):
| before | after | ||
|---|---|---|---|
toml_parse 2 KB | 82 965 | 21 018 | 3.9× |
url_parse 600 B | 6 195 | 2 581 | 2.4× |
xml_parse 3 KB | 152 096 | 74 278 | 2.05× |
path_normalize 450 B | 24 872 | 19 883 | 1.25× |
regex_replace 2 KB | 610 963 | 501 084 | 1.22× |
http_date_parse | 165 | 159 | 1.04× |
semver_req_parse | 3 106 | 3 090 | ~1× |
yaml_parse 2 KB | 51 535 | 52 095 | ~1× |
The two flat rows are expected and worth recording: semver inputs are a few dozen bytes, and yaml_parse splits the document into short lines through an accessor that was already O(1) before scanning them — so neither was quadratic in practice. The gain tracks input size, so the parsers fed whole files (toml, xml, nurldoc, tar, csv) are where it shows. No API or behaviour change: nurl_str_at keeps nurl_str_get's out-of-range-returns-0 contract, and every migrated site passes a length that is already the string's own.
stdlib/std/tls.nu — offer ChaCha20-Poly1305 ahead of AES-128-GCM
(~3× faster HTTPS downloads).** The record layer, not the network, was the ceiling on every large https:// transfer: our AES-128-GCM is the deliberately table-free constant-time S-box (recomputed per byte, ~160 S-box evaluations per 16-byte block), which measures 0.5 MB/s, while the pure-NURL ChaCha20-Poly1305 next to it runs ~25 MB/s — a 50× difference the ClientHello was throwing away by listing 0x1301 first. Both TLS 1.3 suites use the same SHA-256 key schedule, so the reorder touches nothing else in the handshake, and AES-only peers still get 0x1301 from the same list. Measured end-to-end on nurllama run LumiOpen/Llama-Poro-2-8B-Instruct (Hugging Face → its CloudFront CDN, which honours client preference), same 8 MB back to back: 188 KB/s → 594 KB/s, with per-transfer CPU dropping 25.1 s → 2.0 s — i.e. the client went from CPU-bound to network-bound, and now tracks curl on the same link (675 KB/s). Against speed.cloudflare.com: 487 KB/s → 1185 KB/s (curl: 1174 KB/s). The per-byte CPU ceiling is now ~8.5 MB/s rather than ~0.34 MB/s, so the download-side numbers vary with what the far end will give. Servers that pin their own preference to AES (e.g. huggingface.co's API host, which serves only the small metadata files and the redirect) still negotiate AES-128-GCM and remain slow — a bitsliced constant-time AES is the fix there, not a lookup table.
stdlib/std/tls.nu / stdlib/std/bytes.nu — memcpy the record-layer
copies.** _tls_cat and bytes_slice were per-byte vec_push loops sitting on the receive hot path, where every TLS record is copied four times (socket → rxbuf, body out, remainder back, plaintext → appbuf). Both now go through nurl_memcpy (bytes_extend_bytes / bytes_extend_raw): ~0.3 s less CPU per 10 MB downloaded, and the slice+concat cost for 10 MB of 16 KB records is now 1 ms. (Companion to the nurl_str_get / vec_get sweep below, which left bytes_slice untouched.)
nurl_str_get / vec_get walks are
gone.** nurl_str_get re-runs strlen on every call, so a loop that scans a string with it is O(n²). In a read-only loop LLVM hoists that strlen away, which is why the cost hid for so long — but every real parser writes while it scans, and the store blocks the hoist. Six stdlib functions were paying it, plus four more that walked a byte buffer through an ?u-returning vec_get (a bounds check and an Option per byte) where a memcpy / memcmp / memmem was available. Measured (std/bench.nu, -O2, ns/op, same machine):
| before | after | ||
|---|---|---|---|
bytes_from_str 4 KB | 117 497 | 179 | 656× |
bytes_to_str 4 KB | 16 820 | 171 | 98× |
bytes_eq 4 KB | 1 481 | 55 | 27× |
bytes_from_hex 2 KB | 20 047 | 3 322 | 6.0× |
parse_request_head 4 KB | 16 776 | 5 244 | 3.2× |
url_percent_decode 768 B | 6 216 | 1 764 | 3.5× |
url_percent_encode 768 B | 11 248 | 4 000 | 2.8× |
fmt1, 512 B template | 5 092 | 1 845 | 2.8× |
bytes_to_hex 1 KB | 9 122 | 6 895 | 1.32× |
string_push_char ×1024 | 4 035 | 2 693 | 1.50× |
string_push_char also gained an inline fast path: it used to run two independent capacity checks per byte (one in vec_push, one in _string_seal's vec_reserve) even on a pre-sized buffer. It is the most-called mutator in the stdlib, so every text builder — JSON, CBOR, YAML, TOML, fmt, hex — gets the 1.5×. No API or allocation-count change anywhere.
stdlib/std/sort.nu — insertion-sort cutoff for small subranges.
sort_by / binary_search's quicksort now hands ranges of ≤16 elements to a straight insertion sort instead of partitioning them all the way down. Measured ~22% faster end-to-end on the benchmark suite's sort i64 row (18 → 22 M/s) and ~15% on a 2 M-element i64 sort, with no allocation or API change. Investigation note: the win is the algorithm, not closure elimination — a monomorphic comparator closure is branch-predicted and costs ~1-2% over a hand-inlined compare (measured), so higher-order sorting in NURL is already essentially free. As a side effect small all-equal ranges now keep their input order (insertion sort is stable below the cutoff); sort_by still makes no general stability guarantee.
string_eq compared with strcmp, ignoring everything after an
embedded NUL.** String stores inner NUL bytes verbatim, so two strings of equal length differing only past a NUL compared equal. Now memcmp over the full byte range — which is also 1.3× faster.
pkg_publish
mapped both 401 and 403 to PubAuth, so nurlpkg answered a refused name (reserved, typosquat lookalike, or outside the token's scope) with "registry tokens expire after 90 days — run nurlpkg login", sending the user to re-authenticate a perfectly valid token. 401 keeps the token-expiry hint; 403 is now PubForbidden and names the real causes.
## [0.23.0] heading in this file. It was replaced, rather
than pushed down, by an entry added after that release was tagged, so 0.23.0's notes had silently become part of [Unreleased]. Restored.
The ecosystem release. v0.22.0 made the registry a training stack; v0.23.0 closes the loop around it — the missing packages that turn "train a model" into "train it, save it, serve it, retrieve with it", the host-memory and mixed-precision work that makes f32 training usable at scale, browsable API docs on the registry, and the MCP tooling that lets an LLM discover, read, and build against every package in the ecosystem. The compiler is unchanged from 0.22.0; the one toolchain-level change is ext/mcp_search (below). Everything else ships as registry packages and the live registry Worker.
packages/nn 0.1.0 — neural-network layers on the grad tape. The
reusable middle the training stack was missing: nn_linear / nn_lora_linear, nn_rmsnorm / nn_layernorm, nn_silu / nn_swiglu, NEOX nn_rope, grouped-query nn_gqa_attention, nn_cross_entropy — each a pure tape builder, so backward is derived and device replay + megakernel fusion come free. Lifted from grad's PyTorch-f64-verified LoRA block and gated against the same oracle (loss 3.3e-16, adapter grads 1e-13); nurllama's finetune now consumes it, byte-for-byte unchanged.
packages/safetensor 0.3.0 — a writer (round-trip partner of the
reader). stw_add_f32 / _f64 / _f16 / _bf16 / _i64 + stw_finish / stw_write; the reference safetensors Python library reads NURL-written files bit-exact across every dtype. Closes the loop: train on the tape → save through the package writer → serve through nurllama (nurllama's inline emitter folded into it).
packages/data 0.1.0 — a training DataLoader. Seeded reproducible
Fisher-Yates shuffle, epoch reshuffling, drop-last, worker sharding, and disk streaming (the .ndf format, random-access preads so a corpus larger than RAM never loads whole). Determinism is a gate: same seed → same batch order; N shards partition exactly once; streamed batches equal in-memory batches.
packages/vindex 0.1.0 — a vector index (the RAG piece). Exact
brute-force + IVF-flat (k-means coarse quantiser + inverted lists), cosine/L2, .vix save/load. Recall@10 vs the exact index is a measured gate. Completes embed → vindex → nurllama.
grad megakernel fusion (0.7.0) + mixed precision (0.8.1). Fusion
generates the fused-kernel shape from the captured tape — a row-local chain plus the scalar tail become ~5 kernels under one CUDA graph (~18× the CPU tape on the AE bench, bit-identical to the per-node replay). Mixed precision (gput_capture_dt(…, 2): f32 storage, f64 accumulation) makes the half-VRAM f32 path numerically usable at scale — ~35× closer to the f64 reference than pure f32 at identical memory.
grad 0.8.0 + nurllama 0.12.9 — host-memory training.
tape_drop_consts frees the tape's const nodes after device capture, and nurllama frees + streams the model's base weights (identical loader path, so the merge stays byte-exact) — the host-RAM wall that gated building a large model's graph is gone. nurllama finetune --f32 / --mixed.
registry 0.4.0 + the deployed Worker).
Every published release now has a browsable API page — nurldoc over its src/*.nu, at /packages/<name>/<version>/api — plus a symbol index so search matches a package by a function whose name you couldn't guess (?q=gqa_attention → nn, with matched_symbols).
nurl-mcp + ext/mcp_search). nurl_api
gains a package param — streams a published tarball and renders its API surface with the same nurldoc engine. Registry hits now carry the next step (add: nn = "^0.1.1" · API: nurl_api package=nn) and the matched symbols. And nurl_build_project compiles a program that uses a registry package: it synthesises a workspace + manifest, resolves the deps with nurlpkg install (transitive, checksum + signature verified), and compiles with the local toolchain — closing the loop from discovery to a working binary.
ext/mcp_search (the one toolchain-level change) gains
msearch_api_package (registry-package API surface) and the actionable registry-hit footer. This is why nurl-mcp 0.6.0+ needs this toolchain.
underscores (to neutralise the LIKE _ wildcard), so every underscored symbol name was unfindable; it now escapes the LIKE metacharacters (\ % _) with ESCAPE '\' instead of dropping them.
The training release: the registry's inference-only stack becomes a training stack. A new reverse-mode autodiff package (grad) is the keystone; mlp derives its backprop from it, nurllama finetunes real models with it on the GPU, and swarm-mcp runs gradient kernels *emitted from it* across a cluster — no backward pass in the ecosystem is hand-written anymore.
packages/grad 0.4.0 — reverse-mode automatic differentiation over
tensor** (new package, four releases in one arc). Define-by-run tape-recording ops (g_add, g_matmul, g_softmax, …) with one flat single-owner arena; backward(loss) is a deterministic reverse sweep with requires-grad propagation (a frozen-const branch costs no compute and no gradient memory). SGD/Adam optimizers with per-parameter L2 and global-norm clipping. A bit-exact GPU replay engine (gput_capture) mirrors a recorded episode onto the device — one kernel launch per node under the aegpu rounding discipline — so an entire training run lands on bit-equal weights on CUDA and the CPU backend alike (~5× the CPU tape on an RTX 4090 for the AE benchmark). A CUDA-C emitter (gemit_cuda_grad) prints a scalar tape as compute_iterate's __device__ grad(); the emitted C is bit-equal to the tape under gcc -ffp-contract=off, and a live-swarm linear regression landed bit-identical to the local tape run. Verified by central finite differences, hand-derived bit-exact identities, and a PyTorch float64 oracle fed raw bit patterns (loss ~1e-16, grads ~1e-13).
nurllama 0.12.1 — the engine now trains. `nurllama finetune
<model.gguf> <data.txt>` learns LoRA adapters on the GPU over the grad tape: the whole transformer forward + cross-entropy is one tape graph (llama-family NORM rope handled by un-permuting q/k at load; qwen2 native), the corpus round-robins through fixed windows with two device uploads per switch, adapters save as safetensors, and --merged writes a genuine HF-layout full-model safetensors runnable via --weights. Proven by a wiring oracle against the inference engine (top-1 identical, 2e-7 on the top logit) and by the merged model reproducing its training text verbatim through greedy decode.
packages/mlp 0.3.1 — two training engines. mlp_fit_grad runs theexact sklearn recipe with gradients from the grad tape instead of hand-derived layer deltas; both engines pass the sklearn oracle independently and agree to ~14 significant digits.
packages/anomaly 0.5.1 — GPU autoencoder training, bit-exact. The
/train/autoencoder path runs on a CUDA device when present at ~34× the CPU, with the standing guarantee intact: backend choice can never change a result (explicit __d*_rn kernels, serial accumulation in the CPU's order, host-side transcendentals).
swarm-mcp 0.21.0 (six releases): compute_iterate — GD and
general fixpoints (k-means, EM, power iteration) with the loop in the engine, now also async ({"async":true} returns a task_id; compute_iterate_status advances the run in bounded time slices, so a long training never dies to an HTTP timeout); typed datasets (f32/i32/i64); file-backed datasets streamed from disk up to 64 GiB; automatic chunk re-dispatch when a worker dies mid-task; shuffle cardinality that scales with the cluster (per-chunk tables, overflow detected rather than dropped); coordinator crash-restart recovery with dataset persistence; swarm_help topic help for driving agents.
nurlpkg: help never acts, publish --dry-run, unknown-flag
rejection, nurlpkg build.** nurlpkg publish --help used to PUBLISH — help is now resolved before dispatch, every subcommand has its own --help, --dry-run/--dryrun runs every publish gate and uploads nothing (no token needed), a typo'd flag is an error instead of being silently ignored, and nurlpkg build [<dep>] compiles an installed application package in place (arranging the deps link its root-relative imports need).
nurl_str_float now round-trips instead of truncating to 6 significant
digits.** The runtime formatted every float with "%g", whose default precision is 6 significant digits — so any value needing more was silently truncated and could not be parsed back. A sum of 41943040 printed as 4.1943e+07 (= 41943000), and π printed as 3.14159. This corrupted float output everywhere it mattered: JSON serialization (json_float), std/fmt, std/log, std/bytes, string building. Now an integer-valued double within the exactly-representable range prints as a plain integer (41943040 — readable and exact), and every other value uses the shortest %g form that parses back to the identical double (1–17 significant digits), so the text always round-trips. Non-finite values get stable spellings (nan, inf, -inf). Only one test golden changed (cbor's double_pi, now the full 3.141592653589793).
IR.** : f a ( bits_to_f32 b ) — or any f32 value bound/assigned to an f slot, or the reverse — fell through every branch of the store coercion (the never-valid-mix check fires only when exactly one side is a float) and emitted store double %float_val, which nurlc accepted and only clang rejected, with no source location. Float widths now follow the integer-width rule in the same helper: fpext on widen, fptrunc on narrow, at lets, = reassignment and result-field stores.
aggregate literal is now a source-located error** (previously invalid IR only clang caught). Enum-typed fields and single-pointer-handle structs still coerce legally. Also: : i T 5 now explains that T/F are the boolean literals.
lived as a scalar field on a by-value struct, so every minibatch after the first ran with a permanently inflated effective learning rate. Found by the anomaly package's bit-exact GPU parity mirror. Trained networks change versus 0.1.x; the sklearn oracle passes with 100 % flag agreement.
claimed bit-identity with a sequential host loop, but NVRTC's default fmad contraction fused s+=a*b — through tensor's silent ≥100k-flop GPU fast path, "CPU" results depended on whether a GPU was present. The kernels now spell their accumulation with explicit __d*_rn intrinsics (F32 kernels unchanged: their contract is true-float32, pinned by the verified model goldens); the gpu CPU backend gained the intrinsics and compiles with -ffp-contract=off, so the bit-exactness discipline holds on both backends.
"runs fine, silently produces nonsense" class. (1) The HF norm-name table was gemma3-centric: HF llama/qwen2 call the pre-FFN norm post_attention_layernorm, gemma3 uses that name for its extra attention-output norm — so a llama checkpoint's norms loaded as gemma norms and enabled a spurious per-layer normalization. (2) A genuine HF llama-family checkpoint stores rotary q/k lanes half-split while the NORM rope kernel rotates adjacent lanes; llama.cpp permutes at GGUF-conversion time, the --weights path never did. The loader now applies the converter's interleave at load. Every real HF llama/SmolLM/TinyLlama checkpoint through --weights was affected; both paths are pinned by the finetune merge round-trip (12/12 greedy).
dropped on a mid-frame timeout**, leaving its unread body in the socket and desyncing every following frame — the root cause behind large dataset blocks never arriving. A mid-frame timeout with partial data now retries (bounded); net_is_timeout joined std/net.
docs.nurl-lang.org site is now webdocs/ (Next.js + Fumadocs), replacing the old web-docs/ (VitePress), which has been removed. Deploys as a static export (next build → wrangler deploy), the same assets-only Cloudflare Worker pattern the old site used; search runs against a build-time Orama index queried client-side instead of a live API route. Known limitation: the scaffold's automatic markdown content-negotiation for LLM/agent requests (proxy.ts) had to be dropped, since Next.js middleware isn't supported under static export. /llms.txt, /llms-full.txt, and per-page markdown routes are unaffected and still directly fetchable.
The diffusion release: nurllama grows a whole non-autoregressive decode and serves LLaDA2.x — a 16B-A1B Mixture-of-Experts diffusion language model that converts from its Hugging Face checkpoint and answers its own model-card example correctly through the CLI, chat and the ollama API — on the back of a new streaming GGUF writer that converts a model larger than RAM at constant memory. The toolchain itself ships four owned-slice memory-safety fixes across control flow and a new invalid-IR diagnostic, so auto-drop is now correct through ?/?? arms, reassignments and moves. Plus the ecosystem's first trainable package (mlp), an autoencoder anomaly detector, and the distributed compute layer's data + iterate + failover + shuffle primitives (swarm-mcp 0.10 – 0.14).
nurllama serves LLaDA2.x (nurllama 0.9.0).
The engine grows a whole non-autoregressive decode: the llada2 architecture (a 16B-A1B Mixture-of-Experts diffusion LM) converts from its Hugging Face checkpoint and answers the model card's own example correctly through the CLI, chat and the ollama API. - nurllama convert <hf-dir> <out.gguf> [--type q8_0|f16|bf16|f32] turns a llada2_moe checkpoint (config.json + tokenizer.json + safetensors shards) into a GGUF following llama.cpp's llada2 conventions — fused attn_qkv left unpermuted, the 256 per-expert tensors fused into one 3D ffn_*_exps per projection, an F32 router and exp_probs_b, tokenizer.ggml.pre = bailingmoe2 — cross-checked key-for-key and shape-for-shape against the community LLaDA2.0 GGUF (interop both ways). The conversion streams: a 30 GB checkpoint converts to a 17 GB Q8_0 GGUF in ~2½ min at constant memory, on a machine smaller than the model. - The llada2 forward pass: bidirectional attention (a new causal flag in the attention kernels), a Mixture-of-Experts FFN (an fp32 sigmoid router with a selection-only bias and group-limited top-k picking 8 of 256 experts, evaluated as row ranges inside the still-quantised fused tensors, plus an always-on shared expert), partial NEOX rotary, and per-head Q/K norms. - The block-diffusion decode (src/diffuse.nu): the LLaDA2.1 "JointThreshold + editing" loop — parallel commits above a confidence threshold, revision of already-committed tokens, post-resolution passes, EOS early stop — with completed blocks freezing their K/V so a step costs O(window) where the reference recomputes the whole prefix. - The bailingmoe2 tokenizer pre-split (Ling 2.0 / LLaDA2), proven token-for-token equal to Hugging Face tokenizers on a multilingual battery, and the Bailing chat dialect (<role>HUMAN</role>…<|role_end|>) detected from the model's own template.
gguf 0.3.0 — a streaming writer and quantisation encoders. The gws_*
streaming writer declares every metadata key and tensor shape up front, then streams payloads to disk in chunks — constant memory, so a model conversion larger than RAM just works — and its output is byte-identical to the in-memory writer's. New gq_f16_encode / gq_bf16_encode / gq_q8_0_encode follow ggml's reference quantisers, verified against the dequant oracle. Selftest 40 → 50 checks. (The foundation nurllama convert streams through.)
mlp 0.1.0 — the ecosystem's first trainable neural-network package.
A seedable, deterministic MLP regressor faithful to sklearn's MLPRegressor recipe: Glorot-uniform init from std/rng (a fixed seed gives a bit-identical network on every platform), minibatch Adam with bias correction and L2, per-epoch seeded shuffles, and early stopping on a held-out split that restores the best weights seen. Everything else in the stack (onnx, embed, whisper, nurllama) runs inference; this trains.
anomaly 0.4.0 — the autoencoder detector. An explicitly-trained
reconstruction model over the new mlp package: a temporary Isolation Forest drops the ring's anomalies first (the AE must learn only normal behaviour), the survivors are MinMax-scaled, and an MLP autoencoder learns to reconstruct them; the threshold is the p95 of the training reconstruction errors. timevector also became a real, configurable-size sliding window.
swarm-mcp 0.10.0 – 0.14.0 — the distributed data + compute layer.
Content-addressed datasets (BLAKE3-256 1 MiB blocks, verified on arrival) so data moves once and is referenced by hash instead of re-shipped in every payload; compute_iterate, which runs a whole gradient-descent loop inside the coordinator (a distributed GPU vecreduce per round) instead of a round per language-model message; relay failover (a list of relays, workers rotate to the next and re-form when one dies — no single point of failure); and compute_shuffle, the group-by-key / reduce-by-key primitive, whose per-key reduce now runs distributed on the GPU as a MapReduce combiner rather than on the coordinator.
nurlc — owned-slice memory-safety fixes across control flow (four in
this family).** A : binding whose right-hand side is a ? ternary (#541), a ?? match, an = reassignment or a bare-identifier move (#543), or a ?/?? arm whose last statement is an assignment (this release) all mishandled owned-slice ownership — the value flowed a slice buffer through the join without the auto-drop machinery seeing it, leaking the buffer at function exit (and, for reassignment/move, risking a double free). Ownership provenance is now tracked through each of these constructs (__last_slice_owned__, reset at every statement boundary), the arm-local fall-through drop fires for void and plain-scalar arm values (a scalar is copied through the phi, so freeing arm-local buffers behind it can never dangle), and pointer/slice/aggregate arms stay conservative (leak-not-UAF). LSan-pinned regressions.
nurlc — a scalar bound into an aggregate target emitted invalid IR.
: ?T x ( vec_set … ) (where vec_set returns b) compiled to zext i1 … to { i1, %T } — nurlc accepted it (rc 0) and only clang rejected it, with no source location. The i1-widen path now requires an integer target, and the mismatch dies with the standard no-implicit-conversions message and the binding's source location.
nurllama — device out-of-memory is now a loud load error. A failed
cuMemAlloc during weight upload used to be silent: the load "succeeded", the affected tensors stayed zero on the device, and the model "ran" — emitting token 0 forever. Every device allocation in the loader now checks for a null pointer and fails the load naming the cause.
stdlib/net/relay.nu — a multi-megabyte frame was dropped mid-read on a
blocking relay client. __read_exact treated a recv timeout** (SO_RCVTIMEO, which the relay client sets deliberately) as a connection failure, so a frame whose body spanned more than one recv window was abandoned — and its unread bytes desynced the stream, turning every following frame into garbage. A timeout mid-frame now means "the rest is still coming" and is retried (bounded); a timeout with nothing yet read still reports "no frame available"; a real close/error still fails. New net_is_timeout predicate in stdlib/std/net.nu.
PR no longer waits on the full toolchain build.
-Wl,--as-needed — a binary a userdownloads from the playground runs without the container's libraries.
The one kernel library release: the ML stack's device layer is unified — gpukit's dev layer becomes the single dtype-generic kernel library that tensor, onnx and the new embedding server all run on (onnx's private kernel file is deleted outright, byte-identical outputs proving the move) — plus a Unigram tokenizer engine, a pure-NURL embedding server that is drop-in for a FastAPI/sentence-transformers service, three stdlib performance root-causes found by profiling real 250 k-piece vocabularies, and the documentation site going live at docs.nurl-lang.org.
utf8_decode_n (stdlib/std/utf8.nu) — utf8_decode with the
byte length supplied by the caller. The old shape re-ran strlen for every byte access (up to five per character), which made every UTF-8 scan quadratic in the string length; utf8_decode now runs strlen once and delegates, the module's own scan loops use the _n form, and nurl_str_get's O(strlen)-per-call cost is documented at its definition with the pointer-read idiom to use in loops.
CNN / transformer forward pass needs, lifted math-exact from onnx's proven f32 kernels and generalised over the element type: GK_I64 buffers with exact Vec i host views, N-D stride-broadcast elementwise (numpy broadcasting as a stride table, with a host-side proof the strides stay in bounds), batched matmul with per-operand batch broadcast, gather/scatter with ONNX index semantics, gemm (alpha/beta/transB/bias), conv2d / convtranspose2d / maxpool2d, batchnorm, layernorm, softmax over an interior axis, erf, clip, leakyrelu, axis concat/slice, N-D transpose, nearest resize, expand, L2 reduction, argmax, CLIP EOS read-out. Every wrapper validates buffers/dtypes/sizes and fails closed. gk_autosync/gk_sync let an executor chain hundreds of launches with one device sync at the end. 57 numpy-checked tests on CUDA and the CPU backend.
broadcasting on the elementwise ops (one stride-broadcast launch, no materialised expansion), dtensor_bmm with numpy batch broadcast, dtensor_gather/dtensor_scatter (host indices validated before the device is touched), dtensor_conv2d/dtensor_maxpool2d. One broadcast implementation shared between host and device tensors. 33 device checks vs numpy; f64 results bit-identical CUDA ↔ CPU.
tokenizer.json
Unigram models (XLM-RoBERTa / BGE / multilingual-e5). The sentencepiece Precompiled charsmap normalizer (a darts-clone double-array trie decoded straight from the file), Metaspace with HF's exact prepend rule, true Viterbi segmentation over piece log-probabilities with fused unknowns, added tokens with lstrip/rstrip semantics, TemplateProcessing specials — token-for-token identical to HF tokenizers on a 27-line multilingual corpus (committed golden).
XLM-RoBERTa-family model directory (config.json + tokenizer.json + f32 model.safetensors) and serves embeddings over HTTP, drop-in compatible with the reference FastAPI/sentence-transformers service (POST/GET /create_embedding, /health, Bearer auth with a constant-time compare, JSON errors, hardened single-worker serving). The forward pass is wired entirely from gpukit's gkd_* kernels — the package ships no kernel sources. Verified at cosine 1.0000000 against sentence-transformers per row, cosine 1.00000000 against the reference service container over HTTP, CPU ↔ CUDA cosine 1.0.
web-docs/, deployed
to docs.nurl-lang.org by the "Publish Webdocs" workflow (assets-only Cloudflare Worker; deploys on pushes touching web-docs/).
src/ops.nu (24 private f32 kernels + an
eagerly-compiled kernel struct) is deleted; the executor dispatches every node to gpukit's shared kernel library, compiles lazily through gpukit's in-process + on-disk caches, and chains the whole graph walk with a single device sync. Migration was gated on byte-identical model outputs at every step (yoloe det+seg, tinyyolov2, CLIP text encoder, promptable two-input) on both backends; wall-clock unchanged. The gkd validation layer also exposed a latent bug: the CLIP text-encoder export hardcodes [1,77,-1] reshapes, so token batches > 1 made the old executor read 3× past the out-proj weight — the new path fails closed with a diagnostic.
hash_string (stdlib/std/hashmap.nu) — djb2 → FNV-1a with amurmur-style avalanche finisher. The map masks the hash with a power of two, so only the low bits pick a slot — and djb2's low bits carry so little of a real key that 250 002 multilingual tokenizer pieces probed ~7× slower on insert (and ~20× on miss-heavy lookups) than synthetic benchmark keys. Net effect with the utf8/base64 fixes: loading that vocabulary went 3.4 s → 0.24 s and a 7 000-token encode 2.4 s → 0.13 s.
b64_decode was quadratic — it read every input byte through
nurl_str_get, which re-runs strlen per call; a 300 KB precompiled-charsmap blob took 2.1 s to decode. The loop reads through a raw pointer now: linear.
outputs-windows/ goldens
had not followed the __→_ rename wave (http_extras, http_proxy), the nurldoc multi-line-struct addition, or the hash_string change (hashmap_iter's arbitrary-order peek), leaving windows-tests red since before 0.19.0. Refreshed; only genuine platform differences remain.
The reachability release: search that survives contact with a real language-model caller (ranked boundaries, zero-hit widening, exact-name footers, honest package descriptions), build artifacts addressed by absolute URLs, one shared search engine for the playground and the local nurl-mcp — and playground deploys that reach the live container deterministically instead of waiting for an idle window.
stdlib/ext/mcp_search.nu — the MCP search surface (module API
view via nurldoc, declaration query with registry widening and exact-name footers, boundary-ranked grep, registry name+description search) extracted from nurlapi into a stdlib module with explicit path/registry parameters, so the playground API and the local nurl-mcp package share ONE implementation.
nurl-mcp 0.5.0 gains nurl_api and nurl_grep over the
installed stdlib + the package registry — the declaration view and package discovery for local, MCP-only editors. (Publish requires the toolchain release that carries ext/mcp_search.nu.)
download_urls (native,
windows, macos, cross-target and wasm — wasm keeps its base64 payload too): the base comes from NURL_PUBLIC_URL (or the already-deployed NURL_API_URL), so an MCP caller on another machine no longer has to guess which host a bare /download/… path belongs to. Unset env keeps the old relative form for same-host use.
nurl_api notes an exact package-name hit in one footer line
regardless of stdlib match count — query='http' returns hundreds of declarations AND "Note: the registry has a package named 'http' — the unified HTTP server interface…", so the package's existence never drowns.
description gets the
README's first paragraph as its searchable pitch (both the Worker and packages/registry), instead of an empty search row.
http 0.3.1: the manifest description had a 0.3.0 release noteprepended before the actual pitch — and the registry's 500-byte cap kept the note and dropped the pitch. The description is now the stable value proposition ("the unified HTTP server interface … anything that needs to SERVE HTTP"), which also lets a model correctly reject it for client-fetch tasks.
nurl_api widens a zero-hit query automatically: when no stdlibdeclaration matches, the same AND-terms are matched against example programs (file granularity, with each file's header blurb) and the package registry (per-term name/description search, de-duplicated) — in the same reply, so "is there anything about X" never needs a second call.
nurl_grep ranks word-boundary matches first, by default and with
no flags: lines where the pattern's neighbors are not letters (digits, underscore, punctuation and line edges all count as boundaries — mcp is clean in mcp_call, /mcp and mcp2, not in memcpy or -mcpu) come first, and in-word substring hits follow under a clearly-labeled tail with its own small byte budget. Plain-grep substring semantics are preserved — nothing matching is hidden, only ordered. word=true drops the in-word tail entirely (the filtered count is still reported).
The findability release: the MCP server answers "what's in this module", "when did this change" and "does a package for X exist" in kilobytes instead of context-flooding dumps — and the registry learns what its packages are about.
nurl_api — a stdlib module's API surface, or a search across
all of them.** module='ext/csv.nu' returns signatures, doc comments and full type definitions with no function bodies (11 KB where the source is 63 KB); query='csv quote' returns just the declarations whose signature/doc/module-path contain every term. Backed by nurldoc, which now omits __-private (file-scoped, uncallable) functions and renders a multi-line type declaration's full field list in a fenced code block — the /stdlib-docs pages get both fixes too.
nurl_grep — case-insensitive substring search across stdlib
sources, examples and compiler tests (path:line: text, per-file and total caps), plus the package registry by name AND description — "does a package for X exist" is one cheap call.
[package].description is extracted server-side from the published tarball on every publish (latest wins; never a client header), shown in the catalog, returned by /api/v1/search, and matched by it — in both the Cloudflare Worker (D1 migration 0003 + /api/v1/admin/desc-backfill for pre-existing packages) and packages/registry 0.3.0. nurlpkg search prints them.
nurl_changelog results are ranked, and every match stays
visible.** Entries whose TITLE carries the search terms outrank passing mentions; the top limit matches return in full and every further match appears as a one-line provenance+title (previously the newest N matches consumed the byte cap and the rest were an opaque "77 omitted" count). compact=true gives a titles-only overview.
The provenance release: the registry shows what a package contains and when every version shipped, nurlpkg learns to move requirements forward instead of making you hand-edit manifests, and one bad token inside a match arm no longer buries the real diagnostic under phantom errors.
nurlpkg update — move dependency requirements to the newest
versions.** Walks [dependencies] and offers each requirement the newest available version: registry deps follow the newest non-yanked published version, path deps the version in the dep's own local nurl.toml (that is the code you build against, and the publish gate requires the requirement to cover it — the registry may not carry that version yet). Each change is confirmed on stdin (y/N, default No — and piped/EOF stdin never mutates); --all (aliases -y/--yes) accepts everything, nurlpkg update <name>… limits the walk. A requirement the newest version already satisfies is left untouched, and edits are surgical: only the version value on the dep's line changes, so path = keys, formatting and comments survive.
listing.** Each version row on /packages/<name> carries its publish date — read from the authoritative server-side published_at recorded at publish since the registry's first day, never from client-controlled tar/gzip timestamps — and links to a new /packages/<name>/<version>/files page listing every file in the published tarball with its size (USTAR-prefix and GNU-longname paths reconstructed; immutable-cacheable, since versions are immutable). In both registries: the live Cloudflare Worker and packages/registry 0.2.0.
?/?? arm no longer poisons the rest of a
multi-error compile.** die's recovery panic unwinds out of the open function/arm symbol-table scopes; the per-declaration recovery frame now pops those leaked scopes before continuing. Previously gen_match's synthetic __matchtmp<n>__res_nurl_T payload keys survived, and — the label counter that numbers them resetting per function — a later same-numbered option match typed its payload with the dead declaration's struct, drowning the one real error under phantom cross-file type errors in modules compiled much later (diag_match_arm_recover pins the fix).
: i pub … explains itself. Naming a let binding pub now says theword is a reserved keyword (the visibility prefix on top-level declarations) instead of the bare "expected variable name in let".
add/remove leaked one trimmed String per manifest line
walked (the shared dep-line matchers returned through early exits), plus the initial empty buffer when reading nurl.toml. All line-machinery commands (add, remove, update) are now LeakSanitizer-clean.
The joins and guards release: one new language behaviour, three classes of silent failure turned into diagnostics that explain themselves, and a release pipeline that tells the truth about its own version.
?/?? joins.
?? m { T x → x F → 0 } over a ( Vec u ) — "the byte, or zero" — used to be impossible: the u arm and the i literal could not share a phi. Every integer arm now carries a 64-bit shadow extended by its OWN signedness (zext for unsigned and bool, sext for signed), so u 200 arrives as 200 — never −56 — and i −1 arrives as −1: the same number in a wider register, not a conversion. Same-width arms emit bit-for-bit the IR they always did. This is the join catching up with the stores and call sites, which have always bridged integer widths; the join refusing to was the language disagreeing with itself. Float↔int and pointer↔int stay errors — those WOULD change values, and NURL converts nothing implicitly.
NURL compilation unit is one flat namespace — __ is a convention, not a scope — and a second @ fn / : Type { … } / : ~ global from another file used to die inside LLVM ("invalid redefinition"), location-free in a 50k-line generated .ll, or worse: two generic functions sharing a name silently replaced each other's SOURCE, and two files sharing one mutable global by accident is the bug found a week later. All three now report duplicate <kind> '<name>' — already defined at <file>:<line>. A file imported by several importers stays legal (same-position re-registration is idempotent), and a generic beside a non-generic of the same name stays legal — the corpus relies on it.
?/?? arms
are genuinely incompatible (float beside int), the construct degrades to a statement and produces no value — and a cast or binding consuming it used to receive undef: the program printed whatever was in the register. Found live: a WebSocket PCM16 decoder decoded every audio sample to pointer garbage and the failure surfaced three layers away as a timeout. The degrade site now RECORDS why (the two arm types), and the consumer's error carries the reason and the fix.
-dirty. Three test goldens were
committed with CRLF in the blob while .gitattributes demands LF, so every fresh CI clone was "modified" before the build began, and git describe --dirty stamped every release accordingly. The blobs are renormalised, and the posix test runner now honours the r-normalisation contract the attributes always claimed (previously only the Windows runner did).
whisper serve holds
the model open (0.33 s per warm request where the CLI pays 1.15 s per invocation, distil-large-v3) behind whisper.cpp's own HTTP surface — and the SAME port speaks WebSocket: stream raw PCM16, get utterances back as {"text","t0","t1"} on the stream's clock, segmented by an adaptive-floor streaming VAD whose noise floor is the 10th percentile of the trailing minute. One port and a floor that tunes itself, where the whisper.cpp fork needs two ports, a fixed dB threshold and a pile of auto-settings heuristics.
A patch for the speech release's own hot path: the FFT that computes every whisper spectrogram frame.
stdlib/std/fft.nu — "not a power of two" is not one case. 0.14.0
sent every such length through Bluestein; but 400 = 2^4·5^2 is perfectly factorable, and Bluestein pays for TWO radix-2 transforms of length 1024 to produce one transform of length 400 — the fallback for hard lengths, applied to an easy one, 3000 times per 30 seconds of audio. Three paths now, picked by what N actually is: powers of two keep the radix-2 core, **smooth lengths (factors 2/3/5/7) get a direct mixed-radix Cooley-Tukey** with per-level twiddle tables built once in the plan, and Bluestein does only the job it is for — lengths with a large prime factor, where a "radix" would degenerate toward the O(n²) DFT it exists to avoid (997 stays on it, and stays exact).
fft_rfft folds a real signal of even length in half: 400 realsamples become a 200-point complex transform (`z[t] = x[2t] + i·x[2t+1]`) plus an O(n) untangle — the even samples' spectrum is conjugate-symmetric where the odd ones' is anti-symmetric, so they separate. Half the transform is half the work, whichever path the half-length takes.
(the STFT itself 288 → 99 ms). With the packages already on the registry riding this stdlib, distil-large-v3 transcribes a 292-second recording in 3.9 s — 1.3 s with --vad.
out** (3, 5, 7, alone and mixed: 105 = 3·5·7, 343 = 7³) with δ[1] cases — the check that catches a conjugated combine step, which round-trips perfectly while transforming wrongly. Forward transforms of pseudo-random signals at N = 400, 360, 105, 343 were verified against numpy.fft directly.
The speech release: NURL now transcribes real audio with real models, and the ecosystem grew the four packages that took it there.
stdlib/std/fft.nu — the discrete Fourier transform, for any
length. A radix-2 FFT covers powers of two; whisper's spectrogram wants n_fft = 400, which is not one, and padding to 512 computes a different transform rather than a rounder one. So arbitrary N is handled properly, by Bluestein's algorithm: relative error 1.9e-14 at N=400 and 7.3e-15 at the prime 997, against numpy — the arbitrary-length path is as accurate as the easy one. A plan precomputes the twiddles and the transformed chirp, so an STFT does not pay the setup per frame: whisper's whole 30-second mel spectrogram (3000 frames) takes 0.30 s. - The test suite pins the transform with identities, because its round trip passed while the forward transform was wrong: the inverse was wrong in the compensating way. δ[1] → e^(−2πik/N) pins the sign; ifft(fft(x)) == x pins nothing on its own.
nurlpkg publish refuses a package the installed toolchain cannot
build.** A package's $ \stdlib/…`` imports are resolved by whatever toolchain the USER has — so a package importing a stdlib file added since the last release publishes cleanly and then fails to install for everyone. It happened (packages/audio against this release's stdlib/std/fft.nu), and the gate now names the missing files and the toolchain that lacks them.
safetensor 0.1.0 — the container Hugging Face ships every modelin, parsed as hostile input: the header length is checked against the real file size before the JSON is looked at, every tensor's extent must land inside the data region and be exactly what its dtype and shape need, and element counts accumulate with an overflow check. Proven by a whole forward pass: gemma-3-270m from its checkpoint reproduces HF's own logits at r = 1.00000000.
tokenizer 0.1.0 — nurllama's SentencePiece and byte-level BPE
engines, lifted out and made loader-agnostic: a vocabulary can arrive from a GGUF's metadata or from HF's tokenizer.json. The HF oracle found a years-old bug: GPT-2's \s+(?!\S) gives the last space of a run back to the next token, and the default pre-tokenizer was swallowing it.
audio 0.1.0 — WAV (untrusted input), windowed-sinc resampling
(linear interpolation aliases the speech band), and whisper's log-mel to the constant — matching HF's WhisperFeatureExtractor at r = 1.00000000 on both 80 and 128 bands.
whisper 0.1.0 — speech recognition in pure NURL, from asafetensors checkpoint, on the GPU or the CPU. **Word for word what HF transformers produces*, on whisper-tiny and* distil-large-v3. LayerNorm rather than RMSNorm, error-function GELU rather than tanh, two conv1d layers, cross-attention, and the ecosystem's first non-causal attention — a speech encoder hears the whole clip at once.
nurllama 0.8.0 — the tokenizer engine now comes from the package;
its loader is the 128 lines that read tokenizer.ggml.*. Weights can also come from a safetensors file (--weights), which is how the safetensor reader is proven.
whisper transcribe cost 14.2 s on an
11-second clip — and the same 13.7 s whether it generated 1 token or 20. A flat cost is never the model: tokenizer.json was being read with json_obj_get per key, which scans linearly, so a 50 258-entry vocabulary was 2.5 billion string comparisons. 12.88 s → 0.05 s.
The local-LLM release. NURL now runs language models end to end — a hostile-input GGUF parser, a tokenizer read from the model's own metadata, a llama forward pass on GPU kernels that also run on the CPU, a content-addressed model store with resumable downloads, and an ollama-compatible API — all in pure NURL, all verified against independent references. The compiler, the stdlib and three packages each grew what that work proved they were missing.
stdlib/std/progress.nu (new module) — a throttled, width-fitted
transfer bar on stderr that stays **completely silent when stderr is not a tty**, so CI logs never fill with carriage returns. progress_human is a pure, unit-testable byte formatter.
stdlib/std/fs.nu: handle-based writes and seeking —
file_create / file_append / file_write_chunk (binary-safe; a short write is an error, never silent truncation) / file_flush, plus file_seek / file_tell / file_read_at with FS_SEEK_SET/CUR/END. An output far larger than RAM can now be emitted, and a broken transfer resumed.
sha256_init/update/final and
blake3_init/update/final. BLAKE3 grows the real chunk tree as bytes arrive (a buffered chunk is only compressed once a later byte proves it non-final), so any update pattern reproduces the one-shot's tree. Both one-shots are rebuilt as init/update/final compositions, so the streaming and whole-buffer paths cannot drift.
stdlib/std/floatbits.nu: f16 / bf16 ↔ f32, both directions —widening is exact (subnormals, ±inf, NaN payloads, −0), narrowing rounds to nearest-even. Proven by an **exhaustive 65 536-pattern round-trip identity** for both formats.
stdlib/std/term.nu: term_width / term_height — TIOCGWINSZ
(new nurl_native_constant entry) with a $COLUMNS/$LINES → 80×24 fallback.
server_set_stream, exposed on the
http package facade as http_app_stream: the write-side sibling of the WebSocket upgrade hook, for chunked / SSE / NDJSON bodies. The chunked writers existed at the low level, but HttpApp's request→HttpResponse handler type had no seam for a token-by-token body.
rule 5). A slice literal :-bound inside a ? / ?? / ~ / foreach arm leaked: __owned_slices__ is name-keyed and scope-shadowed, so the arm's delta died unseen at nurl_sym_pop. Phase 2D now has a slice counterpart driven by a __slice_decls__ sideband (only bindings declared in the arm — freeing an = outer [ … ] target would be a use-after-free), and the same family's rule-3 gap is closed: = xs [ … ] now frees the old backing buffer.
bootstrap snapshot predated the 0.12.0 self-compile work, so stage 1 still ran the 13.6 GB-era compiler under ASan — right against the 16 GB runner, where it died as a silent Terminated. Refreshing nurlc_lastgood takes the sanitized build's peak from **17.2 GB to 0.47 GB**; the build-stage ASAN_OPTIONS were tuned as well.
gguf 0.2.0 (new) — read, verify, write and dequantise the GGUF
container. mmap-lazy and hostile-input-safe: every count, length and offset is validated against the real file size before anything is allocated. Dequantisation covers F32/F64/F16/BF16, Q4_0/Q4_1/Q5_0/Q5_1/ Q8_0 and the K-quants Q4_K/Q5_K/Q6_K — verified bit-identical against an independent Python decoder on real llama.cpp models — and gguf_dequant_range reads one row of a multi-gigabyte tensor without expanding it.
nurllama 0.1.0 (new) — run language models locally:
pull (resumable, content-addressed store) · run · chat · serve (ollama-compatible NDJSON API). Weights stay **quantised on the device** — the matvec kernels decode GGUF blocks inside the matmul (a Q4_K_M model needs ~3× less device memory than its f32 expansion) — and the same kernel sources run on the CPU backend byte-identically. Token IDs are checked against an independent SentencePiece implementation, logits and greedy text against an independent numpy forward pass.
gpu 0.5.0 — compiled kernels are cached on disk, keyed by the
BLAKE3 hash of the source, so a process start costs a file read instead of an NVRTC / C++ compile (a dozen-kernel package starts ~3× faster; NURL_GPU_CACHE=off disables it). Two CPU-backend bugs that banned __device__ helper functions are fixed: __device__ is an execution-space qualifier, not a storage class, and the parameter scanner now finds the entry kernel by name instead of taking the source's first parenthesised list.
The release that finishes the clean-room self-critique started in 0.11.3. It closes the compiler's existential scaling wall (**self-compile 13.6 GB → 366 MB), the ecosystem's account-takeover surface (registry token expiry + scoped tokens + typosquat guard**), the playground's abuse surface (non-root, timeouts, rate limits), a real --debug compiler crash, and a documentation-accuracy sweep that made every user-facing claim verifiable — plus the two remaining structural critique items (the fused-compiler map, the safe-code safety-hole statement) and a first real Windows CI corpus.
3.0 s instead of 10.1 s). Instrumented per-site allocation counters attributed 11.9 GB of the peak to one function — the borrow checker's per-binding state lookup, which re-sliced the remainder of its state string per token (O(n²) bytes, 14.8 M calls per self-compile). The state-map operations now walk by index and build their output in one exact-size buffer; the state format and every diagnostic are unchanged (old and new compiler emit byte-identical IR). CI gates the number with tools/memgate.sh (600 MB budget) so it cannot silently regress.
std/random.nu draws fail closed. rand_u64 / rand_hex_str now
check nurl_rand_fill's return and panic when every OS entropy source failed (the runtime would otherwise degrade to a non-cryptographic LCG) — the same contract the tls/rsa/x509 draws already enforced.
against the source, workflows, installers, and release assets: a platform-support tier model replaces contradictory "fully supported" claims (tiers defined by what CI actually verifies), docs/ASYNC.md was rewritten from a stale design plan to the shipped runtime, and roughly 95 stale references, wrong numbers, and marketing/past-version notes were corrected across the tree.
POST /api/v1/token/new mints per-package scoped CI tokens; new package names pass reserved-name and typosquat checks (normalised lookalikes and edit-distance ≤ 1 of someone else's package are rejected); publish / token-mint / search are rate-limited.
nurlapi container runs as a
non-root user; every build-tool invocation runs under timeout(1); build routes are rate-limited per client IP with an explicit body cap; the build-output directory is TTL-swept and count-capped.
nurlpkg install <library> works in a plain folder — no nurl.toml
required.** A library package (no src/main.nu) now lands under ./deps/<name> with its transitive registry deps, instead of erroring; when a nurl.toml is present the dependency is recorded there too. Program packages still build + install onto $PATH unchanged.
--strict-borrowck closes the conditional double-free hole. A value
freed on one arm of a ? and freed again unconditionally — a real double-free on the path where the first free ran — is now an error under the strict checker (its third opt-in check). The default checker still deliberately allows it to keep the no-false-positive property; docs/MEMORY.md §2.9/§6.5 state the trade both ways.
std/net.nu gains tcp_connect — the plain-TCP client connect,
returning !TcpConn NetErr like its TLS siblings. The MQTT module's private duplicate (which returned MqttErr) is gone.
docs/dev/COMPILER_INTERNALS.md — the map of the fused walk:
pipeline order, every process-global table with its writers (appendix generated from source by tools/gen_globals_map.py), the invariants that bite, and the safe-change checklist.
docs/MEMORY.md §6.5 now states every way safe-looking code can stillfail, together, with the accurate one-line safety claim to quote instead of "memory-safe like Rust".
nurl.sh --debug no longer crashes clang at -O2. A larger
multi-file program built with debug info crashed clang in DwarfDebug::finalizeModuleInfo: three kinds of inlinable call (the indirect closure call, the __jdrop_* thunk's drop call, and the C-ABI main wrapper's call) were emitted without a !dbg location, so the optimizer reparented a debug-info callee into the wrong subprogram. All synthetic functions now carry a subprogram and their inlinable calls carry !dbg; non-debug IR is byte-identical.
url_parse rejects out-of-range ports instead of wrapping — a portpast 65535 silently wrapped into a plausible small number and parsed as a valid URL pointing at the wrong port.
REG_SIGN_KEYID) — signing witha non-default key (self-hosted registry, local tests) used to embed the production keyid and produce signatures every client rejects.
windows-latest runner
(the windows-tests workflow), on every push to main — the 447 Windows goldens were previously exercised only locally. build.bat now exits non-zero when it skips the test suite (a missing PowerShell 7 used to print success), so an untested build can't masquerade as a tested one.
--debug, so tools/dwarf_test.sh (the -O2 closure/Drop regression plus gdb source-level checks) is now wired into CI.
A hardening & supply-chain release. A clean-room self-critique of the whole project — compiler, language, libraries, toolchain, ecosystem — was turned into fixes, one focused change at a time. The headline is **end-to-end package signing**: the registry now signs every published tarball with a project Ed25519 key and nurlpkg verifies the detached signature with a pure-NURL minisign implementation before unpacking, so a compromised CDN can no longer substitute bytes. Around it: fail-closed installers, integer/type safety fixes in the compiler, a diagnostics overhaul (multi-error reporting, "did you mean", unified borrow-checker rendering), a resolver correctness fix, and new CI safety gates (leak surface, symbol collisions, a second fuzzer).
signs every published tarball with a project Ed25519 key (minisign legacy Ed format — a raw signature over the .tar.gz bytes); nurlpkg pins the matching public key and verifies the detached .minisig before unpacking. No signature, no install (PkgBadSig). The SHA-256 checksum still runs first, but it defended integrity given an authentic index — it no longer stands alone. A $NURL_REGISTRY_PUBKEY trust-anchor override supports self-hosted registries. New stdlib primitives make this possible without any C dependency: pure-NURL BLAKE2b-512 (RFC 7693, std/hash_blake2b) and a pure-NURL minisign verifier (std/minisign) on the existing Ed25519. The registry gained an idempotent backfill so packages published before signing existed are signed without stranding older clients.
release.yml signs each release archive
with minisign, and install.sh / install.ps1 verify the detached signature against a pinned key. This layer is opportunistic — the toolchain can't assume minisign is present at first-run, so HTTPS + a fail-closed checksum stay the root of trust there; the pure-NURL verifier covers the package layer, where nurl is already installed.
curl | sh installer no longer fails open. Both installers verify
fail-closed: a missing, empty, or malformed checksum — or any mismatch — aborts; --insecure / NURL_INSTALL_INSECURE=1 is the explicit opt-out. The rm -rf "$PREFIX" step is guarded so it refuses $HOME / / / %USERPROFILE% and any non-empty directory that isn't a prior NURL install.
instead of executing undefined behaviour (previously a raw sdiv / srem).
hostile input can no longer exhaust the stack.
nurlc). Reports **multiple errors per
run instead of aborting on the first; prints "did you mean …?"** suggestions for unknown functions and identifiers; routes internal invariant violations through a labelled internal-compiler-error path instead of a bare crash; renders borrow-checker diagnostics through the same source-caret renderer as front-end errors; and adds --help / -h.
expect, unwrap,
unwrap_or, or_else, and Option↔Result bridges for terminal error handling.
tools/fuzz) over the DER/X.509 andformat parsers, plus a weekly CI workflow running it alongside the existing differential fuzzer.
taking the first requirement seen — a dependency constrained as both ^0.2 and ^0.2.3 now resolves against the intersection.
nurlc rejects pointer↔scalar call-argument mismatches — passing a *T
where a value T is expected (or vice versa) is now a call-site error.
not the line after it.
DEBUG: prints from nurlc.
guides, and documented the pre-commit nurlfmt hook accurately.
anomaly 0.3.7 and yoloe-demo 0.2.3 depend on http ^0.2 (the
^0.1 caret-lock never picked up the 0.2.0 leak fixes).
% Drop leak — a value with a Drop impl created inside a
while-loop body was never dropped at the end of each iteration (surfaced by the new leak gate). The compiler now reclaims it at the loop-scope boundary.
can no longer ship from a SHA whose ci.yml did not pass.
check, and a symbol-collision gate that fails the build when two stdlib helpers mangle to the same symbol (two real collisions were found and fixed).
An infrastructure-dogfood release. The package registry itself is now a NURL program — packages/registry re-implements reg.nurl-lang.org in pure NURL on three of its own registry packages and is proven end-to-end against the real nurlpkg client. Around it: the installed-tools story hardened (embedded assets, an [install] assets mechanism, dependency-range and installer fixes — a registry install of a tool now just works), the WebGPU backend got 2–4× faster, the generation-accuracy study measured the repair loop (one diagnostic round → exact Python/Rust parity), and the LSan pass over the new registry found a long-standing compiler leak.
packages/registry 0.1.0 — the NURL package registry, served by NURL
itself (critic C8).** A self-hostable registry speaking exactly the wire protocol nurlpkg drives: /index/<name>.json + content-addressed tarballs on the read side; bearer-authenticated POST /api/v1/publish (server-side SHA-256, first-publisher name ownership, version immutability), yank/unyank/revoke on the write side; search/stats JSON; and a server-rendered catalog UI whose package pages render the README straight out of the published tarball (relative image links rewritten onto a version-pinned, CSP-sandboxed /files/ asset route). SQLite is the single source of truth — the index JSON is rendered from it on demand, removing the dual-write consistency hazard of the Cloudflare Worker it replaces. Tokens are peppered-SHA-256 hashes at rest, minted locally (registry token new) or via the config-gated GitHub OAuth flow. Built on the http, template and md2html packages. Proven by 34 socketless router-level wire tests (ASan/LSan-clean) and an end-to-end script that drives the real `nurlpkg publish → search → install → yank` against it, dependency resolution included.
nurlpkg: [install] assets — runtime data files for installed
tools.** A registry install used to ship only the compiled binary, so a tool with data files (anomaly's dashboard HTML) silently lost them. The manifest gains an optional [install] assets = [...] array; `nurlpkg install stages each entry into <prefix>/share/<name>/` (the relocatable <exe-dir>/../share/<name> convention tools already probe), clearing it first so upgrades leave no stale files. Asset paths are validated (no absolute, no ..) — registry manifests are untrusted input. anomaly 0.3.6 declares assets = ["static"], so a registry-installed anomaly serve now serves its dashboard with no --webroot flag.
bench/genacc: the repair loop, measured — the v3 report's missing
half. repair.py gives a failing generated program ONE round of compiler-stderr feedback (the model sees only its own code and the diagnostic, never the expected output): **Sonnet 80→100/100 and Opus 70→100/100 — exact Python/Rust parity**; Haiku reaches 100% compile; the mercury-2 diffusion model quadruples its score. Auditing every v3 failure also closed two frontend holes, now hard errors with fix hints: ! on a non-b operand (used to emit invalid xor IR and surface a raw LLVM error) and an & FFI declaration written inside a function body (used to die with a misleading type-mismatch message).
stdlib/ext/sqlite.nu: sqlite_changes — rows changed by the most
recent INSERT/UPDATE/DELETE on the connection; previously unreachable for statements run through prepare/step (only sqlite_exec returned it).
combinations that turn SwiftShader into the actual GPU.
gpu WebGPU backend: 2–4× faster WGSL inference. Four per-launch /
per-element taxes removed: kernel launches now accumulate into a single command encoder submitted only when results must be observed (a browser pays an IPC round-trip per queue.submit; a YOLOE frame paid it ~380×); conv2d computes 4×2 outputs per invocation with constant-unrolled k3s1/k3s2/k1s1 fast lanes (66 → 269 GFLOPS on the yolov8s shape mix); the mask-proto convtranspose2d decodes its single contributing tap from output parity instead of scanning the kernel window; bind groups are cached.
^ ( f … x … ) no longer cancels the argument's auto-drop.
gen_ret derived the escaping binding from the last-identifier channel, which for a direct-call return holds the call's last argument — so returning a call that takes an owned string / % Drop value / owned-field struct as an argument leaked it on every such return (the callee never takes ownership of its arguments). The skip now keys on whether the returned value can alias the binding: bare-identifier returns keep the ownership transfer, call returns drop their arguments unless the callee is summarised ret-borrow (then the skip stays — dropping the source would dangle the returned alias). The → v fall-off path gets the same guard: nothing escapes a void return, so a stale last-ident can no longer cancel a Drop-value's drop there. Lock: compiler/tests/ret_call_arg_drop.nu; bootstrap refreshed (the fix reclaims buffers inside the compiler itself).
encode_str — the LLVM-IR c"…" encoder — recursed once per character, so a literal past ~124 KB (≈48 KB under a zig -O2 -flto release build) was a stack overflow, plus two heap allocations per character. Rewritten iteratively with a single worst-case output buffer; literal size is now bounded by memory only (verified through 2 MB). Lock: big_string_literal.nu (160 KB literal, position-weighted checksum).
net: a large response no longer aborts because the client reads it
slowly.** nurl_tcp_set_timeout programs both SO_RCVTIMEO and SO_SNDTIMEO; during a big write to a slow-but-alive client one blocking send() window can pass with the socket buffer still full, and that EAGAIN was treated as fatal — a phone fetching a 54 MB model over LAN TLS died mid-body while the server logged a 200. A send timeout is only a dead peer when there is NO progress across consecutive windows: zero-progress send timeouts now retry up to twice (blocking sockets only — the fiber reactor's park-on-EAGAIN contract is untouched), and any successful write resets the allowance. A failed response write is also now logged with its NetErr name instead of vanishing.
packages/http 0.2.0 — the HttpApp facade now exposes the full
server knob set (http_app_body_max / head_max / max_keepalive / request_timeout over server_new_complete), and two per-request leaks are gone: the pre-allocated panic-500 placeholder response now freed on the success path, and the dispatch/middleware closure envs released after the server returns. The same placeholder leak is fixed in the playground server (nurlapi/main.nu).
nurlpkg install
ships only the built binary, so relative asset paths can never resolve on the host: yoloe 0.6.2/0.6.3 compile the 512 KB CLIP BPE merge table into the binary (tokenizer_load_builtin; emitted as ~32 KB chunks so the RELEASED v0.11.1 compiler — which still has the recursive string encoder — can build the package too), and yoloe-demo 0.2.2 embeds its page template the same way. --merges FILE remains as an override.
get-nurl.sh / get-nurl.ps1 deleted the whole prefix before unpacking — including $NURL_HOME/credentials, silently logging the user out of the registry on every toolchain upgrade. User data now survives.
manifests had drifted behind their published dependencies (caret on 0.x locks the minor, so e.g. onnx ^0.4.2 resolved a version missing the ops the code was built against, and anomaly 0.3.4's gpu ^0.4 + gpukit ^0.2 pair was unsatisfiable) — local path-deps hid all of it. Versions bumped and ranges synced (anomaly 0.3.5).
A neural-nets-in-the-browser release. Real object detection and open-vocabulary segmentation now run entirely in a web page — pure NURL compiled to WebAssembly, with the ONNX runtime executing either on the CPU (precompiled kernels) or on the visitor's GPU via a new WebGPU backend (the CUDA-C kernels translated to WGSL compute shaders). No server inference, no Python, no OpenCV, no inference engine. This is carried by two new demo packages (yoloe-demo, and the playground's objdet page), two new gpu backends (static + WebGPU), and the toolchain work that made in-browser GPU compute possible — plus a template engine, TLS hardening, and a CI fix.
packages/gpu 0.4.0 — two new backends: static kernels and WebGPU.
Beyond CUDA (NVRTC) and the runtime-compiled CPU backend, gpu gains a static-kernel backend (backend 2): a generated kernels_static.c precompiles every kernel and registers them under nurl_static_kernel(name) (a weak NULL stub in the core runtime keeps every other link working), so a model runs with **no NVRTC, no system C++ compiler, no dlopen** — the backend a wasm module or a sealed native binary needs. Select with NURL_GPU=static or ( gpu_force_static ). And a WebGPU backend (backend 3): the ONNX kernels, pre-translated to WGSL (web/kernels_wgsl.js), run through navigator.gpu (browser or Deno) driven by host imports the JS embedder implements (web/webgpu.js). Device memory is a GPUBuffer; the one async op — the mapAsync grid readback — is bridged with Asyncify so the synchronous NURL gpu_download returns data in wasm memory. Every WGSL kernel is verified on a real GPU against a JS reference of the CUDA-C (25/25 via Deno's headless WebGPU); the full YOLOE detector forward matches onnxruntime within tolerance (max abs err 0.02).
packages/yoloe-demo 0.1.0 — live YOLOE in the browser, served by pure
NURL.** Open a page, start the webcam, and watch open-vocabulary detection + instance segmentation stream back. Three compute engines: the server GPU (frames posted to a pure-NURL HTTP/TLS server), **this browser · wasm (CPU), and this browser · WebGPU** — one wasm module picks the backend at runtime. Free-text prompting: type any class (coffee mug, a person waving), the CLIP BPE tokenizer + MobileCLIP text encoder run on the GPU (all pure NURL), and the next frame detects it. The page template is rendered by packages/template; --tls mints a self-signed P-256 cert with std/x509_gen.
packages/template 0.1.0 — an HTML template engine over stdlib Json.
Jinja-flavoured {{ vars }} (HTML-escaped by default) with raw/upper/lower/length/json filters, {% if/elif/else %}, {% for %} with loop.index/first/last/length, {% include %} partials from a named TplSet, and {# comments #}. Missing keys render empty and are falsy (mustache-lenient); malformed tags are hard errors with line/col positions. Ships the fs-free engine, a directory loader, and a CLI. ASan/LSan-clean.
packages/onnx 0.6.0 — static-kernel generator + graph-executor
completeness.** tools/gen_static_kernels.nu emits the precompiled kernel set for the gpu static/WebGPU backends. The graph executor grows the ops a browser-shaped ONNX graph (torch 2.12 / onnxsim) needs: Slice (single axis), a real ArgMax (with an int64 kernel for token inputs), the pb parser reads TensorProto.int64_data (packed varints), optional-input Clip no longer clamps everything to zero, and Concat/Gather normalise negative axes.
/objdetdemo). Tiny-YOLOv2 object
detection running on the visitor's GPU as WGSL compute shaders — the lighter demo (single image input, no prompting/masks, one model fetched once from the ONNX model zoo). Runs on the main thread (no worker, no SharedArrayBuffer) via a generic asyncImport() so host_frame awaits the next camera frame through the same Asyncify path as the GPU readback.
packages/wasmbuilder 0.1.2 — WebGPU-ready wasm builds. --obj
accepts several space-separated objects (a module can link both the static kernels and the asyncify stack); --cflags passes extra flags (e.g. -msimd128); --asyncify-imports names the async host imports (asyncify was canvas-only); and the asyncify pass strips DWARF first — `wasm-opt --asyncify` aborts on the debug line-tables wasi-sdk/zig objects carry.
packages/image 0.4.1 — internal JPEG helpers renamed __jp_* →
__jpg_*, resolving a cross-package symbol collision with ext/json.nu's parser (one flat fn namespace) that surfaced the first time both were imported together.
session where possible; the registry renders the [package].repository link and nurlpkg gained [hints].postinstall.
reg.nurl-lang.org: gpu 0.4.0, onnx 0.6.0, wasmbuilder
0.1.2, image 0.4.1, template 0.1.0.
(u/u16/i32, …) called with an i64 value — any bare literal or a plain i variable — emitted call @f(i64 …) against define @f(i8 …). The native ABI masks the mismatch, but on wasm32 the exact-signature rule makes wasm-ld emit a trapping _bitcast_invalid stub — YOLOE-in-the-browser died in image_new's ( vec_push [u] d 0 ). gen_call now trunc/extends fixed-position arguments against the declared parameter (generics substitute the call's type args first). Lock callarg_width.nu.
packages/wasmbuilder: working env imports. The #99 attribute group
carried only wasm-import-module, which wasm-ld still rejects as undefined; the per-symbol wasm-import-name attribute is what marks an explicit import — so undefined & c symbols never actually linked through the local zig path (the container pipeline had masked this with -Wl,--allow-undefined).
record caps its plaintext at 2^14 bytes (and past 65535 the u16 length wraps), so responses over ~16 KB (fatally, over 64 KB) failed OpenSSL clients with "bad record mac" — found live by the demo's ~90 KB HTTPS responses. tls_write/tls_server_write now split into ≤16384-byte records, and tls_close's close_notify is sent under the server keys (it was encrypting with the client-direction keys). A failed TLS handshake no longer takes the whole HTTP server down (it is a per-connection event).
~13.2 GB and crossed the guest's 13.3 GB budget (OOM → SSH drop → exit 255). The width-coercion emitter is now factored into one shared helper (dropping the peak) and the VM memory was raised to track the real peak.
A types-and-ecosystem release. In the compiler, signedness now lives in the type representation itself — the flag side-channel behind a whole class of silent miscompiles is gone (critic A1, the last 1.0-lock compiler item) — and diamond package dependencies compile once. Around the compiler, the package ecosystem takes a leap: pure-NURL image codecs (image), an n-dimensional tensor package over the GPU stack (tensor + gpukit, with a zero-copy bridge into onnx), a declarative command-line framework (cli) and a unified HTTP server facade (http) — plus npm-identical dependency requirements, MCP session completeness, and a sweep of backlog fixes.
The internal type language now keeps u/u16/u32/u64 distinct from i8..i64 end to end (as internal types u8/u16/u32/u64) — through the last-type channel, binding/param/field/payload/return metadata, ?/?? join types, and generic monomorph mangles — lowering to the LLVM i-types only at the IR-emission boundary (nurl_llty). The __last_unsigned__ flag machinery (the side-channel behind 11 fuzzer-found silent miscompiles, later tamed by the last-type coupling) is deleted entirely: no g_last_unsigned_p, no __unsigned/__ret_unsigned syms keys, no __last_nurl_type__ parse side-channel — a value's signedness can no longer go stale or be forgotten at a producing site. Fixes that fell out of the repr: struct→int casts zero-extend an unsigned field 0 (was always sext), FFI fixed-position argument widening zero-extends unsigned args (was always sext), pointer-to-unsigned generic arguments ([ *u64 ] vs [ *i ]) monomorphise separately, and DWARF renders u/u16/u32/u64 as DW_ATE_unsigned while signed i8 is no longer mislabeled u8. New regression signedness_in_types.nu; validated by the bootstrap fixed point, the full suite, the examples gate, and a clean differential-fuzzer run.
packages/gpu 0.3.0 — shared primary CUDA context, modern _v2
driver ABI, gpu_dtod.** gpu_open now retains the device's PRIMARY context (cuDevicePrimaryCtxRetain + cuCtxSetCurrent) instead of creating a private one — every consumer in the process shares one context and one device address space, so device pointers flow between packages (a gpukit buffer straight into an onnx graph); gpu_close releases the retain. All memory/copy/context entry points now bind the 64-bit _v2 driver symbols — the unversioned names are the legacy pre-CUDA-3.2 ABI with 32-bit device addresses, which only happened to work inside a legacy cuCtxCreate context and silently break with 64-bit primary-context addresses (a live 4 GB landmine either way). New gpu_dtod: device→device copy from a raw device pointer (cuMemcpyDtoD_v2; plain memcpy on the CPU backend) — the primitive the tensor↔onnx bridge builds on. gpu/gpukit/tensor suites green on both backends; yoloe's 267-node segmentation forward stays byte-identical.
packages/image.** Both packages delete their private PPM/resize/ draw code and keep only a thin adapter (yoloe's ultralytics-matching letterbox and NCHW packing stay local), so they take photos and write annotated/mask-overlay PNGs and JPEGs directly — no ImageMagick shell-out. Equivalence proven: old-vs-new preprocessing tensors are bit-identical on real photos (objdet's 3×416×416 NCHW; yoloe's letterbox(640), 0 float mismatches), and a live GPU seg run on a real JPEG matches the previously verified result. Also fixed along the way: objdet's voc_class returned a borrow into a per-call Vec (real leak; suite is now ASan-clean), and yoloe cam's v4l2 YUYV group mask tripped the newer &-type check (widened before masking).
stdlib/ext/semver.nu's requirement engine rewritten as an OR of half-open intervals: a bare fully-specified version is now EXACT (was Cargo-style caret), and X-ranges (*/1/1.2), ^, ~, comparators, space-separated AND, hyphen ranges and || OR all match npm — verified against a table of npm cases, ASan-clean. The resolver/registry-index API (semver_req_parse/matches/free, semver_req_max_satisfying) is unchanged, so nurlpkg keeps building; every inter-package dependency in packages/*/nurl.toml switched to ^ to preserve the previous newest-compatible intent, and packages/README.md documents the full syntax table plus the convention (depend with ^, bump minor on a 0.x breaking change).
serve --http mounts the stdlib Streamable-HTTP
facade.** The hand-rolled single-POST handler (manual CORS, no batches, 405 on GET, no session-id echo) is deleted; nurl-mcp's own layer shrinks to bearer-token auth wrapped around ext/mcp_http.nu — the same plumbing swarm-mcp serves through (OPTIONS preflight bypasses auth, as browsers send no headers on preflight). Free from the facade: JSON-RPC batches, GET /mcp SSE probe, Mcp-Session-Id echo, DELETE /mcp, spec CORS. Verified live (401 without bearer, preflight, initialize, batch, SSE content-type, session-id round-trip); read-only tool gating holds and stdio is untouched.
once.** The import seen-tables keyed on the resolved path STRING, so the same file reached through two different-but-equivalent paths compiled twice and every symbol in it collided (`invalid redefinition). Packages hit this through symlinked deps/` chains — onnx importing tensor (whose dev.nu imports deps/gpukit → deps/gpu) alongside its own deps/gpu is a diamond. __canon_import_key (realpath(3)) now canonicalises the dedup key at all four seen-table sites; the as-written path stays in use for reading and diagnostics, so error messages and goldens are unchanged. New regression import_diamond.nu; two-step bootstrap, fixed point re-established.
→ is no longer type context. The formatter
glued an operator starting a braceless arm body to its operand (? flag x 0 → ?flag x 0, * n 2 → *n 2, ? != … → ?!=), making ternary/multiply/not/modulo read as type sigils in canonical output. Declaration headers (@/\ … {) are now the only type context for arrows; return-type gluing (→ ?i) is unchanged. The 14 covered files carrying the old glued spelling were reformatted; lock fmt_arm_arrow_ops.nu; both fmt gates green (777 files canonical, idempotence + IR-equivalence).
!DIFile. Imported functions
and monomorphised stdlib generics debug-attributed to the top-level file (right line, wrong file). Subprograms now carry the !DIFile of their defining source — generics map through the template's recorded path. vec_push__u8 renders as ./stdlib/core/vec.nu:203 in llvm-dwarfdump; non-debug IR is byte-identical and the bootstrap fixed point holds.
serverInfo.version no longer hardcoded (was 0.7.0).packages/tensor 0.1.0→0.3.0 — the reusable n-dimensional array
the ML ecosystem lacked, over gpukit (M1–M3).** Tensor (row-major, f64 compute; an F32 dtype keeps values on the float32 grid): creation / reshape / transpose / permute, numpy-rule broadcasting elementwise and scalar ops, unary maps, 2-D matmul (GPU via gpukit for large problems, identical results), and axis/global reductions (M1); N-D batched matmul with batch-dim broadcasting, numerically stable softmax, per-dimension half-open slicing, concat, argmax/argmin (M2); and DTensor (M3) — tensor data lives in GPU memory and ops chain on the device with no host roundtrips (upload weights once, stream activations), residency explicit via tensor_to_device / dtensor_to_host, with TE_F32 DTensors computing IN float32 (accumulation included) to match numpy-float32/onnxruntime semantics. Verified against numpy on CUDA (RTX 4090) and the gpu package's CPU backend — 17/17 + 25/25 + 12/12 per dtype, ASan-clean.
packages/gpukit 0.1.0→0.3.1 — diamond GPU-compute toolkit over
the gpu package.** Kills the ~35-line marshalling dance every GPU-using package re-invents: typed GkArg bindings and one gk_run (per-name kernel cache; alloc, upload, marshal, launch, sync, download, free) plus ready-made f64 kernels — adding no numerics of its own, so a kernel runs bit-for-bit the same as through hand-written gpu_* calls. 0.2.0 (shaken out by the anomaly dogfood): gk_open returns a heap *GpuKit for singleton use, plus gk_compile to warm the cache at setup. 0.3.0: the device-resident layer — element-typed GkBuf allocations (GK_F32|GK_F64) with staging upload/download conversion, gk_run_dev (cached-compile launch over raw device args, zero marshalling), and dtype-generic gkd_* kernels with true-float32 GK_F32 semantics; 12/12 vs numpy per dtype. 0.3.1 republishes the manifest with gpu ^0.3 (the published ^0.2 was disjoint from onnx 0.5.0's range, letting a resolver install two gpu versions side by side).
packages/onnx 0.5.0 — tensor↔onnx zero-copy bridge (M4a) + engine
memory ownership.** src/tensor_bridge.nu is the seam between tensor's DTensor and the graph runtime, both living in the shared primary context: rt_run_dtensor runs a graph with a device-resident input — no host staging, no upload; the input is borrowed, never freed — and dtensor_from_output wraps a run's output as an OWNED DTensor via a device-to-device copy (outputs may alias an owned base at an offset — Reshape/Split — so ownership transfer would corrupt the heap). Device-input output is bit-identical to the host path and matches the onnxruntime reference; ASan-clean. ASan-gating the bridge also fixed a family of pre-existing engine leaks: rt_reset freed device buffers with cuda_free directly (every activation leaked on the CPU backend; now gpu_free), RTensor shapes were never freed and 9 op sites shared the input's shape vector, graph_free gives the parse layer a teardown at last, parser placeholder-String overwrite leaks (one per attr/node/tensor name — scales with model size), and rt_open/rt_close engine cleanup. yoloe's 267-node seg graph (exercising the reshape/split alias paths) stays byte-identical.
packages/image 0.1.0→0.4.0 — pure-NURL image codecs, raster ops
and an img CLI.** Decode/encode images with no native library and no convert shell-out, over the stdlib DEFLATE: PPM (P5/P6); a complete PNG codec (decode of bit depths 1/2/4/8/16, Adam7 interlacing, palette + tRNS colour keys → alpha; lossless 8-bit encode); baseline AND progressive (SOF2, the format most web JPEGs use) JPEG decode with any common subsampling and restart intervals; baseline JPEG encode (quality 1–100, 4:4:4/4:2:2/4:2:0, Annex K tables, loss profile matching Pillow's encoder); and libjpeg-exact "fancy" triangular chroma upsampling (bit-matching jdsample.c including its rounding biases), dropping 4:2:0 decode to IDCT-rounding distance from Pillow (max diff 3, tolerance tightened 12→4). Plus ops.nu (resize nearest/bilinear/area, crop, flips, rotations, convert, fill/rect/line, alpha blit, packed 0xRRGGBBAA colours), stb-style image_error failure reasons, and the img CLI (info/convert/resize with keep-aspect specs). Verified pixel-exact against Pillow where lossless, and hardened by a deterministic fuzz harness (10k+ mutants per seed, 6 formats, ASan+LSan, no hangs) that found a real infinite loop — baseline block decode never checked the Huffman decoder's corrupt-table return — alongside new dimension caps and inflated-size preflights.
packages/cli 0.1.0 + 0.2.0 — diamond command-line framework. A
single dependency-importable facade over std/args + std/term + ext/env that collapses the 100–500-line hand-written main() every package carries into a declarative Cli: git-style subcommand routing, typed flag accessors (str/int/bool/float) with env fallbacks and defaults, auto colour-aware --help/--version (TTY-gated, respects $NO_COLOR), error→exit-code conventions, and interactive prompts (cli_confirm/cli_prompt/cli_password). 0.2.0 adds cli_default — a default command for programs that ARE the command (psql/redis-cli shape): a bare invocation runs it, and a first positional matching no subcommand (e.g. a redis:// URL) routes to it — and automatic help-short yield when a user flag claims -h. Migrated onto it: anomaly 0.3.2, yoloe 0.6.0, redis 0.2.0 and psql 0.3.0 — three hand-rolled argv parsers deleted, clients live-verified against real servers (redis:7, postgres:16), and several pre-existing leaks fixed along the way (TCP handle leaked on connect failure, __parse_url String overwrites), flushed out by ASan-gating the new paths.
packages/http — unified HTTP server interface package. The
stdlib already ships the complete toolkit (sockets + TLS, parser, response builder, keep-alive server with pools + DoS limits, router, static files, auth/jwt/multipart/middleware/websocket); what every server re-invents is the glue. HttpApp collapses bind → router → shutdown signal → logging/CORS/panic-recovery → keep-alive loop into a few http_app_* calls (http_app_get/_static_dir/_listen or _listen_tls), reusing the battle-tested stdlib implementation with no duplication. First consumer: anomaly 0.3.0–0.3.3 — a self-contained web dashboard (model manager, trainer, feature visualiser, anomaly scanner served from anomaly serve --webroot; plain HTML/JS, no CDN, no build step), serving migrated onto HttpApp (~35 lines of hand-wired glue → 6, gaining graceful SIGINT/SIGTERM shutdown and handler panic→500 for free) and GPU scoring onto gpukit (bit-identical output preserved).
nurl.sh --coverage (critic C9): line coverage for .nu
sources — gcov-style -fprofile-arcs -ftest-coverage as IR passes over the DWARF metadata --debug emits; llvm-cov gcov reports per-line hit counts against the original source.
std/fswatch.nu (critic B8): file watching via Linux inotify,
pure libc FFI — blocking fswatch_next, FSW_* masks, multiple packed events cursored out one per call. Lock fswatch_basic.nu.
std/process.nu (critic B11): raw streaming on a live child —
proc_read_chunk (poll-gated incremental read, empty Vec = EOF) + proc_write_bytes, interleaving safely with proc_read_line. Lock process_pipes.nu (live cat round-trips + sort-after-EOF).
textDocument/rename (critic C7): references sweep + decl
index mapped to a WorkspaceEdit, -32602 on invalid targets/names, renameProvider advertised. Lock lsp_rename_smoke.sh.
Last-Event-ID SSE resumption (monotonic event ids + bounded 64-event per-session backlog, best-effort replay), JSON-RPC batch arrays through mcp_http_handler_session (validated once, ordered responses, 202 for pure notifications), and resources/subscribe + notifications/resources/updated delivered over the session SSE stream (the session transport advertises resources.subscribe:true on initialize). Locks extended in mcp_session_basic.nu + mcp_http_session.nu.
--mcp now mints its self-signed TLS
certificate in pure NURL (std/x509_gen) — the openssl CLI dependency is gone; minted pair verified with openssl and a live curl MCP handshake.
publisher) and the GitHub login that published each version, sourced from the D1 identity tables — the authenticated publishing identity, crates.io/npm style; nurl.toml gains no self-declared author field. New GET /api/v1/stats (packages/versions counts, CORS-open, 5-min cached) feeds a live registry-package count on nurl-lang.org (fetched at deploy time, best-effort). Brand imagery lands: hero logo mark, og:image/twitter social card, brand-showcase band, and a branded registry header + favicon on every page.
nurlfmt hook. .githooks/pre-commit runs `nurlfmt
--write on fully-staged .nu` files and re-stages them (a partially-staged file is only --checked and blocks the commit, so unstaged edits are never silently staged); build.sh wires core.hooksPath = .githooks on build, so a normal build activates it. Prevents commits that would trip the CI nurlfmt --check gate.
locks; B20 (HTTP/2 client interleave) and C6 (nurlfmt --check CI gate) were found already shipped and are annotated as such; C8 notes the live registry.
ROADMAP.md reviewed for 0.11.0: signedness-in-the-type-repr wording,
the registry package-ecosystem summary, and removal of stale pointers to untracked scratch files (also scrubbed from docs/CRYPTO.md).
A patch release: **GPU-dependent packages are installable from release archives again.** v0.10.11 archives were assembled on a GPU-less CI runner and shipped without the runtime.cuda / runtime.nvrtc sentinels, so the installed compiler refused to build anything depending on packages/gpu (nurlpkg install anomaly failed on every machine — GPU or not).
link.** The sentinels (nurlc's compile-time FFI-lib gate) were probed from the build machine, but they answer a question about the link: cuda and nvrtc are the two FFI libraries with fallback stub objects (stdlib/{cuda,nvrtc}_stubs.o) — nurl.sh links the real library when the host has one and the stubs when it doesn't, stubbed calls return errors, and packages/gpu falls back to its CPU backend. The gate could only ever produce false negatives, and baked into a release archive it blocked gpu-dependent packages everywhere. Stub-backed libraries now get their sentinels unconditionally (the host probe remains for the build log); libraries without stubs (opus, asound, X11, sqlite3, …) keep the conditional probe, and Windows (build.bat, no cuda stubs) is unchanged. Verified end to end: with the sentinels in the installed stdlib, nurlpkg install anomaly resolves iforest + gpu from the registry, compiles, links the real libcuda/libnvrtc, and the installed binary scores a 5001-row batch through the CUDA path in 0.29 s.
A streaming anomaly detection release. The new anomaly package is a complete self-training anomaly-detection service in pure NURL: named models are created on first use, ingest one JSON point at a time over CLI or HTTP, and train themselves once enough history accumulates — Isolation Forests under the hood (the iforest package), scikit-learn's exact decision conventions on top, and GPU-accelerated bulk scoring that is bit-identical across CUDA, the gpu package's CPU backend, and the pure-NURL loop, so a GPU-less machine gets the same verdicts. Also in this release: the cas content-addressed store and pure-NURL self-signed X.509 certificates, plus two ecosystem-wide leak fixes the new package's sanitized suite flushed out (every CLI's argv parse; every gpu-package consumer's device allocations).
packages/anomaly v0.2.0 — streaming anomaly-detection service:
dynamically created, automatically trainable models.** Where iforest is the kernel (matrix in, scores out), anomaly is the service around it. Heterogeneous features encode automatically (numeric passthrough, categorical → deterministic sorted one-hot, ISO-8601 → calendar features) with a frozen feature-order projection so one-hot columns never scramble across retrains; a persisted StandardScaler analogue standardises them. Each model keeps a bounded ring of raw points (data.jsonl — raw, so retrains learn new categories), warms up to 50 points, then retrains every enabled time-window version (short_term 180 min / daily / weekly / seasonal 90 d / timevector last 100 points) on schedule; a point is anomalous if any version flags it. Decision maths match scikit-learn exactly — `decision_function = -iforest_score − offset_, offset_ = −0.5 for contamination='auto'`, else the 100·c training percentile; validated against sklearn on identical deterministic data (agreement within ~0.01). Margins are read from live metadata so finetune (95 % of the worst observed score) and config changes apply without retraining. Persistence is atomic with fully-validated binary forest blobs — corrupt, truncated, or renamed files load as errors, never UB. Ships as a library (model_open / model_ingest / model_detect_only / …, injectable clock via _at variants), a CLI (`anomaly detect/score/batch/train/reset/rm/ls/info/ serve`), and an HTTP/JSON service mirroring the reference Flask API route for route (202 while warming, ^[a-zA-Z0-9_]+$ name validation, same error shapes). GPU path (M7): bulk scoring (batch CSVs, the contamination percentile, the fine-tune sweep) routes through packages/gpu at ≥ 128 rows — CUDA when present, the gpu package's CPU backend (host C++ + OpenMP) without one, pure NURL with neither — and all three engines are bit-identical by construction (the kernel only sums f64 path lengths in the pure walker's order over host-precomputed per-leaf constants; the nonlinear finish stays in NURL), asserted element-for-element == in the tests. Measured on 200 000 rows × 300 trees: pure 9.6 s → host C++ 1.1 s (~9×) → RTX 4090 213 ms (~45×). Suite: seven unit suites (165 checks), a CLI end-to-end pass, a live served-over-curl smoke, and the GPU parity test on both engines — ASan/UBSan/LSan-clean throughout, deterministic (seed 42 ⇒ byte-identical forests and scores everywhere).
packages/cas v0.1.0 — content-addressed store over BLAKE3 + minimal
manifest.** The keystone of a verifiable build → hash → dedup → distribution chain: bytes are stored under their own BLAKE3-256 hex (git/OCI-shaped two-level fan-out of plain files, atomic tmp+rename writes, dedup by existence — no index, no database, no locks), and every read re-hashes what it found, refusing to return bytes that don't match their name — a store you can rsync, mirror, or fetch from an untrusted cache without losing integrity. A snapshot is a canonical text manifest (cas-manifest v1 + sorted hash size path lines) stored in the CAS like any blob, so one 64-hex string names an entire tree Merkle-style and the same tree always snapshots to the same hash. CLI (cas put/hash/get/snapshot/ls/checkout/verify, store at $CAS_STORE or ~/.cas) + embeddable library (cas_put / cas_get / cas_snapshot / cas_checkout / cas_verify). Tests cover round-trip, in-store dedup (N identical files → one object), snapshot determinism, and the point of it all: a flipped byte inside the store turns get, verify, and checkout into errors — never silent corruption. Path-traversal-safe checkout (manifest paths may not escape the destination). ASan/UBSan/LSan-clean including the error paths.
std/x509_gen.nu — self-signed X.509 certificates in pure NURL. The
write-side sibling of the parse-only std/x509.nu: a minimal DER encoder plus TBSCertificate assembly, self-signed with the ECDSA-P256/SHA-256 and P-256-keygen machinery the stdlib already had. `x509_selfsigned_p256 cn days` returns a certificate PEM (X.509 v3: random serial, CN + dNSName SAN, basicConstraints critical CA:TRUE, ecdsa-with-SHA256) and a SEC1 / RFC 5915 EC PRIVATE KEY PEM — exactly the pair openssl ecparam + openssl req -x509 used to produce, so the pure TLS server consumes the files unchanged and servers like swarm-mcp --mcp can drop their OpenSSL subprocess. The _pinned variant takes an explicit scalar/serial/validity and — because the stdlib's ECDSA is deterministic RFC 6979 — produces a byte-reproducible certificate, which is what makes the golden test (compiler/tests/x509_selfsigned.nu) possible. Verified externally: openssl verify accepts the cert, the key↔cert pair matches, and `curl --cacert` completes a fully-verified TLS handshake against the pure-NURL TLS server serving it. New playground example examples/selfsigned_cert.nu prints a freshly minted pair and re-verifies it with the stdlib's own X.509 parser.
args_parse_argv leaked every argv token (std/args) — every CLI
built on it (cas, nq, anomaly, …) leaked argc strdups per process: nurl_argv_get returns a heap copy by contract, but the parser treated it as borrowed and copied it again. The copy is now adopted with string_from_take. Found by the anomaly package's sanitized CLI suite.
packages/gpu v0.2.1 — every out-slot and compile-path leak
plugged.** cuda.nu leaked its 8-byte out-param slot on every wrapper call — cuda_malloc leaked per device allocation, for every consumer of the package (onnx, objdet, yoloe) — and cpu_compile leaked every working String it built (cast list, generated source, tmp paths, compiler command lines) on success and error paths alike. All freed at last use; cuda_device_name's contract documented (returns a fresh buffer the caller owns). gpu's own tests pass on both backends.
A local wasm builds release. The new wasmbuilder package turns a bare toolchain install into a complete NURL → wasm32-wasi compiler — no wasi-sdk, no Docker, no build service: the bundled zig cc (wasi-libc + wasm-ld built in) links what nurlc emits, and the wasm runtime object is compiled from the installed stdlib on first use. swarm-mcp compiles compute kernels locally on the strength of it, nurl-mcp grows a fully local nurl_build_wasm tool, and the Windows release now bundles zig like the Linux archives. The compiler fix underneath: enum payload slots are i64 now, so f64 / >2³² payloads survive wasm32 bit-exactly — general-enum programs produce byte-identical output native vs wasm. Also in this release: swarm-mcp's GPU engine matured through v0.6.0 (runtime kernel params, GPU map + histogram) and v0.7.0 (distributed GPU compute over real uploaded datasets).
i64, not ptr — 64-bit payloads survive
wasm32.** General-enum payload slots were pointer-typed and every payload transited them via inttoptr/ptrtoint. Lossless on 64-bit targets, but a wasm32 pointer is 32 bits wide, so an f payload's bit-pattern or an integer ≥ 2³² silently lost its upper half on the wasm target (the chaotic-showcase autodiff computed garbage under wasm while passing natively). Payload slots are now uniformly i64 on every target — floats bitcast in and out, narrow ints widen/trunc, and pointers are the ones that convert (ptrtoint/inttoptr, lossless everywhere, wasm32 pointers zero-extend). Construction, match destructuring (slots 0–N, literal payload patterns), and the auto-generated enum Drop all moved together, so the layout change is invisible on 64-bit native — proven by the full corpus and the held bootstrap fixed point. New regression: compiler/tests/enum_payload_64bit_slots.nu pins the 64-bit-exact round-trip for f64 / >2³² / negative i64 / mixed-slot / pointer payloads (and runs identically as wasm via the wasmbuilder corpus).
packages/wasmbuilder v0.1.0 — local NURL → wasm32-wasi builds, no
wasi-sdk, no build service.** nurlpkg install wasmbuilder is all a blank machine needs: the CLI (wasmbuilder file.nu → file.wasm, plus --doctor) and library (wb_build_file / wb_build_source) drive the installed toolchain end to end — nurlc emits IR, the production IR rewriter (extracted from nurlapi's /build_wasm) retargets it for wasm32-wasi, and the toolchain's bundled zig cc links it (zig carries wasi-libc + wasm-ld, which is what makes wasi-sdk unnecessary). runtime.wasm.o is compiled from the installed stdlib's runtime.c on first use and cached by content hash, so it always matches the toolchain; a machine with no zig at all downloads the pinned 0.13.0 release once, sha256-verified against ziglang.org's index (NURL_WASM_NO_DOWNLOAD=1 opts out). zig's driver rejects -Wl,--allow-undefined, so the rewriter instead marks every remaining declare with a "wasm-import-module"="env" attribute — same semantics (undefined symbols become host-resolved wasm imports; defined symbols win), now expressed in the IR. A @main alias keeps zig's debug-mode wasi-libc linking at -O0. Corpus-tested: 13 repo examples byte-identical to their native builds under both the reference wasmtime and the pure-NURL nwasm; the compiler itself (65k lines) built through wasmbuilder compiles programs byte-identically to its native twin.
packages/swarm-mcp v0.8.0 — kernels compile locally.
compute_submit_kernel / compute_submit_cuda now build wasm in-process via the new wasmbuilder dependency (local toolchain, no network); the NURL build API ($NURL_BUILD_API) remains as a fallback for hosts whose toolchain can't build wasm. A local nurlc failed (a genuine kernel error) is returned directly instead of being retried remotely.
packages/nurl-mcp v0.3.0 — nurl_build_wasm tool. Compile inline
source or a path to a wasm32-wasi module fully locally via wasmbuilder: optional out path; a path input defaults to <input>.wasm next to it; inline source without out returns JSON with wasm_base64. Gated with nurl_build (off under --read-only).
release.yml + install-toolchain.batstaging + uninstall): the Windows toolchain zip now carries the same self-contained zig backend as the Linux archives, so wasmbuilder works offline there too; older Windows installs fall back to wasmbuilder's one-time zig download.
packages/swarm-mcp v0.7.0 — datasets: distributed GPU compute over real
data.** compute_upload_data uploads a flat f64 array (base64 raw LE, or a file on the MCP host; ≤ 256 MiB) and returns a dataset_id + stats; the three CUDA tools take dataset: id and the device functions receive each element as a second argument — f(long long x, double v), bin/val(x, v), with runtime params as a trailing const double* p. Without lo/hi the whole dataset is processed. Sharding follows the map-reduce split rule: each chunk's payload (v3) carries exactly its own data slice (≤ 12 MB per chunk under the relay's 16 MB frame cap), HMAC-tagged like everything else; the worker hands it to the module through a sandbox-preopened file and the generated program uploads it to the GPU. Verified live against Python references: sum / max / variance-with-param over 100k gaussians exact, pointwise normalisation exact, 10-bin floor(v) distribution exact. compute_list_data lists uploads.
packages/swarm-mcp v0.6.0 — dynamic, versatile GPU compute. Three
upgrades to the distributed GPU engine: - Runtime kernel params, no recompiles. params: [numbers] reach the CUDA device functions as const double* p via each chunk's argv — the generated source (and so the module hash) never changes. Together with a new coordinator-side compiled-module cache (keyed by generated source) and the existing worker-side content-hash cache, a parameter scan pays the build API once and then resubmits in seconds. - compute_sample_cuda — a GPU map that returns every value: f(x) for each x in order (curves, fields, tables). Inline as JSON (≤ 1024) or base64 f64 LE (≤ 65536), or written to out_file (≤ 1M values); min/max/mean always included. - compute_histogram_cuda — binned aggregation in one pass: bin(x) picks one of K buckets, val(x) (default 1.0) is accumulated with a portable CAS-based double atomicAdd; chunks combine elementwise. K rides argv, so one module serves any bin count.
GPU chunks now use payload v2 ([ver][mode][lo][hi][K][params][wasm]); vector modes return results through a sandbox-preopened binary file (one fwrite) instead of stdout, and the result frame [ok][count][f64…] keeps chunk failures visible. All-in-one nodes with --gpu require a same-version cluster for GPU tasks (CPU task wires are unchanged).
packages/nwasm v0.6.1: nvrtcCompileProgram's options array is now
marshalled correctly — each guest char* entry is translated to a host pointer (bounded, NULL for out-of-range), mirroring cuLaunchKernel's void** handling. Previously any nonzero option count handed libnvrtc guest offsets as host pointers.
stdlib/dist/job.nu: per-kind routing rings (capability domains).
job_set_ring(node, kind, ring) scopes a task kind to its own consistent-hash ring — submit, ownership, and mid-flight forwarding for that kind all resolve against the scoped ring, so a task can never land on (or re-home to) a node outside its capability domain. Kinds without a scoped ring use the main ring, unchanged. New golden coverage in compiler/tests/dist_job.nu.
packages/swarm-mcp v0.5.0 — distributed GPU compute over MCP.
Workers started with --gpu advertise a capability bit in the HELLO gossip (a trailing byte older nodes ignore); every node folds GPU workers into a GPU-only ring and the GPU wasm task kind routes on it, so mixed CPU/GPU clusters just work. GPU chunks run under the pure-NURL nwasm runtime with --allow-gpu, executing CUDA/NVRTC host imports on the worker's real GPU. New MCP tool compute_submit_cuda: the model writes only a CUDA-C __device__ double f(long long x); the server generates the complete kernel program (NVRTC JIT, grid-stride map, on-device block reduction, host fold), compiles it to wasm, and shards it across the GPU workers — verified live on an RTX 4090 (π·10⁹ integral; a 250M-element chunk in ~0.3 s including the JIT). compute_submit_kernel gains kind:"chunk" (one kernel call per sub-range) and gpu:true; compute_run_wasm gains gpu:true.
[ok:1][partial:8]; a chunk that fails (module trap, missing runtime, GPU error) is counted and the task finishes as status:"error" with failed_chunks — never silently folded into the reduce as a zero. Legacy 8-byte frames are still accepted.
A wasm toolchain release. The pure-NURL packages/nwasm runtime grows from a proof-of-concept into a spec-faithful, hostile-input-hardened WebAssembly engine — real traps and multi-value blocks, bulk memory + reference types, a bounded explicit frame stack with fuel metering and name-section backtraces, a much larger WASI surface (positioned I/O, directories, real clocks/entropy/env), and a CUDA/NVRTC host-import bridge that runs GPU wasm modules on real hardware. Two compiler FFI argument-coercion fixes make GPU-using NURL compile to wasm at all, and the runtime now aborts loudly on OOM instead of silently corrupting wasm32 linear memory. Organisationally, stdlib/runtime.c is split into a bootstrap core and the stdlib FFI shims.
nurlc --ffi-host-imports. With
the new flag, external &-FFI libraries are satisfied by the run-time embedder as wasm imports (module env) instead of a native link line, and the build-time stdlib/runtime.<lib> sentinel gate is skipped (native path unchanged, flag off by default). nurlapi's wasm build passes it through and links with -Wl,--allow-undefined, so undefined FFI symbols become wasm imports; a genuinely missing symbol traps at run time with its name. This is what lets a GPU package (objdet → onnx → gpu) compile to a wasm module the host resolves against real libcuda/libnvrtc.
packages/nwasm — WASI expansion. environ_get/_sizes_get from
repeatable --env NAME=VALUE (capability-style; the host environment is never inherited implicitly); real clock_time_get (wall + monotonic, ns) and random_get from the OS CSPRNG (both previously returned zeros); a path_open overhaul (directory fds, O_CREAT/O_EXCL/O_APPEND/O_TRUNC semantics), positioned fd_pread/fd_pwrite/fd_tell/fd_sync, offset-correct fd_write, a directory surface (fd_readdir, path_create_directory, path_remove_directory, path_unlink_file, path_rename, path_filestat_get), and multiple preopens via repeatable --dir.
packages/nwasm — bulk memory + reference types. Passive data/element
segments (memory.init/data.drop, table.init/elem.drop), a runtime funcref table mutable via table.get/set/grow/size/fill/copy, ref.null/ref.is_null/ref.func and typed select, and up-front bounds-checked memory.copy/fill/init (no partial writes before a trap).
packages/nwasm — explicit frame stack, fuel metering, backtraces.
exec_func drives an explicit frame stack, so guest calls no longer recurse on the host native stack — deep guest recursion is bounded by max_depth (65536 frames, trap call stack exhausted) instead of a host stack overflow (verified 1M-deep). Optional --fuel N traps a runaway guest after N instructions; the custom name section is decoded and traps append a wasm backtrace.
packages/nwasm — CUDA/NVRTC GPU host-import bridge. Resolves a GPU wasm
module's cuda/nvrtc imports to real libcuda/libnvrtc, marshalling guest linear memory ↔ host (every *u param is a guest offset → host address; opaque handles and CUdeviceptr travel as raw i64; cuLaunchKernel's guest void** is reconstructed host-side). nurl.sh auto-links libcuda/libnvrtc when those symbols appear and stubs them on a GPU-less host. Verified on an RTX 4090 (vadd, cuInit+cuDeviceGetCount).
nurlapi — /build_wasm accepts pre-compiled IR (ir field). The only
way to wasm-build a multi-file package: a host nurlc resolves the $-imports the container lacks and the container just does the wasm rewrite + wasi-clang link.
stdlib/runtime.c → bootstrap core + FFI
shims.** The 5.2k-line runtime is separated into stdlib/runtime_core.c (bootstrap core: OOM policy, version, Win32 shims, basic stdio I/O, string ops, file/dir, the allocator + panic-unwind journal, IEEE-754/math, and panic/recover) and stdlib/runtime_ffi.c (stdlib FFI shims: HTTP, process, TCP/TLS + UDP sockets, DNS, thread trampolines, signals, and the async fiber runtime + I/O reactor). stdlib/runtime.c becomes a thin aggregator that #includes both into one translation unit, so the default build still emits a single stdlib/runtime.o with a byte-identical symbol set and link surface — no build-script or bootstrap changes. runtime_core.c has zero references into the FFI half and compiles standalone, so it now defines the core symbol set a bootstrap/no_std target must provide (unblocks ROADMAP D2). Organisational only: no behavioural change, self-host fixed point held, full corpus + sanitizer suite green.
packages/nwasm — core semantics: multi-value, real traps, checked
call_indirect.** Block types decode as signed-LEB s33 (multi-value blocks/loops/ifs), call_indirect performs the runtime structural signature check, the start section runs at instantiation, integer divide-by-zero and INT_MIN/-1 overflow trap, float→int truncation traps on NaN / out-of-range (with saturating 0xfc forms), and float min/max/compare are NaN-correct.
packages/nwasm — import layer strictness. Imports record their module
name and dispatch verifies wasi_snapshot_preview1; a table/memory/global import (or global.get in a const expression) is now a hard decode error instead of being silently skipped.
packages/nwasm — hardened against hostile input (v0.6.0). Audited
against malformed/adversarial wasm (ASan-fuzz + exhaustive prefix sweep): every vector count is validated against the bytes physically remaining before allocating (__chk_count), negative/over-long section and name-subsection sizes are rejected, truncated init/element expressions are clean decode errors, and mem.min/table.min/per-function locals are capped. Every input is now memory-safe and terminating; valid modules still run byte-identically to reference wasmtime.
nurlc — FFI argument coercion to the declared parameter width, plus
int↔pointer.** An integer argument whose width differs from an FFI function's declared parameter (NURL literals are i64 → an i32 param) was emitted verbatim; native LLVM tolerates it but wasm-ld replaces the whole function with an unreachable stub that traps at run time. gen_ffi_decl now records each symbol's LLVM parameter types (__ffi_params) and gen_call coerces via trunc/sext (and inttoptr for a bare 0/handle passed to a pointer param). __ffi_params is also registered in the scan_fn_sigs pre-pass so forward-referenced FFI symbols coerce correctly. Fixes the long-standing fread/cuLaunchKernel "unreachable stub" on wasm.
nurl_alloc/_zalloc/
_realloc and the 81 other malloc/calloc/realloc/strdup sites in the runtime returned raw NULL on OOM. On native that faults on first write, but on wasm32 address 0 is ordinary writable linear memory, so a NULL-backed string/vec silently corrupts state (observed as the nurlc.wasm self-host "hang": once memory.grow failed past the 4 GiB ceiling the analysis spun on NULL-backed state). File-wide checked wrappers now abort with nurl: out of memory (requested N bytes); the panic-journal grow keeps its deliberate degrade-to-a-leak.
nurl_peek_i32/nurl_poke_i32 are 32-bit, not i64. Every
package declared these 32-bit runtime accessors with NURL's i (i64); on LP64 native the register hid the high bits, but on wasm the type differs from runtime.wasm.o and wasm-ld emits an unreachable stub. Fixed to i32 in gpu (cuda.nu/cpu.nu), onnx (pb.nu) and yoloe.
nurlapi — libc off_t/size_t wasm width shims. Correct the FFI shim
widths that produced unreachable stubs on fs.nu's fopen/fread file path (lseek removed from the shim list — its off_t is already 64-bit on wasm32; a new w param-char for an i32 parameter nurlc already emits as i32).
A dynamic trait objects release. NURL's trait system gains a second, opt-in dispatch path alongside the existing static one: %Trait boxes a concrete implementer behind a vtable fat pointer, so heterogeneous collections and plugin-style APIs can dispatch on the trait rather than the concrete type — no GC, and the existing monomorphised impl dispatch is unchanged. Diamond supertraits upcast through the same object, object safety is checked at compile time, and the box's Drop is synthesized so the single-owner memory model holds. A real leak in the (unrelated) static impl-method dispatch path, found while adding the feature, is fixed alongside it.
%Trait (docs/spec.md §4.9). A trait can now be
used as a runtime-dispatched object so values of different concrete types are handled uniformly, layered beside the existing static path (a concrete ( fmt p ) still lowers to fmt__Point; concrete values carry no trait identity). %Trait is a fat pointer %dyn.<Trait> = { i8* data, i8* vtable }: a heap-boxed copy of the value plus a per-impl vtable (slot 0 = destructor, slots 1..K = one uniform-ABI thunk per method). ( dyn Trait value ) boxes a value that implements Trait; a bare-name call ( method obj ) on a %Trait receiver loads the vtable slot and dispatches indirectly. Diamond upcasting: the vtable flattens the transitive supertrait method set, so a %Pet object (Pet : Animal) dispatches Animal's methods too — including inherited default methods, which call back through the object's own dynamic dispatch. Object safety is enforced with a precise diagnostic: a trait is usable as %Trait only if every method (and every supertrait's) is dispatchable from an i8* self plus its signature (has a [T] Self parameter; no method lacks a Self receiver, names Self beyond the receiver, consumes self by value, or mentions an associated type). Memory safety: a %Trait binding is an owned value with a synthesized Drop that runs the vtable's slot-0 destructor on the boxed value (freeing its owned resources transitively) then frees the box — no leak, no double-free, verified under AddressSanitizer. Tests: dyn_dispatch, dyn_diamond, should_fail_dyn_not_object_safe.
nurlfmt understands %Trait type sigils. The formatter now distinguishes
the three uses of % — trait/impl declaration sigil, %Trait object type, and the modulo operator — using prefix-notation context (an operand never precedes an operator). A %Trait in a signature, binding, or return type glues to the trait name (%Pet) and no longer opens a spurious top-level declaration that split the signature across lines.
nurl_free_count() runtime primitive. A symmetric companion to
nurl_alloc_count() (counts every nurl_free of a non-NULL pointer), so a program can bracket a scope and assert leak-freedom deterministically without a sanitizer. Used by the new trait_owned_ret_no_leak regression test.
returning an owned s (e.g. via nurl_str_cat) whose result was passed straight to another call — ( nurl_print ( label d ) ) — leaked the temporary; plain-function composition already freed it. The impl-method dispatch path (Group F in gen_call) emitted its call and returned **without publishing any of the __last_call_* return side-channels** the caller reads, so the callee's owned-string / borrow / signedness / ??-propagation markers were all lost — not just a leak but a latent borrow double-free and a u/!T E mis-handling. Fixed by factoring the marker propagation into one helper (mem_propagate_call_ret_markers) shared by both the impl-method and regular-call paths, so no dispatch path can silently omit a marker.
A CPU inference + GUI release. The GPU/ML stack from 0.10.6 gains two big things: models now run with no GPU at all (a CPU backend in packages/gpu that runs the same CUDA-C kernels on the host), and the yoloe webcam demo can draw its live segmentation into a real X11 window — or the terminal.
packages/gpu (0.1.0 → 0.2.0). gpu_open uses CUDA when
a device is present and otherwise falls back to a CPU backend that runs the same CUDA-C kernels on the host: each kernel is wrapped in a small CUDA-compatibility shim (blockIdx/threadIdx/blockDim/gridDim as thread-locals, __global__ etc. as no-op macros) plus a generated grid-loop, compiled by the system C++ compiler, dlopen'd, and run with OpenMP across cores. gpu_launch's void** argument array is byte-for-byte what CUDA passes, so the neutral surface is unchanged and every caller (onnx, objdet, yoloe) gets the fallback for free. NURL_GPU=cpu forces it. Verified identical CPU vs GPU on the onnx MLP and the full 267-node YOLOE-seg forward.
packages/gpu no longer
hard-requires libcuda/libnvrtc to load: nurl.sh links small stub objects (stdlib/{cuda,nvrtc}_stubs.o, built by build.sh) in place of -lcuda / -lnvrtc when those libraries are absent — the binary links, loads, and auto-falls-back to the CPU backend. NURL_GPU_STUBS=1 forces the stubs to build a portable CPU-only binary on a GPU host. runtime.c gains one tiny generic thunk, nurl_cpu_launch, so the backend can call the dlsym'd kernel entry.
build.sh writes a runtime.X11 sentinel when
libX11 is present and nurl.sh auto-links -lX11 only when @XOpenDisplay appears — the same opt-in-by-symbol pattern as libcuda/libopus, so headless programs are unaffected. Used by packages/yoloe's new window preview.
yoloe — a real webcam segmentation tool. The yoloe command is now a
flag-driven, self-documenting CLI with three sub-commands — detect, seg, and cam — and cam shows the live segmented feed either in a **real X11 window** (full webcam resolution, pure NURL via libX11) or in the terminal (area-averaged 24-bit half-blocks, works over SSH). --boxes / --mask pick what to draw, --frames bounds the run (omit ⇒ until Ctrl-C), --out saves frames, --device / --gpu select the camera / CUDA device.
nurlpkg { path, version } hybrid dependencies are published + resolved
correctly end-to-end (carried over from 0.10.6); the gpu/onnx/yoloe packages were republished with the correct dependency edges so a registry install resolves the whole chain.
A GPU compute + ML inference release. NURL gains the ability to run real neural networks — CNNs, vision-language transformers, and end-to-end object detection and instance segmentation — entirely in pure NURL on the GPU, through a new stack of registry packages (gpu → onnx → objdet / yoloe). The crown jewel is yoloe: promptable open-vocabulary detection with per-object masks, live off a webcam, captured in pure NURL via Video4Linux2 — no ffmpeg, no OpenCV, no external inference engine. The toolchain changes here are the thin layer that makes that possible plus a nurlpkg dependency-resolution fix; the heavy lifting lives in the packages.
stdlib/runtime.c gains
nurl_peek_i32 / nurl_poke_i32 / nurl_peek_f32 / nurl_poke_f32 — natural-stride reads/writes for the packed float32 / int32 arrays that GPU kernels, image data, and binary wire formats use (the 8-byte nurl_peek/poke can't address a 4-byte array at its stride). Pure and dependency-free; they ship to every target, no GPU required.
nurl.sh and build.sh now
link -lcuda / -lnvrtc only when a program references cu* / nvrtc* symbols (detected via sentinels). A GPU-less host — or any program that never touches the gpu package — is completely unaffected; the CUDA dependency lives entirely in packages/gpu.
- gpu 0.1.0 — backend-neutral GPU compute (Gpu / GpuKernel / GpuBuffer: open, compile, alloc, upload/download, launch, sync). One backend: CUDA, with kernels written in CUDA-C and compiled to PTX at runtime via NVRTC, run over the CUDA Driver API — all bound from pure NURL with no runtime.c bridge. - onnx 0.1.0 → 0.4.0 — run ONNX models on the GPU: a pure-NURL protobuf decoder (no protoc) builds an in-memory graph, and a GPU-resident executor dispatches each operator to a CUDA-C kernel. Grew from MLPs (Gemm/Relu) to CNNs (Conv/MaxPool/BatchNormalization/ LeakyRelu/N-D tensors) to the transformer set (LayerNormalization/ Erf/Gather/batched MatMul/N-D Transpose/Softmax) to the segmentation mask-prototype branch — a new ConvTranspose op plus a model's second graph output reachable via rt_output1. Verified end-to-end against onnxruntime at every step. - objdet 0.1.0 / 0.2.0 — object detection (tiny-yolov2) from pure NURL on the GPU: image I/O, YOLO decode, NMS; matches onnxruntime pixel-for-pixel and supports a frame-sequence video mode. - yoloe 0.1.0 / 0.2.0 — promptable open-vocabulary detection **and instance segmentation**, a NURL port of YOLOE (ICCV 2025). Names the classes you want at runtime; the whole YOLOv8/YOLOE network (backbone, neck, DFL head, region-text contrastive head) runs on the GPU. The MobileCLIP text path is pure NURL too — a 12-layer CLIP text transformer (bit-exact vs onnxruntime) and a CLIP byte-level BPE tokenizer (byte-identical to open_clip). 0.2.0 adds per-object masks and a pure-NURL Video4Linux2 capture path (open/ioctl/mmap, YUYV→RGB) for live segmentation off a webcam — no ffmpeg, no OpenCV. - swarm-mcp 0.3.0 / 0.4.0 — a unified node with composable, non-exclusive roles (--relay / --worker / --mcp), --token cluster security (group-id + HMAC), MCP served over HTTPS JSON-RPC, and floating-point (f64) distributed compute kernels.
published package's README into HTML (Markdown → HTML in TypeScript, XSS-safe), including images served straight from the package tarball.
nurlpkg resolves { path, version } hybrid dependencies correctly. A
Cargo-style hybrid dep (a local path override that also carries a registry version) is now (a) published with its version in the registry index — so a downstream consumer can resolve it — and (b) installed by falling back to the registry version when the local path is absent, resolved transitively across the whole dependency tree. Before this, publishing such a package dropped the dependency from the index and a registry install of it silently skipped the dep. This is what lets the layered gpu ← onnx ← yoloe packages install from the registry.
onnx text-encoder forward correctness. Three real runtime bugs —
Softmax with a negative axis, Add mask broadcasting, and an Engine value-map that wasn't reset between runs — were producing all-NaN outputs hidden by a NaN-blind comparison. Fixed; the MobileCLIP text encoder now matches PyTorch to ~5e-6.
monorepo sibling-package references, now render correctly.
A compiler correctness release: a single codegen fix that stops the toolchain from overflowing the stack on hot loops.
nurlc now emits every alloca in the function entry block.
Previously each : binding's stack slot was allocated at its lexical position, so a binding inside a loop re-allocated a fresh slot on every iteration (LLVM only reclaims allocas at ret) — a loop over N items leaked N stack slots and eventually overflowed the stack. clang's mem2reg promoted those slots away and hid the leak, but the released toolchain (relinked with the bundled zig cc -O2 -flto for portability) kept the per-iteration stack growth and crashed. The most visible symptom was nurlpkg publish segfaulting inside the pure-NURL gzip/deflate path while packing a multi-kilobyte tarball. All NURL allocas are static-size, so hoisting them to the entry block is the canonical LLVM idiom and semantically identical — the slot lifetime is already function-wide; the compiler just stops re-issuing it per iteration. The bootstrap snapshot (nurlc_lastgood.{nu,ll}) was refreshed accordingly.
A WebAssembly + self-hosting release. NURL gains a from-scratch WebAssembly runtime written entirely in NURL, the compiler now self-hosts on wasm — the compiler compiled to wasm32-wasi recompiles its own source to byte-identical IR — and the swarm-mcp engine can compile arbitrary NURL kernels to wasm and run them across the cluster.
packages/nwasm — a WebAssembly runtime written in pure NURL. No
libwasm, no embedded interpreter, no external runtime binary: it decodes a wasm binary (LEB128, every standard section) and interprets the full integer + float instruction set — structured control flow (block/loop/if/else/ br/br_if/br_table/return), i32/i64 arithmetic / comparison / bitwise ops (signed and unsigned, rotates, clz/ctz/popcnt, sign-extension), f32/f64 ops and every int↔float conversion, linear memory (load/store, memory.size/grow, bulk memory.copy/fill, the data section), globals, tables + call_indirect, and the element section. It hosts real wasm32-wasi command modules via the WASI snapshot-preview1 surface (proc_exit, fd_write, args_*/environ_*, clock_time_get, random_get) plus a file-descriptor table with --dir preopen and file ops (path_open/fd_read/fd_seek/fd_write/fd_close/fd_filestat_get), so a module can read host files. Cross-checked byte-for-byte against the reference wasmtime and ASan-clean; floats held as IEEE-754 bits via std/floatbits.
wasm32-wasi
(nurlc.wasm) compiles NURL source — up to and including the full compiler nurlc.nu itself — to IR byte-identical to the native nurlc, under both the reference wasmtime and the pure-NURL packages/nwasm runtime above (the 2.4 MB / 65530-line self-compile is md5-verified). The borrow-checker analysis is skipped for the wasm self-compile (--no-borrowck); it emits no IR, so the output is identical. Reaching this required the three wasm32 ABI fixes listed under Fixed.
packages/swarm-mcp — an MCP-driven distributed compute engine. A model
sets a workload over MCP and the cluster map-reduces it. Two kernel forms: compute_submit takes a small integer-expression kernel (in x, over a range, with a sum/product/min/max/count reduce); compute_submit_kernel takes an arbitrary per-element NURL kernel (@ kernel i x → i { … } plus any imports/helpers — no main needed), which the server wraps into a full program, compiles to wasm via the build service, and shards across the live workers. compute_run_wasm runs an already-compiled wasm32-wasi module. Tools: compute_submit / compute_submit_kernel / compute_run_wasm / compute_list / compute_result.
packages/swarm 0.2.0 — real distributed compute workloads. The
install-to-join cluster now runs genuine sharded workloads (e.g. prime counting and sum-of-squares) across the ring via dist/job — π(10⁶)=78498 computed across four workers — not just a membership demo.
std/floatbits — IEEE-754 bit reinterpretation + binary float I/O. NURL's
# cast converts a float's value; this new module reinterprets its *bit pattern* — the operation binary formats need, and previously impossible in the stdlib (bytes_push_float only writes %g text). Backed by a zero-allocation runtime bitcast (memcpy, correct for NaN/±Inf/subnormals): f64_to_bits / bits_to_f64, f32_to_bits / bits_to_f32, and explicit-endian binary helpers bytes_push_f64_le/_be · bytes_read_f64_le/_be → ?f (and f32 variants). For serialization (msgpack/protobuf/wasm/audio), float hashing, random→float, and the pure-NURL wasm runtime. KAT-tested (floatbits).
net/relay frame limit raised from 128 KiB to 16 MiB. A relayed messagemay now be up to 16 MiB, large enough to forward a compiled wasm compute kernel (a NURL→wasm module bundles the runtime, ~250 KB–1 MB) in one frame. It is only an upper bound, so small gossip / audio / unicast frames are unaffected.
was a non-parameter local shadowing the field.** = . t lo lo, where lo is an in-scope local that also names a field of t's struct, compiled to a value-as-index array store (t[lo] = lo, getelementptr %T, %T* t, i64 %lo) — an out-of-bounds, struct-corrupting write with no diagnostic. The read path already let a concrete struct field always win over a same-named variable; the store path only did so when the name was a parameter. The two are now symmetric: a name that is a field of the (non-generic) struct stores into that field whether it is a parameter or a local. The value-as-index array store is still taken when the name is not a field of the struct — raw pointers, and the tparam element types stdlib/core/vec.nu writes through (unaffected; full bootstrap + corpus green). Regression test field_store_shadow.
nurlc.wasm trapped on the first token compiled — int-returning libc
shim ABI.** The wasm32 build's shim layer widened every non-pointer libc return to i64, correct for size_t (which nurlc declares i64) but wrong for int (strcmp/memcmp/atoi/…, which nurlc declares i32): a call i32 @__nurl_<fn>_shim against a define i64 shim is a wasm signature mismatch, so wasm-ld replaced the call with a trap stub and the compiled nurlc trapped on its first strcmp (the first lexed token). The shim now returns i32 for int-returning functions.
__tok_write passed a token pointer as i64 to an i8* parameter. The
lexer cast each strdup'd token spelling to # i (i64) for __tok_write's s (i8*) val parameter — a no-op on 64-bit, but on wasm32 (4-byte pointers) an i64-for-i8* signature mismatch that trapped on the first token. The spurious cast is removed (the value is a string; __tok_write already narrows it internally).
wasm32 link dropped functions referenced only through the call-table.
NURL closures take function addresses, which become wasm function-table indices; wasm-ld's default --gc-sections pruned/renumbered the table so a stored call_indirect index no longer mapped to its function — fine for small programs, but nurlc.wasm compiling a >150-function program trapped (call_indirect: index out of range). The wasm link now passes -Wl,--no-gc-sections to keep the table stable.
A portability + distributed-stack release. The runtime drops its last glibc-version-specific symbols so the toolchain builds and links on older-glibc targets again (notably Raspberry Pi OS aarch64), a silent net/relay group-multicast bug is fixed, and the new packages/swarm package turns the distributed stack into a compute cluster you join by installing it.
aarch64): undefined symbol: __isoc23_strtoul / __isoc23_strtol.** When the runtime is compiled with glibc ≥ 2.38 headers in C23 mode, strtoul / strtol / atoi / scanf resolve to the versioned symbols __isoc23_strtoul etc., which are baked into the shipped LTO runtime bitcode and then undefined when linked against a target with an older glibc. The runtime no longer calls any of them: a hand-rolled decimal parser (nurl__parse_ul) replaces strtoul/atoi, and nurl_read_int is rebuilt on getchar/ungetc instead of scanf. The runtime bitcode now references none of the __isoc23_* symbols, so it links cleanly across every glibc/musl version and target (verified by cross-linking for aarch64 against glibc 2.28).
net/relay group multicast silently dropped any non-32-byte group id.
GJOIN/GLEAVE carried the group id as a raw, variable-length body, but GSEND reused the fixed 32-byte pubkey split — so a group id that was not exactly 32 bytes was mis-parsed on the server (aliasing the payload) and the fanout was silently skipped. Group sends are now length-delimited ([gidlen:2][gid][payload]) via relay_gsend_gid / relay_gsend_payload, so a group id of any length round-trips. Regression test added in relay_codec (a 5-byte gid). Existing 32-byte users (replicated_counter, pttvoice) are unaffected.
net/relay server verbose mode. relay_server_set_verbose(rs, 1) (and a
new RelayServer.verbose field) logs each peer connect/disconnect as relay: + peer <id> / relay: - peer <id>. packages/swarm's relay exposes it as swarm relay <host> <port> --v / --verbose, so cluster membership is visible as workers come and go.
packages/swarm — a distributed compute cluster you join by installing it.
nurlpkg install swarm then swarm worker <relay> <port> makes a machine a live compute node: it announces itself over the relay group (HELLO census), every node folds it into the consistent-hash ring, and it takes its share of work. swarm submit … primes <lo> <hi> / sumsq <lo> <hi> shards a real numeric workload across the live workers (by key, via dist/ring + dist/job) and sums the partial results — verified live (π(10⁶) = 78498) and by an ASan-clean offline suite that pins exact sharding.
A language-maturity release. The static trait system reaches v1.0 (coherence, supertraits, associated types, a dyn-dispatch seam); Result gains a by-value representation; the toolchain gains first-class FreeBSD support (now a CI gate) and a local MCP server that exposes the compiler to LLM agents. The standard library absorbs argument parsing, letting the registry shed its argz and tls packages.
method##type registered once, position-idempotent re-scan, i/i64 collisions resolved); supertraits (% Sub : Super, whole-program-enforced and transitive); associated types (type Item in a trait, bound per impl and substituted into defaults); and a recorded dyn-dispatch vtable seam. Documented in spec §4.9 and the EBNF.
std/utf8 — a validating UTF-8 codepoint layer over NURL's byte strings
(rejects overlong / surrogate / out-of-range encodings, with U+FFFD recovery): utf8_valid / utf8_len / utf8_nth / codepoints / encode_cp / utf8_substr / … The byte-level core/string stays the fast default; the codepoint layer is opt-in.
Rc in a thread_spawn closure is now
a compile error (use Arc), catching that data race at compile time.
nurl-mcp — a local MCP server for the toolchain (registry package:
nurlpkg install nurl-mcp). Exposes the locally installed compiler to an LLM agent over the Model Context Protocol so it can build / run / type-check / format NURL against the host's real files and read the installed stdlib. Stdio by default; an optional token-authenticated HTTP transport gates code execution behind --allow-run and refuses a non-loopback bind without --token. The LLM-facing counterpart of nurl-lsp.
toolchain binaries are libc-only and the wrappers POSIX sh); CI now builds, bootstraps, and runs the full test corpus on a real FreeBSD 14 VM as a hard gate. docs/PLATFORMS.md is split into codegen targets vs. host platforms, and docs/BUILDING.md documents every CI gate.
Result !T E now lowers to { i1, T, E }. The Ok payload rides field 1
and the Err payload field 2, both by value, mirroring ?T → { i1, T }. A multi-field-struct success payload is no longer heap-boxed — the Ok path emits no nurl_alloc. Note: direct numeric access to the error payload moves from . r 1 to . r 2 (?? / \ matching constructs are unaffected).
std/args (flags, value
options, clustered shorts, --, positionals, an auto-generated --help); the registry CLIs (nq, md2html, chart, iforest) migrated onto it.
install-toolchain now installs nurlfmt, and its sourceable env file
is POSIX-sh-safe — the old ${BASH_SOURCE[0]} form aborted FreeBSD / Alpine /bin/sh with "Bad substitution". The release relinks nurlfmt against the old-glibc floor like the other shipped binaries.
tls registry package — pure-NURL TLS now lives in the stdlib, and
psql / redis depend on it directly.
argz and argz-demo packages — argument parsing is now
std/args.
A security-hardening release for the pure-NURL cryptography and TLS stack introduced in 0.10.0. A full adversarial audit of the OpenSSL-replacement code (documented in the new docs/CRYPTO.md) drove fixes across certificate-chain validation, the TLS 1.3 protocol guards, and the side-channel posture of every asymmetric and symmetric primitive. The elliptic-curve secret path is now constant-time including operand timing via a dedicated fixed-limb P-256 field, and the certificate chain now enforces Basic Constraints — closing an "any leaf certificate can sign for any host" authentication bypass.
BasicConstraints cA:TRUE on every signing certificate — closing a complete authentication bypass where any leaf certificate could mint a forged cert for any host (the classic Moxie Marlinspike break; is_ca was parsed but never read, and was in fact written to a by-value copy and discarded). Adds keyUsage keyCertSign / EKU serverAuth enforcement, pathLenConstraint, embedded-NUL dNSName rejection, tightened wildcard matching (*.com / *foo.com now rejected), iPAddress-SAN matching, a ≥2048-bit RSA-key floor, a presented-chain length cap, and issuer/subject DN name-chaining.
secret (RFC 8446 §7.4.2) on both client and server; check the §4.1.3 downgrade sentinel when a 1.3-capable client negotiates 1.2; fail closed on a CSPRNG failure; constant-time TLS 1.2 server-Finished comparison.
time (Affine(x⁻¹ in GF(2⁸)), no secret-indexed table — closing the cache-timing key-recovery leak); GHASH is branchless; the GCM and ChaCha20-Poly1305 entry points validate key/nonce lengths and enforce plaintext caps. Tag comparisons were already constant-time.
bigint_modpow is now a Montgomery
powering ladder with a uniform square-multiply trace independent of the secret exponent. The P-256 secret scalar multiply (ECDSA nonce, ECDHE) runs on a new dedicated fixed-limb constant-time GF(p256) field (std/p256_field), so it is constant-time including operand timing. RSA private-key signing adds base blinding (plus a signature range check and the PSS maskedDB check). Ed25519 verify rejects non-canonical encodings and S ≥ L (malleability); ECDSA verify and P-256 ECDH validate that peer points are on-curve (invalid-curve guard).
docs/CRYPTO.md): on thetiming/cache axis the EC and symmetric primitives are constant-time including operand timing, and RSA's residual operand timing is covered by base blinding. Physical power/EM (DPA/template) side-channels are out of scope for any pure-software stack. Revocation (OCSP/CRL) and X.509 name constraints are not checked.
std/p256_field.nu — a dedicated fixed-16-limb constant-time GF(p256)
field (Montgomery CIOS multiply, conditional-±p add/sub, Fermat inverse) and constant-time P-256 point arithmetic via the Renes–Costello–Batina complete addition formula. Cross-checked against the bigint reference (2000 cases) and Python cryptography (500/300 cases); new corpus test p256_ct_field.
docs/CRYPTO.md — documents the pure-NURL crypto/TLS stack: implementedprimitives, the side-channel taxonomy, TLS 1.3 controls, X.509 verification, and the soundness contract. Linked from the README.
bigint_modinv (modular inverse) and bigint_cselect (constant-time select)
in std/bigint.nu.
tls_accept_rsa now takes the RSA public exponent (argument order n e d)
so the server's CertificateVerify signature can be base-blinded; net.nu's TLS listener threads the exponent through. Other public TLS APIs are unchanged.
rather than "byte-for-byte" — a greedy LZ77 encoder produces a valid but not bit-identical stream versus zlib.
The self-sufficiency release. NURL no longer depends on any third-party C library at its core: the entire external-library surface — libcurl, libssl/libcrypto, libpq, and libz — has been removed from the runtime and replaced with pure-NURL implementations in the standard library. A default ./build.sh now links libc only (plus libm). The same self-contained stdlib provides TLS 1.3 (client and server), cryptography, HTTP/1.1, and DEFLATE/gzip/zlib — written in NURL, verified against the libraries they replace by known-answer vectors and round-trip interop (our DEFLATE output is a valid, if not byte-identical, stream — a greedy LZ77 encoder compresses differently from zlib), and clean under AddressSanitizer.
Alongside the dependency purge: a new pure-NURL redis client, a compiler type-checking sweep that converts several silent miscompiles into clear compile-time errors, and an O(n²) → O(1) symbol-table speedup that keeps whole-program compilation fast now that HTTP pulls in the full TLS+crypto closure.
Toolchain requirement: the minimum supported compiler is now clang / LLVM 15+ (was 14+). LLVM 15 emits opaque-pointer IR by default, so the build no longer needs the -opaque-pointers shim for older clangs.
std/tls.nu,
std/tls_verify.nu) — promoted from the tls package so stdlib HTTP/TLS consumers (anthropic, mcp_http/mcp_client, cluster, http_json, http2_client) can build on it. The tls package is now a thin re-export facade for backward compatibility. (§8 P0)
std/tls_server.nu) — full
ServerHello / EncryptedExtensions / Certificate / CertificateVerify (ECDSA P-256) / Finished handshake over tcp_accept, EC P-256 private-key PEM loading, ALPN (h2) negotiation, and an in-place STARTTLS upgrade. net.nu's TcpConn is now polymorphic (plain / TLS-client / TLS-server) so http_server gets clean HTTPS. (§8 P4)
signing (rsa_pss_sign_sha256), PKCS#1/#8 key parsing (rsa_priv_from_pem), cert-chain transmission, and tls_accept_rsa; net.nu auto-detects EC vs RSA and frames fullchain.pem. (PR #289)
AES-256-GCM (std/aes_gcm.nu), Ed25519 sign/verify (std/ed25519.nu, a TweetNaCl port reusing the x25519 field arithmetic), scrypt (std/scrypt.nu, RFC 7914), PBKDF2-HMAC-SHA512, and ECDSA P-256 signing (RFC 6979). All KAT-verified, ASan/LSan-clean. (§8 P1/P2/P4)
ext/http_pure.nu) over the stdlib TLS —
one transport+parser driving both the buffered http_request/http_get family and the pull-based http_stream_* API: incremental chunked decoder (live SSE streaming), Content-Length, redirects (301/302/303/ 307/308), all methods, binary bodies. (§8 P3)
std/deflate.nu) — RFC 1951
inflate (puff.c port) + greedy-LZ77 deflate, crc32/adler32, and streaming inflate_stream / deflate_block_dict for permessage-deflate context takeover. ext/compress.nu is rewritten over it (zlib_*/gzip_*/raw_deflate_*). (§8 P6)
redis package — a pure-NURL Redis client speaking RESP2 over a
libc TCP socket, with optional TLS (rediss://) via the pure tls package, so the binary links libc only. Flat-arena RESP2 codec (src/resp.nu), a typed surface (strings/lists/hashes/sets, INCR/ EXPIRE/TTL/KEYS, full pub/sub), and a redis-cli-style CLI with a REPL and SUBSCRIBE/PSUBSCRIBE streaming. Validated live against redis:7 (20/20, ASan-clean).
ext/crypto.nu is now a thin facade over the pure crypto modules —
the OpenSSL EVP FFI layer (~35 bindings) is gone; the public API and CryptoErr/CryptoKeypair types are unchanged, so noise/session/ jwt/http_jwt consumers compile untouched. (§8 P2)
mqtt, websocket, http2, smtp) now
route through net.nu's pure tcp_connect_tls[_alpn] / tcp_starttls instead of the OpenSSL-backed runtime helpers. (§8 P4)
nurl_sym_get was a backward linear scan over an append-only table, so whole-program compilation was O(n²). Now O(1) average lookups with identical semantics (byte-identical IR, bootstrap fixed point holds). Effect: compiling examples/http_basic.nu 11.0s → 0.59s, the examples sweep 8m11s → 25s, the corpus stage 7m13s → 1m11s.
reject older clangs with install guidance; the -opaque-pointers shim for clang 13/14 is removed.
libcurl — the entire C HTTP-client backend (libcurl bridge +
WinHTTP + stubs, ~850 lines) is deleted from runtime.c; -lcurl and its detection are gone from the build. (§8 P3)
libssl / libcrypto — all SSL code (the dlopen vtable + ~250
SSL_* call sites, −801 lines) is deleted from runtime.c; `-lssl -lcrypto and NURL_HAVE_OPENSSL` are gone from the build. (§8 P4)
libpq — ext/postgres.nu (47 FFI bindings) and its examples/test
are removed; the pure-NURL psql package is the replacement. (§8 P5)
libz — nurl_z_* + the z_stream ABI are deleted from
runtime.c; -lz and NURL_HAVE_ZLIB are gone from the build. (§8 P6)
./build.sh produces binaries whose NEEDED is
libc.so.6 only (plus libm). libzstd and libsqlite3 remain optional, behind runtime sentinels and -Wl,--as-needed, so a program that does not use them still links libc only. (§8 P7)
"nurlc accepts → clang rejects late (or silently miscompiles)" gaps into clear compile-time errors. Diagnostic-only: IR for valid programs is byte-identical and the bootstrap fixed point holds. - String vs s (i8*) argument mismatch — passing a raw C-string where a String parameter is declared (or vice versa) was silent wild-pointer UB (it surfaced as a SEGV in net.nu's cert-chain code). Now rejected at the call site with a fix-it (string_data / string_from). - Wrong named struct by value — passing a B where an A is declared by value type-checked clean and let the callee read the foreign struct's leading fields. Now rejected at the call site, in return position (^ b from a → A fn), and in struct literals (bad field type or too many fields). - ^ value from a → v (void) function — silently lowered to ret i64 … out of a void LLVM function. Now a clear error, and a bare ^ is supported as an early return in void functions. - Reassignment and field stores (= name v, = . obj field v) — a float-into-i, wrong-struct, or String-vs-s store emitted a type-mismatched store that only clang caught. Now rejected (integer width / signedness / pointer-stash coercions stay legal). - !b / ?b bool payloads in result/option matches were extracted as i64 but used as i1 (clang rejected the IR); a b branch now truncates the payload back to i1.
nurl_read_password (added in 0.9.19)
included <termios.h> on every non-Windows target, which broke the wasm32-wasi compile (no termios) used by the playground / API image. It now uses a WASI fallback branch (echoed read) alongside the POSIX termios and Windows console paths; native behaviour is unchanged.
A usability pass on the pure-NURL psql client: it gains a real psql-style front end — aligned result tables, a multi-line REPL, and backslash meta-commands — and now prompts for a password when the server requires one, so it connects to password-protected servers out of the box instead of failing with an opaque error. The cross-platform hidden password entry is provided by a new runtime helper, nurl_read_password.
nurl_read_password — prompt on stderr and read a line from
the terminal with echo disabled (termios on POSIX, SetConsoleMode on Windows), restoring the prior console state. The cross-platform home for password entry, so packages need not declare platform-specific console symbols themselves.
psql: prompt for a password when the server requires one. Like the
real psql, it now reads the password interactively (echo disabled) when the server requests authentication and neither $PGPASSWORD nor a URL password was supplied — instead of sending an empty password and failing with PgServerError. Trust-authenticated servers are still connected without a prompt.
psql: a --help / -? flag. Bare psql again attempts a default
(localhost) connection like other psql clients; only an explicit --help prints usage.
psql package — a proper psql-style front end. The command now
renders results as aligned tables (numeric columns right-justified, NULLs blank, an (N rows) footer), runs a multi-line REPL that accumulates a statement until its ; terminator, shows prompts and a banner only on a terminal, and supports backslash meta-commands (\dt, \d TABLE, \dn, \l, \du, \conninfo, \?, \q). It captures the server version and the connection identity for the banner and \conninfo (which reports whether the link is TLS-encrypted). All of this stays pure-NURL — the binary still links libc only.
psql: a per-query memory leak. Each pg_query overwrote theresult's command-tag string without freeing the previous one, leaking one allocation per statement over a long session. The REPL is now free of per-operation leaks.
psql: a connection leak on authentication failure. pg_connectreturned the error without closing the socket or freeing the connection; it now closes on the failure path (which the password-retry flow relies on).
Portability fixes surfaced by real-world installs of the v0.9.17 toolchain: a program built on a non-x86_64 release could fail to link against OpenSSL, and the pure-NURL TLS client could not reach TLS-1.2-over-P-256 servers.
libc-only on every platform — OpenSSL is
loaded at runtime via dlopen.** The shipped runtime.o is built with OpenSSL, so it referenced SSL_* / X509_* / CRYPTO_*. A program that doesn't use runtime TLS only linked libc-only if the linker dead-stripped those references — which clang/lld LTO does on x86_64 but the bundled-zig portable builds do not, so nurlpkg install of any net.nu-importing package (e.g. tls) failed on linux-arm64-glibc with dozens of undefined symbol: SSL_new / TLS_server_method / …. OpenSSL is now resolved entirely through dlopen/dlsym, so runtime.o carries zero link-time references to libssl/libcrypto: every program links libc-only regardless of dead-code elimination, and libssl is loaded lazily the first time a TLS connection is created (its absence becomes a clean TLS_CTX_INIT error rather than a link failure). On Linux the link adds -ldl (dlopen lives in libdl below glibc 2.34; --as-needed drops it where unused); FreeBSD/macOS keep dlopen in libc, and Windows is unaffected (it uses WinHTTP, not OpenSSL).
TLS 1.2 fallback only did X25519 ECDHE, so a TLS-1.2 server negotiating ECDHE over prime256v1 (the OpenSSL default ssl_ecdh_curve) failed with TlsHandshake. The 1.2 handshake now reads the named_curve from the ServerKeyExchange and does P-256 ECDHE as well, matching the TLS 1.3 path. (tls package republished as 0.1.1.)
A complete pure-NURL cryptography and TLS stack, and on top of it a TLS client and a PostgreSQL client that need nothing installed on the target — no OpenSSL, no libpq. Every primitive is implemented from scratch in NURL and validated against its RFC/NIST known-answer vectors; the resulting clients link libc only. Also ships the chart data-visualisation package and makes the runtime's libssl an optional dependency.
chart package — terminal data-visualisation (sparklines, barcharts, histograms, line plots) as both a CLI and a reusable library.
stdlib/std/, each validated against its
RFC/NIST known-answer vectors and ASan-clean: - X25519 key exchange (RFC 7748) — x25519.nu. - ChaCha20-Poly1305 AEAD (RFC 8439) — chacha20poly1305.nu. - HKDF + TLS 1.3 key-schedule helpers (RFC 5869 / RFC 8446 §7.1) — hkdf.nu. - RSA signature verification — PKCS#1 v1.5 and PSS — plus bigint modpow / from_bytes_be / to_bytes_be — rsa.nu, bigint.nu. - ECDSA verification on NIST P-256 and P-384 — ecdsa_p256.nu. - AES-128-GCM AEAD (NIST SP 800-38D) — aes_gcm.nu. - X.509 / DER certificate parser (TBS, signature algorithm, SPKI, validity, SAN, CA flag) with RFC 6125 hostname matching — x509.nu. - PBKDF2-HMAC-SHA-256 (RFC 8018) — pbkdf2.nu. - P-256 ECDH (p256_ecdh_keygen / p256_ecdh_shared) — ecdsa_p256.nu.
tls package — a pure-NURL TLS 1.3 client (RFC 8446) with TLS 1.2
fallback.** No OpenSSL and no FFI beyond the libc TCP socket. Negotiates ChaCha20-Poly1305 and AES-128-GCM over X25519 and NIST P-256 key exchange. verify-full by default: the CertificateVerify signature, the certificate chain up to the system trust store, the validity window and the hostname are all checked (TlsBadCert otherwise). tls_attach runs the handshake over an already-connected socket for STARTTLS-style upgrades. Ships a tlsget HTTPS CLI. Verified live against example.com, google.com and cloudflare.com.
psql package — a pure-NURL PostgreSQL client. The version-3 wire
protocol, authentication (trust / cleartext / MD5 / SCRAM-SHA-256) and an optional TLS transport over the pure-NURL stack — **no libpq, no OpenSSL**. A secure, authenticated connection works on a host with nothing installed; the produced binary's NEEDED is libc only. Installable with nurlpkg install psql; usable as a library (pg_connect / pg_query). Verified against a live PostgreSQL 16.
$-import now resolves
relative to the importing file first (then the working directory, then $NURL_STDLIB), so a multi-file package can reference its own modules by bare name and build identically standalone, from the monorepo, and as a deps/<name> dependency. Purely additive — the self-host bootstrap is byte-identical.
libssl is now an optional runtime dependency. The OpenSSL hot-path
symbols (SSL_read / SSL_write / …) are routed through a lazily-installed, volatile function-pointer vtable, so a program that never opens a runtime OpenSSL connection — a pure-NURL-TLS client or a plaintext-TCP program — links libc only. (volatile is required: without it, full LTO constant-folds the addresses back into the live readers and re-pins libssl.) A program that does use runtime TLS re-links libssl correctly.
declares by symbol name. Two imported
modules may legitimately declare the same libc/runtime extern (e.g. nurl_rand_fill in both std/random.nu and the tls package); the duplicate IR line is now suppressed instead of failing the build with LLVM's "invalid redefinition of function". Generalises the existing prelude-symbol skip; purely additive — only fires when a duplicate would otherwise error, so the bootstrap is byte-identical.
nurl_fiber_yield pushed
the fiber onto the run queue before swapcontext returned, so another worker could steal, run and free it while the worker loop still read f->state. The push moved out of nurl_fiber_yield into the worker loop's runnable branch, after the context is saved. Surfaced intermittently by the sanitized test suite (async_chan); 0 failures under stress after the fix.
Makes the Windows toolchain actually usable. v0.9.15 shipped a Windows archive that installed but couldn't build anything; this release makes it self-contained and fixes the toolchain bugs that surfaced behind it.
runtime.o is
built with -DNURL_HAVE_ZLIB, so it references zlib and EVERY program link needs it — but runtime.winlibs recorded the CI build machine's absolute vcpkg path (C:\vcpkg\…\zs.lib), so on a user's box every nurlpkg install/build died with lld-link: could not open 'zs.lib'. build.bat now copies the resolved static libs into stdlib\winlib\ and records a relocatable fragment that nurl.bat resolves against the install prefix — the toolchain links against its own bundled zlib/zstd with no vcpkg on the box.
nurlpkg install on Windows. nurlpkg copied the built binary from
pkgdir/.nurl-bin, but the Windows driver emits .nurl-bin.exe, so the copy silently failed with failed to install binary. Fixed by appending .exe on Windows.
$-import. __ffi_lib_check
looked for stdlib/runtime.<lib> relative to the current directory, so a program importing stdlib/ext/compress.nu failed to compile from any directory other than the stdlib tree with a bogus "no build-time sentinel" error. It now resolves CWD-first then $NURL_STDLIB, matching how $-imports resolve — so an installed toolchain finds its shipped sentinel from anywhere. (Control-flow only; the self-host bootstrap is unaffected.)
nurlc --version / nurlpkg --version on Windows reported unknown:
build.bat never generated stdlib/nurl_version_gen.h. Added tools/version.bat (Windows counterpart of tools/version.sh) and wired it into build.bat, so the real version is baked into runtime.o.
(irm https://nurl-lang.org/install.ps1 | iex), alongside the Linux / FreeBSD curl | sh command.
nurl-<tag>-windows-x86_64.zip, built natively on windows-latest in CI, so the one-line installer works on Windows out of the box (irm https://nurl-lang.org/install.ps1 | iex). The website install card gains a labelled Windows PowerShell one-liner next to the Linux / FreeBSD curl | sh command. The Windows release job is no longer best-effort — with the build fixed below it drops continue-on-error, so a future Windows break fails the run instead of being silently swallowed.
build.bat wrote the -lzlib link
fragment whenever zlib.h was present, without checking that a matching .lib actually existed — so nurlpkg failed to link with LNK1181: cannot open input file 'zlib.lib'. The vcpkg zlib static-lib name is version-dependent (1.3.1 → zlib.lib, but 1.3.2 adopted zlib's new CMake and ships zs.lib, whose zlib.pc is rewritten -lz → -lzs), which is why the build passed on local boxes yet only broke in CI. build.bat now probes the lib directory for the actual file, derives the -l<name> from it, and enables zlib only when a linkable lib is present (same name probe for zstd).
nurl-<tag>-freebsd-x86_64.tar.gz, built on FreeBSD 14 in CI, so the one-line installer works on FreeBSD out of the box (curl -fsSL https://nurl-lang.org/install.sh | sh). The shipped binaries depend on libc.so.7 only; FreeBSD's base clang drives nurlpkg install, so no compiler is bundled. get-nurl.sh detects FreeBSD via uname -s. Validated end-to-end on real hardware: `nurlpkg install argz-demo && argz-demo --shout hi`.
self-hosting fixed point, and runs the test corpus on genuine FreeBSD — the gate that caught the two FreeBSD bugs fixed below.
nurlc --version / nurlpkg --version / nurl --version. The version
is derived at build time by tools/version.sh (git describe → v0.9.14 on a release tag, v0.9.13-2-gabc-dirty on a dev checkout; falls back to the top CHANGELOG.md entry for a git-less source tree) and baked into runtime.o via a generated, git-ignored header (stdlib/nurl_version_gen.h). Nothing is hardcoded, and it flows in automatically both in releases and local builds. Because the string lives in runtime.o and not in nurlc.nu's IR, the self-hosting fixed point and the committed bootstrap snapshot are unaffected by a version bump.
scripts (nurl.sh and the nurl / nurlc / nurlpkg shims) were #!/usr/bin/env bash, so on a stock FreeBSD / Alpine / busybox box — where the binaries are libc-only but bash is absent — the first build died with env: bash: No such file or directory. They are now POSIX sh, validated under dash on Linux and end-to-end on a bash-less FreeBSD 14.3 box.
glibc/macOS, so on FreeBSD (which has full ucontext) it silently fell back to a no-op stub and fibers never ran. Now enabled on FreeBSD / NetBSD / DragonFly.
HttpInvalidUrl on a no-libcurl build (was HttpOther),matching the libcurl and WinHTTP backends — input validation that no longer depends on which HTTP backend is compiled in.
z_stream ABI helpers decoupled from the zlib build flag, so a runtime
linked against libz without zlib.h at compile time still reports the correct struct size (an ABI replica pinned by _Static_assert).
run_tests.sh no longer uses GNU
sed -i (mis-parsed by BSD sed); FFI-dependent tests and examples now self-skip when their optional library (sqlite3 / libpq / …) is absent instead of failing the suite.
source ~/.nurl/env. The shims self-locate the
stdlib, so export PATH="$HOME/.nurl/bin:$PATH" is the whole setup and works in any shell. The website one-line install is updated to match and gains a Copy button.
A one-line follow-up to v0.9.12 that makes the bundled-zig build actually work on a fresh box.
installed and ran on a Raspberry Pi 4 / ODROID, but any build — including nurlpkg install <tool> — died with a bare compile failed:. The feature-lib availability probe ended each check with probe && EXTRA_LIBS+=(…), so a failed probe made the helper return non-zero and the build's set -e aborted at the first unavailable library. A fresh box typically has the runtime libcurl.so.4 but not the -dev libcurl.so linker symlink, so -lcurl (and openssl/sqlite/pq/ zstd) won't link and every probe failed — killing the build before the link step. The probe now uses an explicit if and is total, so an unavailable library is simply skipped. Verified on a real Raspberry Pi 4 (glibc 2.31) and ODROID: `nurlpkg install argz-demo && argz-demo --shout hi → HELLO, HI!`.
The "bombproof install" release: the toolchain now installs and builds on old or minimal Linux boxes — a fresh Raspberry Pi / ODROID can run the curl … | sh one-liner and nurlpkg install <tool> straight through, with no system compiler and no surprise library requirements.
zig build backend. The archive ships a self-contained
zig; nurl.sh uses zig cc to lower nurlc's LLVM IR to a native binary. zig carries its own modern LLVM (so the opaque-pointer IR just parses), its own lld linker, and libc headers — so **building a program (and nurlpkg install) needs no system clang at all** and is immune to the box's LLVM version. Falls back to a system clang (with -opaque-pointers on clang 13/14) when no bundled zig is present.
nurlc/nurlpkg were built
on a glibc-2.39 runner and failed to start on e.g. Raspberry Pi OS bullseye (glibc 2.31) with version 'GLIBC_2.34' not found. They are now relinked with the bundled zig against an old glibc floor (tools/relink-toolchain-portable.sh), landing at GLIBC_2.25 — which covers the Pi and essentially every Linux since ~2017.
nurl.sh probes
each back-end library (libcurl / OpenSSL / sqlite3 / libpq / zlib / zstd) and links it only if it is present on the box; under LTO + --as-needed an unused one is dropped anyway. A feature-free program — the common registry tool — links against libc only and never demands a library the box lacks (an unconditional -lpq previously broke a hello-world where the Postgres client was absent).
prints install guidance rather than clang: command not found; the installer smoke-tests the unpacked binaries and, on a missing shared library, names the .so with a package-manager hint.
The "dependency-free toolchain + registry programs" release: the installed nurlc and nurlpkg now link libc only (no inherited libpq / libcurl / libsqlite3 / …), several real programs landed on the package registry, and the whole source tree is now held to canonical nurlfmt form by CI.
permessage-deflate (RFC 7692), server and client. Per-message
compression negotiated over Sec-WebSocket-Extensions, with both context- takeover directions and peer-imposed window bounds honoured. New surface in stdlib/ext/websocket.nu: extension negotiation (ws_deflate_parse_extensions, ws_deflate_offer_header, ws_deflate_response_header), a WsDeflate context (ws_deflate_make / ws_deflate_free), deflate-aware messaging (ws_send_text_deflate / ws_send_binary_deflate / ws_read_message_deflate / ws_serve_messages_deflate and their ws_client_* mirrors), and one-shot handshake helpers (ws_perform_handshake_deflate, ws_connect_deflate). The frame reader now surfaces the RSV1 compressed bit (WsFrame.rsv1, WsMessage.compressed) — still rejected on the non-deflate readers, so the change is fully backward-compatible. Decompression is capped against WsLimits.max_message_bytes (a decompression-bomb guard) and text payloads are UTF-8-validated after inflation.
stdlib/ext/compress.nu (the engine the
above rides on): raw_deflate_* / raw_inflate_* drive a persistent z_stream with raw (header-less) DEFLATE via negative windowBits, Z_SYNC_FLUSH, sliding-window context takeover, and *_reset. Four new layout-absorbing nurl_z_* accessors in runtime.c let the NURL-side loop rebind input/output across the persistent stream. Regression tests compress_rawdeflate.nu and ws_permessage_deflate.nu.
written in NURL and published to reg.nurl-lang.org: nq (a jq-lite JSON query tool), md2html (a Markdown → HTML converter, CLI + library), and iforest (Isolation Forest anomaly detection, CLI + library). argz / argz-demo gained READMEs and published 0.1.1.
renders the README from its tarball (Markdown → HTML done in TypeScript, XSS-safe).
nurlweb auto-generates release-coupled facts and serves the installer.
Site facts (version, line/test/module counts) are generated from the repo state instead of being hand-maintained, and nurl-lang.org serves the installer scripts so the curl … | sh one-liner works.
nurlc and nurlpkg link libc only. A
fresh install could die immediately with error while loading shared libraries: libpq.so.5, even though nurlpkg never touches Postgres: the monolithic runtime.o carries every FFI back-end, and the link lines named them all unconditionally with no --as-needed, so each binary inherited whatever the build machine had as a hard DT_NEEDED. Every native link line now passes -Wl,--as-needed (a binary keeps a dependency only for a library it actually references; LTO drops the dead back-end code first), and nurlpkg reaches the registry through the system curl binary (new stdlib/ext/http_cli.nu) with zlib + zstd linked statically — so both nurlc and nurlpkg now depend on libc only. The installer (get-nurl.sh) also smoke-tests the unpacked binaries and reports any missing shared library with a package-manager hint.
nurlc: Vec[T] indexing when T has a field named like the indexvariable.** A struct-pointer field access could be mis-resolved as an array index (and vice-versa) when a local index variable shared a name with a struct field; the field/element disambiguation is now correct.
server hangs-but-keeps-running, the Cloudflare container Worker now detects the "not listening" wedge and restarts it instead of serving sticky 500s.
nurlfmt
and a nurlfmt --check CI gate now rejects any non-canonical first-party .nu file (the formatter is IR-transparent, so this changed no behaviour).
The "NURL becomes an ecosystem" release: the toolchain is now installable, the package registry can host and install real programs, and tagged releases ship install packages for Linux and Windows.
$NURL_STDLIB import root (the compiler is relocatable). nurlc
resolved every $ … import path relative to the current working directory with no notion of an installed stdlib, so a package could not reference stdlib/… outside the monorepo. __norm_import_path now falls back to $NURL_STDLIB/<path> (via libc getenv) when a cwd-relative hit misses; the cwd hit always wins, keeping the bootstrap byte-identical. This is what lets an installed compiler — and registry-installed packages — find the shipped stdlib from any directory.
nurlpkg install <name> — install a program from the registry. The
cargo install-shaped sibling of bare install: fetch a published package, resolve its dependencies, compile its src/main.nu against the installed stdlib, and drop the binary in $NURL_HOME/bin (default ~/.nurl/bin). A package with src/main.nu is an installable program (binary = package name); without it, a library. Shell-free and cross-platform: staging via the language's own filesystem primitives under the platform temp dir, in-process dependency resolution, and a build-driver spawn that wraps .bat with cmd /c on Windows.
tools/install-toolchain.sh / .bat. Install
nurlc, nurlpkg, and the stdlib into a self-contained prefix (default ~/.nurl) and wire up NURL_STDLIB + PATH so the whole toolchain works from any directory. The shims and env are relocatable (they resolve the prefix from their own location), so a downloaded archive works wherever it is unpacked.
.github/workflows/release.yml. On a v* tag,
build the toolchain natively for linux-x86_64-glibc (ubuntu-latest), linux-arm64-glibc (ubuntu-24.04-arm), and windows-x86_64 (windows-latest), package each as a relocatable archive + .sha256, and attach them to the GitHub Release. workflow_dispatch gives a publish-free dry run.
tools/get-nurl.sh / get-nurl.ps1. The
curl -fsSL https://nurl-lang.org/install.sh | sh front door: detect OS/arch, resolve the latest (or pinned) release, download the matching archive, verify its SHA-256, and unpack the toolchain into $NURL_HOME. $NURL_INSTALL_BASE overrides the download base for internal mirrors.
packages/argz + packages/argz-demo.
argz is a tiny, dependency-free command-line argument parser (boolean flags, value options, short aliases, -- separator, positional arguments, auto-generated --help), leak-clean under AddressSanitizer/LeakSanitizer. argz-demo is an installable greeter that depends on argz = "^0.1". Both are published to reg.nurl-lang.org. tools/nurlpkg/test-install-tool.sh drives the full fetch → build → install → run loop against a local static registry.
LSP's workspace indexer read imported files with the compiler's nurl_read_file, which calls exit(1) on a missing file (a missing import is fatal to a compile, but must never be fatal to the server). Opening a file whose imports don't resolve — e.g. a package whose registry dependencies aren't installed yet — killed the whole language server. It now probes with file_exists first (__read_if_exists).
getenv declaration removed from env.nu. Now that the
compiler globally declares getenv (for import-path resolution), env.nu re-declaring it via the & FFI form emitted two declare @getenv lines and failed to link; env.nu relies on the compiler-provided declaration, matching how fopen/access are handled.
RELEASING.md — the distribution model (GitHub Releases + a curl|shfront door served from nurl-lang.org; the registry stays the package registry), how to cut a release, runtime dependencies, and the Windows/macOS caveats.
packages/README.md — the two registry packages and the fullinstall-and-run loop on POSIX and Windows.
i and u64). Generic code using signedness-sensitive operations (like /, %, >>, or comparison operators) now monomorphises correctly based on the concrete type argument's signedness. Previously, types with the same LLVM width (e.g. i and u64) shared the same mangled slug, causing the compiler to reuse the first generated monomorphisation (e.g., executing udiv instead of sdiv). Mangles generic type arguments from their source word (u8/u16/u32/u64 vs i64) to force distinct instantiations.
Regression: compiler/tests/generic_signedness_mono.nu.
# i ( f … )) of generic call results now zero-extend (instead of sign-extend) the result when the function's declared return type is unsigned. The return signedness of the monomorphised instantiation is now tracked and resolved at the call site.
Regression: compiler/tests/unsigned_call_result_widen.nu.
i64 max parsed correctly in u64 range. The decimal lexer now accumulates digits using wrapping 64-bit arithmetic instead of atoll (which silently saturated at LLONG_MAX for literals in [2^63, 2^64)).
Regression: compiler/tests/u64_literal_parsing.nu.
~ x container { … } loops now correctly inherit the container's unsigned type (recovered from the vector/slice metadata), ensuring signedness-sensitive operations and widening casts inside the loop execute with unsigned semantics.
Regression: compiler/tests/foreach_unsigned_element.nu.
!=). Float != comparisons now emit fcmp une (unordered-or-not-equal) instead of fcmp one (ordered, not-equal), correctly returning true when either or both operands are NaN.
Regression: compiler/tests/float_ne_nan.nu.
! T E now correctly propagates T's unsigned flag to the Ok-arm (T v) binding inside the match block, correcting signedness-sensitive operations on the payload.
Regression: compiler/tests/result_payload_unsigned.nu.
F e) binding now correctly inherits E's unsigned flag from a Result ! T E type.
Regression: compiler/tests/result_err_arm_unsigned.nu.
ensure_struct_instantiated now propagate the unsigned flag, preventing unsigned fields of generic struct instances from being treated as signed on field access (e.g. . p field).
Regression: compiler/tests/generic_struct_field_unsigned.nu.
f32 struct fields. Float literals (always parsed as double) are now correctly truncated to float (via fptrunc) when initializing f32 fields of structs/aggregates, resolving "insertvalue operand and field disagree in type" compilation errors.
Regression: compiler/tests/f32_struct_field_literal.nu.
f32 payload construction support. Float payloads constructed for f32 enum variants now correctly perform float narrowing (fptrunc) from double literals or values before insertion, allowing float-payload enums to round-trip.
Regression: compiler/tests/enum_f32_payload.nu.
- A binary operator mixing a bool (i1) and a non-bool operand, both non-constant (< flag n with n : i), emitted a width-mismatched icmp i1 %flag, %n that only clang/llvm-as rejected. gen_binary now rejects it. A constant operand is still fine — it reinterprets to the other side's width — so == flag 0 (int literal fits i1) and == v T (bool literal fits i64) keep compiling; only two disagreeing registers are flagged. - Binding a payload while matching a non-aggregate scalar (`?? n { T a → … } with n : i) emitted an extractvalue on the scalar. gen_match` now rejects payload binding unless the scrutinee is an enum / option / result; integer-literal arms and bare tag matches are unaffected. Locks should_fail_binop_bool_int, should_fail_match_payload_scalar. (One deeply-contrived fuzz holdout remains: a mutation that makes a match scrutinee's declared type claim an option/result while its actual SSA value is a scalar — an inconsistency no real program produces.)
?? arm payload arity is enforced (fuzz follow-up #3). An
Option (? T) or Result (! T E) match arm binds at most one payload — the T-arm value / Ok payload, or the F-arm error. Binding more (`?? o { T a b → … }) used to emit an out-of-range extractvalue { i1, T } v, 2` that nurlc accepted (rc 0) and only clang/llvm-as rejected. gen_match now reports *"match arm binds N payloads but an option/result 'T' arm binds at most one …"*. (Enum variants already validated per-variant payload arity; this closes the opt/res case, completing the in-?? out-of-range-extractvalue class.) Locks should_fail_match_opt_overbind.
mutation fuzzer's remaining BAD_IR shapes (nurlc rc 0, only clang/llvm-as rejecting) were turned into source diagnostics — and one of the new checks caught a genuine latent bug in the corpus: - A function / FFI symbol name used where a type is expected now reports "unknown type 'X'". The earlier unknown-type check accepted any name in scope; it now requires a %-type, so @ f rand x → i (using the FFI symbol rand as a parameter type) is rejected too. - . field/element access on a non-aggregate scalar (. n x with n : i) is rejected — it used to emit extractvalue i64 …. - An out-of-range integer index into an aggregate (. p 9 on a 2-field struct) is rejected — it used to emit an invalid extractvalue …, 9. - Accessing a field a struct does not have (. p nope) now reports "type 'P' has no field 'nope'" instead of silently reading field 0 (a miscompile) — this exposed and fixed a real . pho req typo in compiler/tests/websocket_client.nu that had been reading the right field (head, index 0) only by luck. - An FFI declaration whose @ is not followed by an identifier (& "lib" @ @ foo) is rejected with "expected the C function name …". Locks `should_fail_{type_is_fn_name, member_on_scalar, member_index_oob, unknown_field, ffi_no_name}. (Remaining, deferred: a ??` arm that binds more payloads than its variant/option has still emits an out-of-range extractvalue — a gen_match concern for a future round.)
undeclared type identifier in a type position — an FFI parameter/return type, a function parameter/return type, or a struct field type — used to leak into the IR as an undefined %Name that nurlc emitted with status 0 and only clang / llvm-as rejected ("use of undefined type named 'X'", or the cryptic "cannot allocate unsized type"). A typo'd type name or a missing $ import now produces "unknown type 'X' … (a typo, or a missing '$' import?)" pointing at the use. The new check_type_known scans the emitted LLVM type for %Name references and verifies each against the pre-scan type registry; generic type variables (tparam-like, substituted at monomorphisation) and compiler-mangled names (containing __, e.g. a %Vec__i64 instantiation or an aliased import) are accepted unchanged, so generics and imports are unaffected. Locks should_fail_unknown_type_{ffi,param,return,field}.
A corpus-seeded mutation fuzzer (36k + 15k mutants over build/nurlc; oracle: never crash, never hang, and rc 0 ⇒ the IR assembles) found zero crashes but a class of infinite loops: several body-parsing loops checked only for their closing token (} / ) / ]) and not for end-of-input, so an unterminated construct spun forever on a no-op nurl_lex_advance at EOF instead of erroring. A playground user mid-keystroke (or any truncated file) could wedge the compiler. Fixed by adding an EOF guard to every such loop and letting the trailing expect report a clean "expected '}' / ')' / ']' but found end of input": - enum-variant loop (: | E { A), - trait/impl scan + gen loops and the method-signature skip (% Shape { @ area i s → i, % Shape i { …), - the [T] type-param skip, and - parse_type_paren's generic-application and closure-type argument loops (( Vec i, reachable in any type position). Struct / match / block / call-argument bodies already terminated (their inner sub-parsers hit EOF first) and were left unchanged. 15k post-fix mutants produce no hangs and no crashes. Locks should_fail_unterminated_enum, _trait, _impl, _type_paren.
sweep #3).** A sharper probing pass found that a whole family of trivial type errors was accepted by nurlc (rc 0, no diagnostic) and emitted IR that only clang rejected — the "where is the type checker?" optics. All are now caught at the source, with no implicit conversions introduced (NURL stays explicit): - Binary-operator operand mismatch — mixing a float and a non-float in any operator (+ 1 1.0, * 2.0 3, == 1 1.0), or a pointer/string and an integer in an arithmetic op (+ a 1). gen_binary enforces the "operands share a type" invariant it already documented. Pointer comparisons (== ptr 0, ptr↔ptr) stay exempt via the existing ptrtoint coercion. Locks should_fail_binop_int_float, should_fail_binop_ptr_int. - Binding initialiser / assignment mismatch — : i x 1.5, : i x hi, = n 1.5. coerce_store_val now rejects the never-valid float/non-float and pointer-into-non-pointer store clashes after its real coercions (i1-widen, enum-wrap, single-handle, int-width) have had their say; the null-as-0 idiom (: *T p 0) stays valid. Locks should_fail_let_type_mismatch, should_fail_assign_type_mismatch. - Return-value type mismatch — ^ hi / ^ 1.5 from a → i function. gen_ret checks the returned value's type against the declared return type (same narrow never-valid directions). Locks should_fail_return_type_mismatch. - Void/unit value (v) stored or bound — : i y v, = x v (the bare type keyword leaking into a value position). Locks should_fail_store_void; the operator/complement/not sinks were already guarded. - Recursive struct of infinite size — : Node { i v Node next } (a by-value self-reference, the classic missing-pointer mistake) is diagnosed at the declaration with the "box it as * Node" cure, instead of a cryptic insertvalue operand and field disagree IR error at first construction. Locks should_fail_recursive_struct. - Closure return-type context — gen_ret inside a closure body now sees the closure's declared return type (the body scope shadows __fn_ret_ty__), not the enclosing function's; this both enables the return check above inside closures and corrects the pre-existing void-return diagnostic there.
closing token stays the canonical, permanent surface form (CRITIC A3, the last open grammar decision). The call was made on data: a ~77 000-line first-party corpus sweep (the self-hosted compiler, the HTTP/1.1+2 + WebSocket stack, a regex engine, crypto, and the Game Boy / C64 emulators) measured the longest consecutive prefix-operator run per line — ~96 % of operator-bearing lines nest only 1–2 deep, and just **19 lines in the entire corpus** reach depth ≥5, clustered in two idioms (n-ary boolean membership and big-endian byte assembly) that already have ordinary library answers (a predicate helper such as is_alpha, or an intermediate : binding). The foot-gun shape is caught by the existing dead-value / prefix-arity-cascade diagnostics. Rationale and the depth table are recorded in docs/spec.md §6. The decision is safe to revisit additively — an optional grouping form could be added post-1.0 without breaking any program; 1.0 locks only the negative.
v1.0-lock language sweep): - A generic function called without [T …] type arguments now reports *"generic function 'pick' needs explicit type argument(s): write ( pick [T] … ) — NURL does not infer generic type arguments from value arguments" instead of the misleading "call to unknown function 'pick'"* (the same message a genuine typo gets). A truly-unknown name still gets the unknown-function message. Lock compiler/tests/should_fail_generic_no_typeargs.nu. - A pure-NURL stdlib helper used without importing its module — nurl_str_cat / _cat3 / _cat4 / _slice, which are pre-registered for cross-module typing but have no C body (unlike nurl_str_int / _float / read_*) — now reports *"'nurl_str_cat' is defined in stdlib/core/string.nu … add a '$' import of that file"* at the call site, instead of leaking clang's "use of undefined value '@nurl_str_cat'" at link. Safe because scan_fn_sigs is a complete whole-program pre-pass, so a real definition anywhere sets __arity before any call is generated (no false positive for cross-module callers). Lock compiler/tests/should_fail_stdlib_helper_no_import.nu. Both are diagnostic-only — the bootstrap fixed point holds.
examples/chaotic-aggressor.nu) —
four edges hardened before the v1.0 lock.** A hostile valid-NURL stress demo (a concatenative stack VM exercising generics, traits, multi-payload ??, a slice-of-closures jump table, closures-returning-closures and dense prefix float math) flushed out: - Slice element access by a parameter index emitted malformed IR. . slice idx where idx was a bare function parameter lowered the index as a load from a <name>__ptr alloca a parameter never has, leaving the load i64, i64* pointer operand blank — IR nurlc accepted (rc 0) and only clang rejected ("expected instruction opcode"). gen_member now resolves the index exactly like gen_ident (parameter → %name, local → load __ptr, const / enum → load @name). Regression compiler/tests/slice_index_by_param.nu. - A bare type keyword used as a value emitted add void void, …. The void/unit literal v (produced only by a bare type keyword in value position) reaching an operator / complement / logical-not lowered to a void-typed SSA operand — again rc 0, clang-only rejection. New die_if_void guard rejects it at the source (covers : i x + v 100 and the ~ v xs { … } foreach-binding trap). Lock compiler/tests/should_fail_void_operand.nu. - Closure capture-by-value assignment is no longer silent. Assigning to a binding a closure captured by value (the counter footgun: 1,1,1 instead of 1,2,3) now warns at the assignment, pointing at the supported shared-mutation shape (a : ~ multi-field struct captured by reference, which cannot escape its frame). Baseline compiler/tests/should_warn_byval_capture_assign.nu.
generic_inst grammar widened to match the implementation (compound type
arguments).** The compiler has always accepted compound generic arguments — a nested application ( Pair ( Box i ) i ), a pointer *T, an option ?T / ??T, or a closure ( @ R P* ) — at both the type-position ( Name … ) form and the call-site [ … ] form, but spec/grammar.ebnf still said "base identifiers only; `T` is not accepted."* The grammar and docs/spec.md now define a shared generic_arg covering those forms, so spec and implementation agree ahead of the 1.0 lock. The one excluded shape — a bare anonymous slice ([ T) as an argument — used to emit garbage IR that only clang rejected and now gets a clean source diagnostic with the wrap-in-a-struct cure. Lock compiler/tests/should_fail_slice_generic_arg.nu.
: ~ s g …) can now be reassigned. The
declaration was accepted and emitted as writable global i8* storage, but gen_const_decl's string branch never recorded the __mutable flag (the i / u / f / b branches all did), so a later = g … was wrongly rejected with "cannot assign to immutable global" — even though the grammar lists s as an updatable mutable-global type. The string branch now sets the flag like the other scalar branches. Reassignment to a constant or a heap-allocated (nurl_str_cat) string both work. The bootstrap fixed point holds (no in-tree code could use a mutable string global, since the bug rejected them, so emitted IR is unchanged). Regression compiler/tests/mutable_string_global.nu. Surfaced by an adversarial language-probing sweep for grammar/spec-vs-compiler gaps before the v1.0 lock.
enum type and construction already supported any number of payload slots; only the match side was limited — the parser stored just three binding names and the emitter had three hand-unrolled slot blocks, so a 4th+ payload was silently dropped (use of undefined identifier), forcing a post-match . extraction. The parser now collects overflow payload names/literals and the emitter binds slots 1..N-1 through one emit_enum_payload_bind helper in a loop (slot 0 keeps its option/result-aware reconstruction); literal constraints on slots 3+ loop the same way. Verified for 4- and 5-payload variants across mixed payload types (int / float / string / pointer / struct), a literal constraint on slot 3, and a guard reading a slot-3 binding. The bootstrap fixed point holds without a refresh. Regression compiler/tests/match_payload_n.nu. Removes the docs/LIMITATIONS.md Enums limitation (CRITIC A6).
f / f32) enum payloads now compile. An enum's payload slot
is uniformly pointer-typed; construction coerced i1 / integer / string / struct payloads into it, but had no branch for double / float, so a float payload emitted insertvalue %E …, double X, 1 into a ptr field and clang rejected it (operand and field disagree in type). The match side had the symmetric gap. Any sum type with a floating-point payload — a numeric AST, a real-valued JSON, a geometry variant — was uncompilable, although the grammar permits it. Fixed in gen_agg_lit (bitcast the float to a same-width int, f32 widens i32→i64, then inttoptr into the slot) and emit_enum_float_extract (the inverse on the match arm). Verified across payload slots 0/1/2, a mixed float+pointer recursive enum, and a genuine f32 value; the bootstrap fixed point holds (no existing code used float enum payloads, so emitted IR is unchanged). Regression compiler/tests/enum_float_payload.nu; surfaced by the examples/chaotic-showcase.nu grammar stress test (recursive symbolic autodiff). An implicit double-literal → f32 narrowing in an enum literal still needs an explicit # f32 cast, exactly as struct construction requires.
pubkey-addressed overlay where a peer is a public key, not an address — it reaches that key over a direct peer-to-peer path when it can and a relay when it must, and never drops to zero reachability. Built bottom-up: net/securedgram.nu (WireGuard-style encrypted UDP over Noise + a session AEAD with a sliding replay window, plus endpoint roaming so a peer survives a network change), net/stun.nu (RFC 8489 server-reflexive discovery), net/nat.nu (candidate gathering, NAT-type classification, UDP hole punch), net/relay.nu (DERP-style opaque forwarding plus group multicast — broadcast to your own group), net/rendezvous.nu (signaling-only directory), net/transport.nu (the flat seam: transport_send/broadcast/recv, direct-when-possible/relay-when-forced with promote/demote), and SWIM membership (net/membership.nu) hardened by Lifeguard (std/lifeguard.nu) with a failure-detector control loop (net/failuredetector.nu). On top sit the sharding + replicated-state layers: a consistent-hash ring (dist/ring.nu), state-based CRDTs (dist/crdt.nu — PN-Counter, LWW-Register, OR-Set) and their gossip wiring (dist/replicator.nu, anti-entropy scoped to a key's replica set). Documented end to end in docs/DISTRIBUTED.md.
above into distributed work. dist/identity.nu gives each peer a replica id; dist/job.nu is the keystone — submit a task keyed by k, the ring owner executes it via a registered handler, the result is recorded idempotently, and a key that re-homes mid-flight is forwarded to the new owner so the job still completes. dist/lease.nu adds fencing tokens (epoch monotonicity + idempotency keys) so a side-effecting task fires at most once across a split-ownership window. Liveness under load is handled by SWIM self-refutation plus a heartbeat on a dedicated OS thread (dist/heartbeat.nu), so a 100%-CPU node is not falsely evicted. The whole story is verified by a deterministic chaos-simulation harness (dist/sim.nu): a virtual clock + in-process message bus with seeded fault injection (drop, latency/jitter→reorder, partition/heal) drives the real stdlib logic, with scenarios for converge-under-loss, keystone-across- partition, at-most-once-side-effect, and CPU-pinned-not-evicted — all byte-reproducible goldens, ASan-clean.
pttvoice/. A distributed PTT voice app on the
overlay: captures the microphone, Opus-encodes it (48 kHz mono, 20 ms frames via a libopus FFI binding), and pushes a talkspurt either to one peer (unicast — p2p when punchable, relayed otherwise) or to the whole group (multicast); a receiver decodes and plays it. ALSA capture/playback (audio.nu), the codec (opus.nu), the voice wire frame (proto.nu), and the app (ptt.nu) live in a self-contained folder. Verified live over a loopback relay (group broadcast and peer unicast). build.sh now detects libopus and ALSA (dropping stdlib/runtime.{opus,asound} sentinels) and nurl.sh auto-links -lopus/-lasound when those FFI symbols appear.
/pptchat tab: a
page with a microphone button and an embedded NURL→WebAssembly module (nurlapi/static/pptchat.nu → pptchat.wasm) that reads the mic through the audio FFI and paints a live VU meter + frequency spectrum, framing the distributed voice tech. Channels: no id → the shared public channel; + Create channel mints a random id and navigates to /pptchat/<id>; opening that URL joins the same channel (the URL is the invite, the future shared-secret/QR), with a Copy-link button.
compiler/tests/run_san_tests.sh now runs
AddressSanitizer/UBSan checks in parallel (NURL_SAN_JOBS, default = cores), cutting the sanitized CI leg from minutes to under one — matching the already- parallel functional runner.
stdlib/ext/cookies.nu (critic B23). The
server side writes Set-Cookie (ext/http_auth.nu); this is the missing client half. cookie_jar_set parses one Set-Cookie value (Domain, Path, Expires, Max-Age, Secure — Max-Age wins over Expires, an already-expired cookie deletes its stored match) defaulting Domain/Path from the request host/path; cookie_jar_header returns the Cookie: value for a request, applying RFC 6265 domain matching (§5.1.3, host-only vs subdomain), path matching (§5.1.4), Secure gating, and expiry, longest-path-first. Pure string-in/string-out — decoupled from the HTTP client types, so it round-trips a session over HTTP/1.1, h2, or any header source. now (unix seconds) is passed explicitly for deterministic expiry. Lock: compiler/tests/cookies_basic.nu (host-only vs Domain, path ordering, Secure, Max-Age/Expires expiry, replacement, Max-Age=0 deletion, malformed rejection); ASan+UBSan+LSan clean.
stdlib/std/bench.nu + nurlpkg bench (critic
C4). std/bench.nu times a no-arg closure over many iterations and reports ns/op (via the monotonic clock) and allocations/op. bench_run name iters body runs a short untimed warmup then a timed loop; bench_auto name body auto-scales the iteration count until a pass clears ~50 ms, for stable numbers on sub-microsecond operations. bench_report prints one line; bench_result_* accessors expose the raw numbers. The allocation metric is backed by a new runtime hook, nurl_alloc_count (a relaxed-atomic counter on every nurl_alloc/nurl_zalloc — which is what stdlib vec/string/struct blocks route through), snapshotted around the timed loop so warmup is excluded. nurlpkg bench discovers benches/*.nu, compiles each at -O2, runs it, and streams its report (no goldens — wall time is machine-dependent; a bench fails only on a compile error or nonzero exit). Ships bench/stdlib_hotpath.nu (string build / vec push / sort micro-benches). Locks: compiler/tests/bench_basic.nu (deterministic surface — report formatting, the alloc counter on a vec cycle vs a no-op body, iteration bookkeeping) and compiler/tests/nurlpkg_bench_smoke.sh (runner discovery / streaming / summary / exit codes).
nurlpkg test — user-facing test runner (critic C3). Ships the
compiler suite's per-test pattern as a tool: nurlpkg test discovers tests/*.nu, compiles and runs each, and reports PASS/FAIL with a summary (exit 0 iff every test passes). A test passes on exit 0; if a tests/outputs/<name>.txt golden exists, the program's stdout must match it byte-for-byte instead. Tests run in sorted order for determinism. The build driver is ./nurl.sh by default, overridable via $NURL_CC (a command taking <flags> <src> <outbin>) for an installed toolchain. Smoke-tested by compiler/tests/nurlpkg_test_smoke.sh (all four verdict paths + all-pass/any-fail exit codes + the empty-tree message).
tools/repl (nurl repl) (critic C1). An interactive
read-eval-print loop on a process-per-eval model: top-level definitions (@ functions, & FFI, $ imports, and : types / enums / globals) accumulate into a persistent session, while every other line is spliced into a fresh main, compiled with ./nurl.sh -O0, and run — its stdout is echoed back. A new definition is validated by a fast build/nurlc frontend pass before it joins the session, so a typo never poisons later evaluations. Line editing + history come from std/term.nu (the B10 work); on a non-tty (pipe / script) it falls back to plain buffered reads. All REPL chrome — prompts, acks, errors, :help — goes to stderr, so stdout carries only the evaluated program's output. Meta- commands: :help/:h, :quit/:q, :defs, :reset, :save FILE. Multi-line definitions are read until brackets balance. Build with ./tools/repl/build.sh; smoke-tested by compiler/tests/repl_smoke.sh (definitions + globals persist across lines, a bad definition is isolated, stdout stays clean). Note: process-per-eval re-initialises a : global on every evaluation — definitions and pure functions persist, but mutation does not accumulate across lines.
stdlib/std/bitset.nu (critic B18, collections round-out).
A fixed-size bit array over 64-bit limbs: bitset_set / bitset_clear / bitset_flip / bitset_test (all bounds-checked, so the unused high bits of the last limb stay clear), bitset_set_all / bitset_clear_all, a popcount-backed bitset_count, bitset_any / bitset_all / bitset_none, the in-place combiners bitset_and_with / bitset_or_with / bitset_xor_with, bitset_clone, and an ascending bitset_each_set. Storage is a flat nurl_zalloc word buffer peeked/poked by limb. NURL has no native XOR or NOT operator, so the module uses the exact, carry-free identities a ^ b = (a|b) - (a&b) and ~m = -1 - m. Lock: compiler/tests/bitset_basic.nu — cross-limb set/clear/flip, out-of- range no-ops, popcount, all() on a full set, the three combiners with an XOR-identity bit check, and clone independence; ASan+UBSan+LSan clean.
stdlib/std/lru.nu (critic B18, collections round-out).
A fixed-capacity LruCache [V] over string keys, backed by a HashMap (key → slot) plus an intrusive doubly-linked recency list over preallocated slot arrays with a free list — so lru_get / lru_put / lru_contains / lru_remove are all O(1) and a cache at capacity does no further allocation. lru_get moves the key to MRU; lru_peek does not; lru_put returns the displaced value (replaced or evicted), owned; lru_each walks MRU→LRU; lru_free_with drops each value on teardown (the owned-element discipline the deque/heap work established). The map's hash/eq closures are non-capturing, so they allocate nothing per call. Lock: compiler/tests/lru_basic.nu — eviction order, get-as-touch survival, peek-without-reorder, replace-returns-old, remove, the MRU→LRU walk, and the owned-String free_with path; ASan+UBSan+LSan clean. With the B-tree and bitset, this closes critic B18.
stdlib/std/btree.nu (critic B18). A
BTree [K V] with O(log n) insert / lookup / delete, replacing the array-shift backing for large ordered maps. Classic CLRS proactive split-on-descent and borrow/merge-on-descent (minimum degree 8, so up to 15 keys per node) keep the tree balanced; each node also caches its subtree size, which makes btree_key_at / btree_val_at order- statistic queries (the i-th smallest key) O(log n) too. API: btree_new / btree_len / btree_is_empty / btree_get / btree_contains / btree_set / btree_remove / btree_min_key / btree_max_key / btree_key_at / btree_val_at / btree_each / btree_free / btree_free_with. Nodes are raw 6-slot blocks with typed-pointer access (the same generic-container pattern as std/set.nu). Lock: compiler/tests/btree_basic.nu — a 2000-key scrambled fill with replace, remove-every-third churn, order-statistic and ascending-iteration checks, and full drain; ASan+UBSan+LSan clean.
stdlib/std/term.nu (critic B10). The
prerequisite for a REPL and TUI examples: POSIX termios raw mode (term_raw_enable / term_raw_disable, with struct termios sized via nurl_native_sizeof so the platform layout never leaks into NURL), term_is_tty, a full set of byte-exact ANSI builders (ansi_reset / ansi_sgr / ansi_fg / ansi_bg / ansi_clear / ansi_clear_line / ansi_cursor_to / ansi_cursor_up/down/right/left), and a minimal raw-mode line editor (term_read_line) with printable insert, backspace, ←/→, ↑/↓ history, Ctrl-A/E/K, and a clean not-a-tty None fallback. Adds TCSANOW / TCSAFLUSH to the runtime's nurl_native_constant table (POSIX-only; Win32/WASI return None from raw mode while the ANSI builders still work — Windows Terminal speaks VT). Lock: compiler/tests/term_basic.nu — the tty-independent surface (None on a file fd, byte-exact ANSI hex); ASan+UBSan+LSan clean.
stdlib/ext/zip.nu (critic B15). A reader and
writer for the ZIP format over the existing zlib FFI. Writer: zip_new / zip_add (raw-deflate, windowBits −15, falling back to store when deflate would not shrink the entry) / zip_add_stored / zip_finish, emitting local headers, the central directory, and the EOCD with a fixed DOS timestamp so archives are byte-deterministic. Reader: zip_open (backward EOCD scan over the comment window, with zip64 rejected as unsupported) / zip_count / zip_name_at / zip_size_at / zip_extract / zip_extract_name / zip_close, every extraction CRC-32-validated. Lock: compiler/tests/zip_basic.nu — build (deflate + store + a compressible 5000-byte entry), re-open, CRC-checked extract of each entry, by-name extraction, missing-name and junk-archive rejection; cross-checked against system unzip -t; ASan+UBSan+LSan clean.
stdlib/ext/smtp.nu (critic B17). A mail-submission
client over the runtime's client-side TCP/TLS connect: smtp_connect / smtp_connect_tls, EHLO capability discovery (smtp_ehlo / smtp_has_cap), smtp_starttls (RFC 3207 — upgrades the live plaintext fd to TLS and re-EHLOs), smtp_auth_plain / smtp_auth_login (RFC 4954), the smtp_mail_from / smtp_rcpt_to / smtp_data envelope (DATA dot-stuffs and terminates per RFC 5321 §4.5.2), smtp_quit / smtp_close, plus a minimal RFC 5322 MIME builder (mime_build), smtp_dotstuff, and smtp_date_now. STARTTLS needs to upgrade an already-open fd, which the existing connect primitives could not do, so this adds nurl_tcp_starttls to the runtime — the client-handshake half of nurl_tcp_connect_tls applied in place. Lock: compiler/tests/smtp_basic.nu — the offline surface (multiline reply scanner/parser, AUTH PLAIN/LOGIN base64 tokens, dot-stuffing, MIME), ASan+UBSan+LSan clean; examples/smtp_send.nu demonstrates the live STARTTLS submission flow.
stdlib/std/unixsock.nu (critic B9). The
local-IPC sibling of std/net.nu's TCP, same API shape but a filesystem path instead of host:port — for Postgres-over-socket, systemd-style services, container control planes. unix_listen / unix_accept / unix_connect / unix_socketpair / unix_read_chunk / unix_write_all / unix_write_str / unix_close_conn / unix_close_listener (which unlinks the socket file). Pure libc FFI (blocking SOCK_STREAM); unix_listen unlinks any stale path before binding. Adds AF_UNIX / SOCK_STREAM / EADDRINUSE to the runtime's nurl_native_constant table (POSIX-only; the module degrades to a clean UnixSocket error on Win32/WASI). Lock: compiler/tests/unixsock.nu — a deterministic socketpair round-trip (both directions + EOF-after-close) plus a thread-driven listen/accept/connect echo gated on NURL_NET_TESTS; ASan+UBSan+LSan clean on both paths.
stdlib/ext/cbor.nu (critic B16). The
IETF-standard binary serialization (COSE / WebAuthn / CTAP), sibling of MessagePack, over the shared Json value: `cbor_encode Json → !( Vec u ) CborErr and cbor_decode ( Vec u ) → !Json CborErr`. Encode is canonical-ish — integers and lengths use the shortest head, floats are float64 — so equal documents serialize to equal bytes. Decode is liberal: every definite-length head, signed/unsigned integers at all widths, and float16 / float32 / float64 (the half-float decoder handles zero / subnormal / normal / inf / NaN). Byte strings, tags, indefinite-length items, and exotic simple values are rejected as CborUnsupported; undefined (0xf7) → JNull. Lock: compiler/tests/cbor.nu — Json round-trip with canonical-byte check, the RFC 8949 Appendix A decode vectors (integer boundaries, negatives, nested array/map, all three float widths), and every documented rejection; ASan+UBSan+LSan clean (error paths free the partial tree).
stdlib/std/decimal.nu
(critic B14, the last ROADMAP numeric gap). A Decimal is a BigInt coefficient × 10^-scale, so it is exact — 0.1 + 0.2 is 0.3, not the binary-float 0.30000000000000004 — and never overflows. Exact dec_add / dec_sub / dec_mul; dec_div a b scale with an explicit result scale and banker's rounding (round half-to-even, the financial default); scale-agnostic dec_cmp; dec_round / dec_rescale / dec_normalize; dec_from_string ("-12.340", ".5", "42") and dec_to_string. Builds on the bigint div/rem from PR #100. Lock: compiler/tests/decimal.nu (exact 0.1+0.2==0.3, the full half-to-even rounding table incl. negatives, division + div-by-zero, normalize, cross-scale compare; ASan+UBSan+LSan clean — every intermediate BigInt freed).
/stdlib-docs
(nurlapi). The nurldoc library is now wired into the playground server: /stdlib-docs is an auto-generated index of every stdlib module (grouped by core/std/ext/hal), and /stdlib-docs/<path> renders one module's signatures + doc comments through nurldoc_render → the existing md_to_html + dark-theme doc chrome (the same presentation as the README/spec pages). Append .md for the raw Markdown. Closes the loop the C2 nurldoc PR opened — the "broad stdlib" is now browsable, not just greppable. Listed in the OpenAPI spec; live-verified end-to-end (index + module HTML + raw .md, ASan-clean).
nurldoc — Markdown API-doc generator (critic C2). The stdlib's
//-header + doc-comment discipline (90+ modules) was unrenderable; nurldoc extracts each module's header block, top-level declaration signatures (functions trimmed at their { body; types/enums/consts keep their full definition), and the doc comment above each, into Markdown. The render logic is an importable library (stdlib/ext/nurldoc.nu, nurldoc_render content title → String, brace-depth-aware so : locals inside bodies are never picked up); tools/nurldoc/main.nu is a thin CLI — nurldoc <file.nu> to stdout, or nurldoc <src-dir> <out-dir> to walk the tree with fs_glob and write one .md per module. Lock: compiler/tests/nurldoc.nu.
stdlib/std/time.nu (critic
B13). The server formatted HTTP dates (time_format_http) but could not parse them; http_date_parse now accepts all three forms RFC 7231 §7.1.1.1 requires — IMF-fixdate (Sun, 06 Nov 1994 08:49:37 GMT), obsolete RFC 850 (Sunday, 06-Nov-94 08:49:37 GMT, 2-digit year via the POSIX <70 pivot), and asctime (Sun Nov 6 08:49:37 1994) — for If-Modified-Since / If-Unmodified-Since / cookie Expires. rfc2822_parse handles the email Date: form with numeric ±HHMM zones (Mon, 02 Jan 2006 15:04:05 -0700). Both return UTC seconds in the !i ParseErr shape (pair with time_from_unix), matching time_parse_iso. Lock: compiler/tests/http_date.nu — the three RFC 7231 spellings agree on the spec's own example (784111777), the RFC 2822 -0700 case equals Go's canonical reference instant, plus round-trip and rejects.
stdlib/ext/http_jwt.nu (B5
follow-through). with_jwt_hs256 secret inner / `with_jwt_eddsa pubkey inner` wrap a claims-aware handler (( @ HttpResponse HttpRequest Json )) and return the standard ( @ HttpResponse HttpRequest ) middleware shape, so they compose with with_access_log / with_cors_default / router_handle. A request runs the handler only with a valid Authorization: Bearer token; the verified payload claims are passed straight in (no re-parse, no header injection / spoofing surface, borrowed + freed by the middleware). Missing / invalid / expired / not-yet-valid tokens get a 401 with an RFC 6750 WWW-Authenticate: Bearer challenge whose error= / error_description= names the failure. Kept in its own module so the base ext/http_auth.nu stays free of the OpenSSL dependency ext/jwt.nu pulls in. Lock: compiler/tests/http_jwt.nu (valid/expired/tampered/wrong-key/missing × HS256 + EdDSA; ASan+UBSan+LSan clean).
stdlib/std/fs.nu (critic B6 + B7).
fs_rename (libc rename), fs_copy_file (64 KiB-chunk streaming, so large files copy in bounded memory), fs_tempfile (libc mkstemp → unique 0600 file, returns the path). fs_glob expands shell patterns against the tree: * / ? / [...] (with [a-z] ranges and [!...]/[^...] negation) within a segment, ** as a whole segment for recursive descent, the leading-dot rule (* never matches a dotfile; an explicit . does), absolute or relative patterns. Pure NURL over dir_list. Lock: compiler/tests/fs_glob.nu (every pattern class + rename/copy/tempfile against a built temp tree).
stdlib/std/url.nu (critic B12). RFC 3986
scheme://[userinfo@]host[:port][/path][?query][#fragment] into an owned Url, with bracketed-IPv6 hosts, url_default_port / url_port_or_default (http/https/ws/wss/ftp/redis/postgres), url_request_target (path?query for the request line), a percent codec (url_percent_encode/_decode), and url_query_decode → Vec[UrlParam] (form-urlencoded +→space, %xx). Lives in std/ with core-only deps so ext/ layers on it without a cycle. Locked against RFC 3986 component-split vectors in compiler/tests/url_parse.nu.
stdlib/ext/jwt.nu (critic B5). HS256 (HMAC-
SHA256) and EdDSA (Ed25519) sign + verify over the existing crypto block and base64url. jwt_hs256_sign/verify, jwt_eddsa_sign/verify, a …_verify_at core taking an explicit epoch now (deterministic; the wrapper uses the system clock), and jwt_decode_unverified. Validates exp/nbf time claims; the HS256 signature comparison is constant-time (std/subtle.nu). The HS256 path reproduces the canonical jwt.io reference token exactly and EdDSA is goldened against the RFC 8032 test key (Ed25519 signatures are deterministic) in compiler/tests/jwt_basic.nu. Adds b64_url_encode_vec / b64_url_decode_vec to std/encode.nu for binary, unpadded base64url (the signature segment).
ext/websocket.nu and ext/http2_client.nu delegate URL parsing to
std/url.nu** (critic B12 consolidation). Both hand-rolled scheme/host/port/path splitting; their __ws_parse_url / __h2_parse_url are now thin wrappers over url_parse that enforce the ws/wss and http/https schemes and map to the existing WsUrl / H2Url types — same public API, one parser underneath. The new parser also correctly stops the authority at ?/# (the old scanners folded a query into the host when no path was present) and keeps the query in the request target.
dist/crdt.nu,
dist/replicator.nu, dist/identity.nu). The chaos-simulation harness exposed silent state corruption: PNCounter stored increments in a dense vector merged by position, while identity_of handed out ids in local first-seen order, so every node called itself replica 0 — two distinct replicas collided into one slot and the merge took max(1,1)=1 instead of summing to 2, converging to the wrong value with no error. Fixed deeply: identity_stable_id(pubkey) derives a globally consistent id from the pubkey (FNV-1a/64, no coordination); PNCounter is now sparse and **keyed by replica id** (merge aligns by identity, not position); the wire carries the id per slot and pncounter_encode emits slots in canonical ascending-id order so equal states encode identically (required by the digest anti-entropy).
variable** (compiler/nurlc.nu, gen_field). . p field on a struct pointer resolved field as a same-named local integer and emitted a pointer array-index instead of a field load — a silent miscompile only clang caught. A struct field now always wins over a same-named variable on the field-load path (the field-store = . p name val array-index form is unchanged and intentional). Lock: compiler/tests/ptr_field_name_shadow.nu.
(compiler/nurlc.nu, gen_backslash_expr). \ ( Vec u ) p → ( Vec u ) { … } failed with “undefined identifier 'u'” because \ ( was only treated as a closure when the next token was @; a compound type head fell through to the try-expression path. \ ( is now a closure whenever the next token introduces a type (incl. the builtin Vec). Lock: compiler/tests/closure_compound_param.nu.
@ ?Enum { T Variant } emitted invalid IR (compiler/nurlc.nu,
gen_agg_lit). Constructing Some(variant) of an option whose payload is a no-payload (C-style) enum inserted the variant's bare i64 tag into the option's %Enum aggregate slot, which clang rejected (insertvalue operand and field disagree in type). The Result form ! T E always worked because its payload slot is i64; only the option payload field carries the full %Enum type. The coercion now wraps the tag with `insertvalue %Enum zeroinitializer, i64 tag, 0. Found while writing ext/jwt.nu`. Lock: compiler/tests/option_enum_payload.nu.
(stdlib/ext/crypto.nu, critic B1–B3). Binds libcrypto's EVP layer through pure-NURL & openssl @ FFI (no C bridge, same sentinel pattern as TLS/libpq → "install libssl-dev" at compile time): AES-256-GCM and ChaCha20-Poly1305 one-shot AEAD (tag appended; CryptoVerify on tampered input); Ed25519 keygen/sign/verify and X25519 keygen/derive via the EVP_PKEY raw-key API; HKDF-SHA256, PBKDF2-SHA256/512, and scrypt. Every primitive is locked against its published vector (NIST GCM, RFC 8439, RFC 8032 §7.1, RFC 7748 §6.1, RFC 5869 A.1, RFC 7914 §12) in compiler/tests/crypto_evp.nu.
std/subtle.nu — constant-time comparisons (critic B4).
constant_time_eq / _eq_n / _eq_vec for secret material, promoted from the private bearer-token loop in ext/mcp_registry.nu (which now calls it). Length-leaking but content-timing-invariant, matching hmac.compare_digest / Go crypto/subtle.
ext/crypto.nu HKDF: binary salt was silently zeroed before the
module shipped — nurl_str_get is a NUL-bounded C-string read, so hex-encoding a Vec u salt through a # s cast truncated at the first 0x00 byte. Switched the binary→hex helper to the *u + . p k indexed load (the idiom encode.nu's __b64_emit already uses). The RFC 5869 test vector caught it; documented as a reuse hazard in critic.md B3.
nurlc --lint detects unused imports (compiler/nurlc.nu). A
top-file $ import none of whose defined symbols (functions, FFI externs, types, constants — generic templates included) is referenced by the top file itself now warns `unused import: no symbol from '<path>' is referenced in this file`. References count from calls, identifier reads, and type positions; uses recorded while re-parsing generic template bodies during the instantiation flush are attributed to the template's defining file, so stdlib internals never mask a top file's dead import. Pure aggregator files (only $ directives, no decls of their own — e.g. stdlib/ext/http_full.nu) are exempt both as importer and as import target: re-exporting is their purpose. The LSP server (v0.6.0) now surfaces these straight from nurlc --lint and drops its former text-heuristic duplicate (~190 LOC removed) — one source of truth for the diagnostic.
compiler/nurlc.nu). A
call to a name with no registered return type — not an @-fn, FFI extern, builtin, impl method, or local closure — previously fell through as an assumed-i64 call to an undeclared symbol: invalid IR that clang rejected far from the source, or worse, code that linked by accident when the defining file happened to be imported later in the unit and the return type happened to be i64. Now dies at the call site: `call to unknown function 'X' — … add the missing '$' import … or check the spelling.` Same treatment for a generic call whose template is nowhere in the import closure (was: opaque expected '->' but found end of input inside the synthetic <generic …> source). En route this surfaced — and forced fixing — nine runtime builtins that were header-declared but missing from the compiler's symbol table (nurl_peek, nurl_init, nurl_memset, nurl_vec_drop, nurl_argc, nurl_argv_count, nurl_read_int, puts, printf), all silently riding the i64 default.
$ imports removed tree-wide (88 stdlib, 92
tests/examples/tools). Several masked real latent bugs, now fixed with explicit imports: stdlib/ext/csv.nu used opt_unwrap_or without importing stdlib/core/option.nu (rode on sort.nu's own stale import), stdlib/ext/http2_hpack.nu called h2_default_header_table_size without importing stdlib/ext/http2_frame.nu, and stdlib/std/bufio.nu called nurl_file_open/nurl_file_close without importing stdlib/std/fs.nu — each compiled only when every consumer happened to import the missing file first. stdlib/std/set.nu no longer imports hashmap.nu for its callers' convenience; import it alongside (the stock hash_*/eq_* helpers live there).
generics are hard errors now** (compiler/nurlc.nu, critic A7). An unknown/unimported type name in an enum variant's payload position parses as a SEPARATE variant (same-file forward references already resolve via the pre-scan, so this fires only for typos and missing imports) — the intended payload silently vanished and downstream code emitted out-of-bounds extractvalue / broken store IR, or silently read a sibling variant's slot. Three new hard errors: payload-arity checks at the match arm ("match arm binds 1 payload(s) but variant 'V' declares only 0 …") and at the enum literal, both naming the unknown-type-parses-as-variant cause; and an unknown-generic check in parse_type_paren — ( Vec i ) with no generic-struct template in scope and no materialised instantiation dies at the use site naming the missing $ import, instead of clang's "loading unsized types is not allowed" far from the cause (zero-type-arg ( Type ) trait-impl targets are exempt). Locks: should_fail_ghost_variant_match.nu, should_fail_ghost_variant_construct.nu, should_fail_unknown_generic_type.nu. False-positive sweep: full suite 339 PASS + nurlapi + examples clean.
cascade is now diagnosed** (compiler/nurlc.nu, critic A2). A statement that produces a value without being a call or control flow (bare local identifier, operator expression like + a 1, a # cast, a . field read) discards that value silently — under prefix notation with fixed arity and no closing token, this is exactly the residue left when an operator short an argument swallows the next statement's leading token. The bare-literal flavor was already a hard error (dangling operand); these shapes name real bindings, so they warn. Value-block tail expressions (the block result), calls, and ?/?? statements (their arms may be effectful) are exempt. The diagnostic embeds the dead statement's own line — by the time the block iterator sees the flag, the lexer already points at the next statement. Tree-wide false-positive check: nurlc.nu self-compile, the full stdlib, nurlapi, and examples produce ZERO warnings. Locks: should_warn_dead_value.nu (four dead shapes warn; tail/call/return stay silent), and should_warn_caret_xor.nu now also catches the previously silent dead b in : i x ^ a b. This closes the last undiagnosed half of the prefix-arity cascade family (critic §4); the A3 closing-delimiter decision can cite it as the mitigation.
std/bigint: arbitrary-precision division and modulo —
bigint_div / bigint_rem (stdlib/std/bigint.nu), closing the last gap in the bigint arithmetic surface. The magnitude core is Knuth Algorithm D (TAOCP vol. 2, §4.3.1) over the base-2¹⁶ limbs: D1 normalization reuses the existing small-multiply helper (top divisor limb ≥ base/2, so every trial digit is off by at most one), the multiply-and-subtract step uses a per-limb {0,1} borrow (no negative shifts), and the rare add-back step is exercised by both classic Hacker's Delight divmnu trigger vectors. A single-limb divisor short-circuits through __mag_divmod_small_inplace. Semantics are truncated division exactly like the native / and %: the quotient rounds toward zero, the remainder takes the dividend's sign, and x == (x/y)*y + x%y holds for every y ≠ 0. Division by zero panics (recoverable via recover) — a defect, not a data error, so it is not threaded through !. Regression compiler/tests/bigint_div.nu: all four sign combinations, zero/a<b/exact edges, both add-back vectors, a 39-digit ÷ 21-digit case, a 60-round deterministic invariant sweep (reconstruction, |r| < |y|, remainder sign) over growing multi-limb operands, and the recovered divide-by-zero panic; ASan+UBSan clean, leak-free. Additionally verified against Python on 300 random cases (mixed limb counts/signs, near-power-of-2¹⁶ divisors).
transfer ownership to the caller** (compiler/nurlc.nu, critic A4c). Two bugs closed. ^ @ T { ( nurl_str_cat … ) } (direct construction return) leaked the field — the callee never bound it so never registered a drop, and the caller never registered one either. ^ v where v is a bound struct was worse: a use-after-free — the callee's scope-exit drop freed v's owned field while the returned-by-value copy still aliased it, so the caller read freed memory. The fix mirrors the existing owned-string return flag: the callee skip-drops the escaping struct binding and publishes its exact owned-field list (<fname>__ret_owned_fields), which the caller's : T x ( f ) re-registers through the same mem_register_agg_owned_fields path — exactly one drop, at the caller's scope exit. Ownership composes through ^ ( mk ) call chains and reaches nested struct fields. Safe against double-free with stdlib's manual *_free conventions: only raw-s/slice fields filled by a fresh allocation in a direct agg-literal return register for transfer — stdlib's struct returns use String/Vec handle fields (untracked) and build incrementally before ^ binding (which never registers agg fields), so their manual frees stay correct. Verified: full san suite 0 SAN_FAIL, tools/leakcheck zero, suite 340 PASS, and a targeted incremental-build manual-free probe stays single-drop. Regression ret_struct_owned_transfer.nu (direct / bound / chain / nested shapes, ASan+LSan zero). No nurlc IR change — fixed point holds without a bootstrap refresh.
^ ( call )
string ownership now propagates; aliasing escapes transfer ownership** (compiler/nurlc.nu, critic A4). Three coupled fixes: (1) a : declaration as an arm's LAST statement made the arm look value-producing (gen_let_or_struct left the RHS type in last_type), which suppressed the Phase 2D fall-through drop — leaking the binding on every ?/??/loop arm ending in a decl — and emitted a bogus phi over the discarded value; declaration statements now publish void. (2) __fn_ret_str_owned__ was only set for identifier returns, so @ helper → s { ^ ( nurl_str_cat … ) } was never marked __ret_owned=str and : s x ( helper ) leaked one buffer per call; gen_ret now consults the outermost call's __last_call_ret_owned__ for direct parenthesised-call returns, making ownership compose through helper chains. (3) The widened tracking exposed missing ownership TRANSFER on aliasing escapes: = outer x and ternary/match arms whose value is a bare load of an owned binding now cancel that binding's scheduled drop (mem_remove_owned_str; the arm delta-drop protocol switched from prefix-length to word-membership to stay consistent under mid-list deletion). Conservative direction throughout: worst case a leak, never a use-after-free — the pre-transfer behavior freed buffers that had escaped through phis, which miscompiled the compiler itself (gen_cast's : s norm ? … xv ( nurl_cg_reg cg ) returned a freed register name). Bootstrap snapshot refreshed (--refresh-bootstrap). Regressions: arm_local_trailing_drop.nu + ret_owned_propagation.nu, both ASan+LSan zero, with manual-free double-free locks on the transfer paths. Known residual filed as critic A4c: fn-returned structs with owned fields still transfer nothing (needs an ownership-model decision against the stdlib's manual *_free handle conventions).
server_stop from another thread freed the listener under blocked
pool workers** (stdlib/ext/http_server.nu). server_run_pool's documented shutdown — call server_stop s from another thread while workers block in accept — was a heap-use-after-free: workers hold no reference on the listener, so the stop's nurl_tcp_close dropped the last ref and freed the struct while every worker was still polling its shutting_down flag and wake-pipe fd (3/3 reproducible under ASan; single-threaded server_run raced identically). server_run and server_run_pool now retain the listener for the whole run→join window and release it only after no worker can touch the handle — the same contract server_run_async already followed for its accept fiber. The two-phase tcp_shutdown_listener → join → server_stop pattern remains valid; it is simply no longer the only safe shutdown. Regression compiler/tests/http_server_stop_direct.nu drives both fixed paths with a direct cross-thread stop (ASan-clean 10/10 under NURL_NET_TESTS=1). Closes critic.md B19 together with the earlier accept-wake fix (f470571).
recover leaked the closure's captured environment
(stdlib/std/panic.nu). recover decomposes its closure into (fn_ptr, env_ptr) and hands them to the C trampoline; passing the raw env pointer onward suppresses the parameter's auto-drop (the compiler must assume the env escapes — and in thread_spawn, whose shape this mirrors, it really does). But nurl_recover is synchronous: once it returns, the closure can never run again, so the env was simply leaked — one allocation per recover call with a capturing closure, panic or not. recover now frees the env right after nurl_recover returns (NULL-safe for capture-less closures), on both the normal and the unwind path. Found via ASan on the new bigint_div divide-by-zero regression; the existing recover_basic / http_server_panic goldens are unaffected (output is unchanged — only the leak is gone).
security bughunt** (stdlib/ext/http_request.nu, http_server.nu, http_response.nu): - **Chunked request bodies were silently dropped on keep-alive connections.** __finish_body only handled Content-Length, so a Transfer-Encoding: chunked body was left undrained in the connection carry buffer — the handler saw an empty body and the leftover bytes were mis-parsed as the next request (a desync / request-smuggling vector). __finish_body now decodes chunked bodies carry-aware (draining from the buffer + socket, leaving any pipelined successor). - Chunk-size integer overflow → smuggling/DoS. __parse_hex_size accumulated an unbounded hex value; 0x10000000000000000 wrapped i64 to 0 (read as the terminating chunk, ending the body early) or to a small positive (wrong boundary) — both smuggling vectors, and a huge positive could drive an enormous allocation. Now rejects any value past a sane ceiling, well clear of i64 overflow. - Content-Length + Transfer-Encoding smuggling. A request carrying both framing headers (RFC 7230 §3.3.3) is now rejected at head parse (and in read_body_to) instead of silently letting Transfer-Encoding win — the classic CL.TE desync. - HTTP response splitting (CWE-113). Response header names/values were serialised verbatim, so a value reflected from untrusted input (a redirect Location, an echoed header) could inject \r\n<header> and split the response. The serialiser (and the chunked response_begin_chunked path) now strips CR/LF from every emitted header name and value.
Regressions: compiler/tests/http_request_parser.nu (CL+TE rejection, chunk-size overflow rejection), http_response_builder.nu (header CR/LF stripping), and a new live http_server_chunked.nu (chunked body decoded + keep-alive survives a chunked request, gated on NURL_NET_TESTS=1).
large body no longer deadlocks the driver.** Three related fixes: - SETTINGS parameter-ID mismap (critical). The client's SETTINGS parser handled id 3 (MAX_CONCURRENT_STREAMS) as INITIAL_WINDOW_SIZE and ignored id 4 (the real INITIAL_WINDOW_SIZE), so every stream's send window was seeded with the peer's max-concurrent-streams value (typically 256) instead of its advertised window (65535). Any POST/PUT body over ~256 bytes stalled forever waiting for a WINDOW_UPDATE that never needed to come. IDs are now mapped correctly. - Driver read/write interleave. Each pump step now drains every inbound frame already available (readiness-probed via nurl_reactor_wait_read) before flushing pending DATA, keeping the peer's send buffer to us empty so it never blocks writing and keeps reading our DATA — removing the documented single-socket deadlock on a large request body. - Server per-stream WINDOW_UPDATE (stdlib/ext/http2_conn.nu). The h2 server replenished only the connection window, so it could not receive a request body larger than the 64 KB initial stream window; it now also replenishes each stream's window as it consumes DATA.
Regression: compiler/tests/http2_client.nu gains a live 200 KB POST (spanning many DATA frames and several flow-control windows) over the in-repo h2 server, gated on NURL_NET_TESTS=1.
inout / sink parameter conventions now work on trait impl
methods** (grammar-v2 borrow checker). An inout (or sink) parameter on an impl method silently miscompiled: the convention was recorded under the mangled method name (bump__Counter) while the call site dispatches by the bare name (( bump c )), so the receiver was passed by value into a %T* parameter — memory corruption / segfault. Fixing that surfaced a second bug (applying inout pointerised the first argument to %T*, which missed the method##%T impl-dispatch key and emitted an undefined bare @method). Both are fixed: the bare-name convention is mirrored at emission, and the impl-dispatch lookup retries with the receiver pointer stripped. Regression compiler/tests/impl_inout_sink.nu (struct inout, inout + by-value, a second implementing type, and a sink impl method; ASan + leak clean).
title screen** (examples/gameboy/core.nu). Root cause was a halt-bug emulation error, found by stack forensics on an instruction trace: EI + HALT with a timer IRQ landing inside HALT's own 4-cycle window set g_halt_bug, the EI delay then raised IME and the interrupt dispatched immediately — and the stale halt-bug flag replayed the HANDLER's first instruction (PC failed to advance once inside the handler). Tobu's handler starts with PUSH HL, so SP skewed by 2 and RETI returned into WRAM data — the screen froze and execution fell into a RST 38 loop (the gray bars + hang seen on the playground). Two-part fix per Pan Docs: (1) EI immediately before HALT with a pending interrupt is NOT the halt bug — the interrupt is serviced with the HALT's own address as the return address; (2) invariant: an interrupt dispatch always clears the halt-bug replay (it applies to the next sequential fetch only, never the handler's). Verified: Blargg cpu_instrs 11/11 + 02-interrupts + instr_timing still pass, dmg-acid2 renders, and a 40 000-frame idle soak (vs the ~2 918-frame crash) runs ASan-clean with a live framebuffer. Also: migrated examples/gameboy to the enforced : immutability (97 declarations — it sits outside the test suite, so the tree-wide migration missed it; all gameboy targets compile again, playground build regenerated), and fixed gbtrace.nu --trace to drive the real cpu_advance path (its hand-rolled step loop was a stale copy that never woke from HALT).
sink-of-auto-dropped-value boundary is documented as an
intentional, locked limitation** (docs/MEMORY.md §1, docs/LIMITATIONS.md). Passing a compiler-auto-dropped value (owned string / slice / Drop value / owned-field struct) to a sink parameter is rejected by design: the auto-drop obligation is tracked in per-scope owned-sets that are snapshotted/restored across ? / ?? / loop boundaries, so transferring it to the callee would be silently undone by an enclosing arm's restore — reintroducing a double-free. Reframed from "a future step" to a sound, conscious 1.0 decision with the rationale and workaround; pinned by compiler/tests/should_fail_sink_autodrop.nu.
pub visibility contract is now stated exactly and locked by
tests** (docs/spec.md §3.3). Cross-file enforcement covers @-functions, structs, enums, top-level consts, and enum variants; pub on traits, impl methods, and FFI is accepted but has no cross-file effect by design — trait dispatch resolves by type-mangled method name (no trait-name identity to gate) and FFI symbols are linker-level ABI globals. New compiler/tests/pub_trait_ffi_visibility.nu pins the unenforced surface (a non-pub trait method + FFI stays callable across files) so it can't silently regress into enforcement; the existing should_fail_pub_* tests pin the enforced surface. (Corrects the stale "only @-function calls observe the check" wording.)
stdlib/ext/http2_client.nu).
Completes the HTTP/2 stack (server + client). Reuses the direction-neutral framing/HPACK: h2_client_connect_tls (TLS + ALPN h2) / h2_client_connect_h2c / h2_client_attach → h2_client_submit (N concurrent streams) → h2_client_run_until_complete → h2_client_take_response, plus one-shot h2_get / h2_request. Full HPACK (connection-global decoder), per-stream + connection flow control, WINDOW_UPDATE / SETTINGS / PING / GOAWAY / RST_STREAM. Added runtime nurl_tcp_connect_tls_alpn. Example examples/h2_client.nu.
sampling/createMessage
reverse RPC (stdlib/ext/mcp_session.nu).** The pure, socket-free stateful core: an McpSessionStore keyed by a CSPRNG Mcp-Session-Id, a per-session outbound notification queue, and server→client reverse-RPC correlation. mcp_http.nu gains mcp_http_handler_session (mints/validates session ids, drains the queue to SSE on GET, settles reverse-RPC responses, tears down on DELETE) plus chunked mcp_sse_* helpers for a long-lived stream.
completion/complete argument autocompletion.
mcp_registry_add_completion r ref_type ref_id handler registers a completion provider for a prompt argument (ref/prompt) or resource template (ref/resource); completion/complete resolves it and returns the spec-shaped {completion: {values, total, hasMore}}. Unknown refs yield an empty list (per spec). Works over both stdio and HTTP transports.
resources/subscribe + notifications/resources/updated.
Per-session resource subscriptions: mcp_session_subscribe / _unsubscribe / _is_subscribed, and mcp_session_notify_resource_updated which fans an update notification out to every subscribed session over the SSE queue. The session HTTP handler services subscribe/unsubscribe against the request's Mcp-Session-Id.
stdlib/ext/xml.nu. Parses the common subset
(elements, attributes, nesting, self-closing, text, comments, CDATA, declarations, the 5 predefined entities + numeric refs) into an Xml tree; xml_stringify round-trips with entity encoding. Accessors + builders. Out of scope: DTD/DOCTYPE, namespace resolution.
stdlib/ext/yaml.nu. Parses a pragmatic
subset into the shared Json value (so every json_* accessor works on a parsed doc): block mappings/sequences, the seq-under-a-key idiom, plain and quoted scalars resolved per the YAML 1.2 core schema, flow collections, comments, ---/... markers. yaml_stringify emits block style with round-trip-safe quoting. Out of scope: block scalars, anchors/aliases/tags, multi-doc streams.
stdlib/std/time.nu. Local-time conversion +
DST driven by a POSIX TZ string (EST5EDT,M3.2.0,M11.1.0, IST-5:30, …) — the format IANA tzdata compiles each zone's current ruleset into (covers US/EU/AU). tz_utc / tz_fixed / tz_parse / tz_offset_at / tz_is_dst / time_from_unix_tz / time_now_tz / time_to_unix_tz / time_format_iso_tz. Not in scope: IANA region-name lookup.
stdlib/std/arena.nu.
arena_growing chunk_sz returns a chained-chunk arena: when the current chunk fills, a fresh chunk is linked in and old ones are never moved, so every pointer handed out before a grow stays valid (the canonical pointer-stable arena model). arena_alloc_n [T] a count → *T allocates count contiguous items of T. arena_new / arena_with_cap remain fixed (NULL on overflow) — byte-identical behaviour for existing callers.
stdlib/std/thread.nu. sem_new / sem_acquire
/ sem_try_acquire / sem_release / sem_avail / sem_free, built on the existing Mutex + Cond. The permit count lives in a heap cell, so a by-value copy (e.g. a worker-closure capture) shares state across threads — the classic tool for bounding concurrency (see the nurlapi compile gate below).
readlink(2) FFI — stdlib/std/fs.nu. fs_readlink reads a symlink's
target as an owned String via a direct & \c`` binding (no runtime change). nurlpkg now reads and verifies an existing deps/<name> link target, catching transitive name collisions.
stdlib/std/rng.nu (xoshiro256**).
Companion to std/random.nu's OS CSPRNG, which draws from the kernel entropy pool and cannot be seeded. rng.nu gives a reproducible stream: ( rng_seed s ) expands any i64 seed into a 256-bit xoshiro256** state via SplitMix64 (so even 0/1 produce well-distributed, uncorrelated streams), and the same seed yields a **byte-identical sequence on every platform and build** — the determinism guarantee extends here because the generator is integer-only (no float state, >> lowers to a logical lshr on the u64-typed words). Surface: rng_next (raw 64-bit), rng_below (unbiased, rejection-sampled [0,n)), rng_range, rng_u01 (uniform double in [0,1), 53-bit), rng_bool, rng_free. Opaque-handle lifecycle (same shape as Arena/Channel/String); state mutates in place. Not a CSPRNG — predictable, so never for security; use std/random.nu there. Regression compiler/tests/rng_seedable.nu pins the exact stream for seeds 0 and 0x1234 against an independent reference implementation, and checks determinism, rng_below bounds, and the inverted-range guard.
runs a 16-worker pool, so up to 16 requests run at once; each compile spawns clang -O2 -flto (hundreds of MB), and 16 simultaneously could exceed the container memory cap and get the whole process OOM-killed ("container is not listening"). The accept pool stays wide (light requests keep flowing) but the heavy compile step is now bounded by a counting semaphore: POST /build* and POST /mcp take a permit before dispatch and release after (panic-safe), default 4, tunable via NURL_COMPILE_SLOTS.
responses were forwarded via a NUL-terminated carrier and truncated at the first embedded NUL (only SSE/JSON/text survived). The proxy now reads the stream's true byte length and copies exactly that many bytes. New bytes_extend_raw + http_stream_next_bytes (→ ( Vec u )).
toward-1.0 → planned), with the per-feature history delegated to this changelog. Bootstrap snapshot refreshed.
struct value carried as an enum payload anywhere but the first slot (: | E { Nil V i Pt }) emitted invalid IR (store %Pt <ptr>) and was rejected by clang — construction heap-boxed it for every slot but the match/unbox path only reconstructed slot 0. Slots 1/2 now mirror slot 0.
Drop for boxed-payload enum trees. A
% Drop-free enum whose variants box struct/enum payloads now gets a compiler-generated recursive drop, so nested owned payloads are freed at scope exit instead of leaking.
Drop enum bindings off aborrow-returning accessor.** A binding taken from an accessor that returns a view into its parent is no longer treated as owned, fixing a double-free.
^ ?? owned { … } that returns a
scalar.** The owned match scrutinee in a returning ?? whose arms yield a scalar was leaked; it is now dropped before the return.
brace nesting and stray tokens at top level are now hard errors with source locations instead of reaching the backend.
examples/).
release tags.** The playground header now carries a version pill — a __NURL_VERSION__ placeholder in index.html is stamped at image-build time from the NURL_VERSION build-arg (dev for local builds). A new .github/workflows/api-deploy.yml builds the API image on a v* tag (or manual dispatch), pushes it to Docker Hub under the exact semver (nurllang/nurl:vX.Y.Z — no :latest), pins cloudflare/Dockerfile's FROM to that tag and runs wrangler deploy, so a git tag is now a reproducible playground release. The Docker image was renamed hindurable/nurl → nurllang/nurl; registry-deploy.yml is now manual-only (the registry changes rarely).
mqtt_connect_ws. Adds a WebSocket
transport alongside the raw TCP/TLS path so a client can reach a broker's MQTT-over-WS endpoint (e.g. wss://host:8084/mqtt) — handy when a firewall only permits the WS port inbound. wss:// enables TLS with certificate verification and negotiates the mqtt subprotocol automatically; the codec and framed packet reader stay transport-blind behind two chokepoints. New entrypoints mqtt_connect_ws / mqtt_connect_ws_cfg; mqtt_disconnect also sends a WS Close frame, and mqtt_reconnect rejects WS clients (no URL to redo the upgrade). stdlib/ext/mqtt.nu.
(ROADMAP §4).* Rounds the registry out into a minimum lovable* product. - CLI ergonomics. nurlpkg login stores a per-registry publish token in ~/.nurl/credentials (chmod 600) — publish/yank resolve the token $NURL_TOKEN → credentials, so it no longer has to live in the environment. nurlpkg logout [--revoke] forgets it (and optionally revokes it server-side). nurlpkg search <q> and nurlpkg info <name> query the registry (info with no arg still prints the local manifest). New stdlib/ext/credentials.nu. - Registry hygiene. nurlpkg yank|unyank <name> <version> flips a version's yanked flag (owner-only, via POST /api/v1/{yank,unyank}); the resolver already skips yanked versions, so a yanked release disappears from resolution. nurlpkg logout --revoke (POST /api/v1/revoke) deletes the presented token from D1. - Catalog UI. The Worker's / is now a searchable package list and /packages/<name> a detail page (versions with yank state, latest dependencies, an install snippet); GET /api/v1/search?q= backs the CLI. - Client helpers: pkg_search (pkg_fetch.nu), pkg_yank / pkg_revoke (pkg_publish.nu). Regression compiler/tests/credentials_basic.nu (set/get/upsert/multi-registry/remove; gated NURL_CREDS_TESTS=1, clean under ASan/UBSan). Whole feature set verified end-to-end against the Worker under wrangler dev: login → creds-based publish → search → info → yank (install then fails ResolveNoMatch) → unyank → catalog → logout --revoke → publish rejected (PubAuth).
nurlpkg publish sends X-Nurl-Deps.
Publishing now includes the manifest's registry dependencies (a JSON [{name, req}] built by __deps_json) as the X-Nurl-Deps header, which the registry records in the package index. pkg_publish gained a deps_json parameter. With the deps in the index, resolve_registry pulls sub-dependencies transitively — previously the index always recorded deps: [], so only leaf registry packages installed correctly. Verified end-to-end against the local Cloudflare Worker: publish tdep-b, publish tdep-a (depends on tdep-b ^1.0), then install a consumer of only tdep-a → both land in deps/ and the lock. Registry now supports real dependency graphs. stdlib/ext/pkg_publish.nu, tools/nurlpkg/main.nu.
registry/,
ROADMAP §4 phase 6).** The deployable server side of the ecosystem, in TypeScript. The read path serves the static index/<name>.json + content-addressed pkgs/<name>/<name>-<v>.tar.gz from R2 (cacheable, no compute); the write path POST /api/v1/publish authenticates a Bearer token (peppered SHA-256 looked up in D1), enforces **first-publisher name ownership + version immutability, recomputes the tarball SHA-256 server-side** (never trusts a client digest), and writes the tarball + updated index to R2. Identity bootstraps via GitHub OAuth (/login → /auth/callback mints a one-time CLI token). D1 schema in migrations/0001_init.sql (users / tokens / packages / versions). Implements exactly the wire contract the NURL client already drives. Validated end-to-end locally (no Cloudflare account): under wrangler dev (miniflare R2 + D1), the real nurlpkg binary completes a full publish → install round-trip plus immutability (409) and bad-token (401) rejections — registry/test-local.sh. Ships with registry/DEPLOY.md, a registry-deploy.yml GitHub Actions workflow (guarded so a placeholder token can't trigger a broken deploy), and secrets kept out of the repo (wrangler secret put for GITHUB_CLIENT_SECRET / TOKEN_PEPPER; GH Actions secrets for the Cloudflare deploy token). This completes the registry-backed package manager: nurlpkg publish + nurlpkg install against a deployable registry, all pure-NURL on the client and standing up locally today.
stdlib/ext/pkg_publish.nu + nurlpkg publish
(ROADMAP §4 phase 5).** The write side. pkg_pack walks a project tree into a .tar.gz (excluding deps, .git/dotfiles, nurl.lock, target, build); pkg_publish uploads it with `POST <registry>/api/v1/publish, Authorization: Bearer <token>`, and X-Nurl-Package / X-Nurl-Version headers (binary body via http_request_bytes), mapping status to PubAuth (401/403) / PubConflict (409, version immutability) / PubRejected. nurlpkg publish packs the current project, prints its size + SHA-256, and uploads using the token from $NURL_TOKEN and the registry from $NURL_REGISTRY → [package].registry → default; a missing token or any non-2xx exits non-zero. The registry recomputes the checksum server-side — no client-supplied digest is trusted. Regression compiler/tests/pkg_pack_basic.nu (offline pack + gunzip + tar_parse membership: nested source included, deps/ + dotfiles excluded), clean under ASan/UBSan + leak-free. Verified end-to-end against a static python registry: a full publish → install round-trip (a library packed, uploaded with a Bearer token, then resolved + installed into a consumer's deps/), plus immutability (409), bad-token (401), and no-token rejections. This is the exact contract the Cloudflare Worker + R2 write endpoint (phase 6) will implement.
stdlib/ext/pkg_fetch.nu (ROADMAP §4
phase 4b).** The I/O side that turns a resolved LockPkg into files on disk against a static-HTTP registry (R2 + CDN shape). pkg_fetch_index GETs <registry>/index/<name>.json; pkg_install_one downloads <registry>/pkgs/<name>/<name>-<v>.tar.gz, **verifies its SHA-256 against the recorded checksum**, gunzips, and path-safe tar_unpacks it into <dest>/<name> — composing the whole pure-NURL package stack (http binary body + sha256 + gzip + tar). Capstone regression compiler/tests/pkg_install_e2e.nu stands up a **loopback NURL registry server** (serves a real tar_create+gzip tarball + an index carrying its true checksum) and drives the full pipeline resolve → download → verify → unpack end-to-end, plus a wrong-checksum rejection (PkgChecksumMismatch); NURL_NET_TESTS=1. Clean under ASan/UBSan.
nurlpkg install is now registry-aware. It resolves the manifest's registry deps (foo = "^1.2" or { version, registry }), downloads + verifies + unpacks each into deps/<name>, and writes a nurl.lock whose registry entries carry source = "registry+<url>" + the tarball checksum (path deps keep their local source). The registry URL comes from $NURL_REGISTRY → [package].registry → a built-in default. A failed download or checksum mismatch makes install exit non-zero. Verified end-to-end against a static python -m http.server registry serving a GNU-tar --format=ustar | gzip package (differential interop): the happy path installs + locks with the sha256sum-computed checksum, and a tampered index checksum is rejected with the package left uninstalled.
http_body_bytes / http_body_len.
http_body_str reads the response body through a NUL-terminated carrier (truncates at the first embedded NUL). The new http_body_bytes returns an owned, length-accurate ( Vec u ) copy, and http_body_len exposes the byte count — required for binary downloads (package tarballs, images, compressed payloads). Completes the binary HTTP story alongside the earlier binary-safe request body. Regression: compiler/tests/http_response_binary.nu (loopback server replies a 5-byte A B \0 C D body; client confirms full length + the NUL via http_body_bytes; NURL_NET_TESTS=1). Clean under ASan/UBSan. stdlib/ext/http.nu.
stdlib/ext/registry_index.nu +
stdlib/ext/resolver.nu (ROADMAP §4 phase 4).** The read side of the package registry. A registry serves a static JSON index per package at <registry>/index/<name>.json (versions, each with a tarball SHA-256 checksum, yanked flag, and deps); tarballs live at the content-addressed <registry>/pkgs/<name>/<name>-<ver>.tar.gz, so the whole read path is a cacheable CDN with no compute. registry_index.nu parses an index and regindex_select picks the highest non-yanked version satisfying a semver requirement. resolver.nu's resolve_registry walks the transitive dependency graph (BFS) and emits a Vec[LockPkg] ready for lock_serialize — the index fetcher is injected as a closure (name → index-JSON), so resolution is pure and offline-testable; nurlpkg will wire it to an HTTP GET. v1 policy: one version per name (first requirement wins; a later one must share that version or it's ResolveConflict), sub-deps from the parent's registry, path deps left to the existing symlink installer. Regressions: compiler/tests/registry_index_basic.nu (parse + select + yanked exclusion + tarball URL) and compiler/tests/resolver_basic.nu (transitive resolve with a mock index, ResolveNoMatch, ResolveNotFound). Both clean under ASan/UBSan and leak-free.
side-channel — the structural fix for the whole u-vs-signed-i8 bug class.** The LLVM type (i8/i16/i32) can't distinguish NURL's unsigned u/u16/u32 from the signed types, so signedness travelled in a separate __last_unsigned__ syms entry that each of ~83 value-producing sites had to remember to update — and ~67 didn't, leaving a stale flag that silently sign- or zero-extended the next widen (the source of a long run of miscompiles). Now signedness lives in g_last_unsigned_p right next to g_last_type_ptr, and nurl_set_last_type always resets it (signed default): every value-producing site already calls the type-setter (IR needs the type), so a stale "unsigned" can no longer leak. The handful of unsigned-PRODUCING sites assert it atomically with the type via nurl_set_last_type_u / nurl_mark_unsigned, and widen/op-selection readers consult nurl_last_unsigned. This eliminated the stale-leak subclass structurally; the migration also surfaced and fixed a latent gap the old leaky channel had masked by accident — bitwise &/| (gen_bitwise_binary) never set its result's signedness, relying on the last operand's flag happening to survive. Net: fewer, simpler, faster (a global vs a string-keyed map) and no longer forgettable. Bootstrap fixed point holds; full suite + ASan/UBSan green; 500 fuzzer seeds clean across every dimension.
__last_unsigned__
side-channel hazard; fixed in compiler/nurlc.nu). Regression compiler/tests/const_ternary_signedness.nu (7 known-answer checks). 1. An unsigned global const load sign-extended. # i GU over : u GU 200 gave −56. gen_const_decl now records <const>__unsigned (which gen_ident already turns into __last_unsigned__ on load). 2. A ? (ternary) result didn't carry its arms' signedness. # i ? c (# u 200) (# u 100) sign-extended the selected value. gen_cond now snapshots each arm's __last_unsigned__ and sets the result flag (the arms share a type, so either suffices).
site.** # i ( f ) where f → u returns 200 gave −56 (and likewise for u16/u32): the call site never carried the callee's return signedness onto the __last_unsigned__ side-channel the enclosing widening cast reads (the LLVM return type i8/i16/i32 can't distinguish u from i8). scan_fn_sigs now records <fn>__ret_unsigned in the persistent pre-pass symbol table (the per-function gen_fn_decl scope doesn't reach call sites), and gen_call re-asserts it on __last_unsigned__ after the call. Regression compiler/tests/fn_return_signedness.nu (5 known-answer checks). Bootstrap fixed point holds; full suite + ASan/UBSan green.
An enum variant carrying a u/i8/u16/i16/u32/i32 payload (e.g. : | E { None Val u }) was accepted by the front-end but emitted invalid IR: gen_agg_lit only converted i64/i32 payloads into the enum's pointer slot (so an i8 payload hit insertvalue …, i8 … against a ptr field — clang reject), and gen_match only un-converted i1/i64 (storing a ptr into an i8 binding). Now construction widens a narrow payload to i64 (zext for an unsigned payload, sext for signed — from the payload signedness gen_enum_decl now records) before inttoptr, and the match ptrtoints back and truncs to the payload width, carrying the payload's signedness onto the binding so a later widen zero-extends an unsigned payload. Found by hand-probing the fuzzer's struct dimension outward. Bootstrap fixed point holds; full suite + ASan/UBSan green. Regression compiler/tests/enum_payload_signedness.nu (5 known-answer checks).
with a struct dimension; same root cause — the LLVM field type can't carry NURL's signedness). Both fixed in compiler/nurlc.nu; regression compiler/tests/struct_field_signedness.nu (8 known-answer checks); validated by 600 fuzzer seeds with the struct dimension. 1. Reading an unsigned field sign-extended. # i . rec u8field over a u/u16/u32 field holding e.g. 200 read back −56: gen_member never surfaced the field's declared signedness onto __last_unsigned__. gen_struct_decl now records <S>__<field>__unsigned, and both the value (extractvalue) and pointer (GEP+load) field-load paths set the flag from it. 2. **Constructing a wider field from a narrower unsigned value sign-extended.** @ Wide { # u 130 } into an i64 field stored −126 instead of 130 — gen_agg_lit's field-store widening hardcoded sext. It now picks zext when the field value is unsigned (the __last_unsigned__ snapshot it already takes), sext otherwise.
(tools/fuzz) and fixed at the root in compiler/nurlc.nu.** Both produced wrong values with no error — the worst class of bug. 1. Unsigned-byte cast widening sign-extended. # i64 # u 217 gave −39 instead of 217: a nested cast-to-unsigned never set the __last_unsigned__ side-channel the enclosing widening cast consults, so it defaulted to sext. gen_cast now records the cast target's signedness for an enclosing widen / binop / shift. (Previously only casts whose subject was a typed binding — where gen_ident sets the flag — widened correctly.) 2. Signed i8 arithmetic treated as unsigned. gen_binary inferred unsignedness from the LLVM type i8, but both the unsigned NURL u and the signed i8 lower to LLVM i8 — so signed i8 / % >> < selected udiv/urem/lshr/icmp u* and the result was marked unsigned, silently zero-extending a negative value at the next widen. Signedness now comes solely from the __last_unsigned__ flag (set by gen_ident from a binding's __unsigned and by gen_cast from an unsigned cast target), never from the ambiguous LLVM type. Bootstrap fixed point holds; full suite + ASan/UBSan green. Regression compiler/tests/cast_signedness.nu (12 known-answer checks). Validated by 340 fuzzer seeds (0 divergences).
with float round-trip + comparison + store-coercion probes; fixed in gen_cast). Same root cause — the LLVM integer type can't carry signedness, so it must ride the __last_unsigned__ side-channel. 1. Unsigned int → float used sitofp. # f # u32 0x80000001 became a negative float (≈ −2.1e9 instead of +2.1e9); # f # u 200 became −56. Now uitofp when the source is unsigned. 2. Float → int ignored target signedness. Now fptoui for an unsigned target (a value above the signed max no longer becomes poison), else fptosi. 3. A float result leaked its source int's stale unsigned flag. After # i64 # f # u …, the still-set __last_unsigned__ made a surrounding *// pick udiv on a negative product (e.g. −65 / 7 computed as an unsigned divide → garbage). Float-producing casts now clear the flag; float→int casts set it from the target. Regression compiler/tests/cast_int_float.nu (9 known-answer checks). Validated by 600 fuzzer seeds with the new float dimension (0 divergences).
nurlc integer codegen (tools/fuzz).
gen.py generates random sized-integer expression trees and, from the same tree, both a self-checking NURL program (prints each result's exact 64-bit pattern) and a Python reference oracle with explicit two's-complement / width / signedness semantics. fuzz.sh compiles each at -O0 and -O2 and requires stdout(-O0) == stdout(-O2) == oracle, catching miscompiles that are wrong at every optimisation level. Biased toward the historically fragile surface (width coercions, unsigned arithmetic, mixed signed/unsigned); generates no UB. Found and fixed two silent miscompiles on its first run (see Fixed, above). See tools/fuzz/README.md. Subsequently extended with let-binding store coercion, variable reuse, comparison operators, and int→float→int round-trips — which surfaced three more (see Fixed).
stdlib/ext/tar.nu. Pure-NURL POSIX.1-1988
tar: tar_create (entries → archive bytes), tar_parse (bytes → entries, in-memory), and tar_unpack (path-safe extract to disk). Composes with gzip_compress/_decompress to make the .tar.gz package format the registry will use. The reader treats archives as untrusted input: tar_unpack rejects absolute paths and .. components (TarUnsafePath) and refuses symlink/hardlink/device members (TarUnsupported) so nothing can escape the destination; every header checksum is verified (TarBadChecksum) and an over-long declared size is TarTruncated. v1 supports the 100-byte name field on write (TarPathTooLong otherwise) and honours the prefix field on read. Bidirectionally interop-tested against GNU tar (NURL→`tar xf and tar cf`→NURL both round-trip). Regression: compiler/tests/tar_basic.nu (round-trip incl. embedded NUL in file data, gzip composition, checksum tamper, ../ rejection, unpack + binary read-back); verified clean under ASan/UBSan. First building block of the registry-backed package manager (ROADMAP §4).
stdlib/ext/semver.nu. Pure-NURL semver
parse / compare / render with full precedence ordering, including the prerelease rules (§11: numeric < alphanumeric identifiers, fewer < more identifiers, prerelease < release; build metadata ignored). Plus version requirements: semver_req_parse turns a constraint (^1.2.3, ~1.2, >=1.0, <2.0.0, =1.2.3, 1.*, *, or a bare 1.2.3) into a half-open range, semver_req_matches tests a version, and semver_req_max_satisfying picks the highest matching version — the resolution primitive the registry-backed package manager needs (ROADMAP §4). Constraint dialect is Cargo-shaped: a bare 1.2.3 means ^1.2.3, use =1.2.3 to pin. v1 matches prereleases by pure range containment (no Cargo-style prerelease comparator special-casing yet). Regression: compiler/tests/semver_basic.nu (round-trip, the canonical §11 precedence chain, every constraint operator, max_satisfying, parse errors); clean under ASan/UBSan and leak-free.
stdlib/ext/manifest.nu's
Dep gained a registry field and Manifest gained a default [package].registry, so a dependency can now be expressed as a path dep ({ path = "…" }), a bare registry dep (foo = "^1.2", default registry), or an explicit registry dep ({ version = "1.0", registry = "…" }); dep_is_path / dep_is_registry discriminate. New stdlib/ext/lockfile.nu is a typed view over nurl.lock: a LockPkg { name, version, source, checksum } with lock_serialize (deterministic, name-sorted, Cargo-shaped [[package]] blocks; source/checksum omitted for path/local packages) and lock_parse / lock_load (round-trips through toml.nu's array-of-tables). checksum is the hex SHA-256 of the package tarball — the integrity pin a registry install verifies. Regressions: compiler/tests/manifest_registry.nu, compiler/tests/lockfile_basic.nu; clean under ASan/UBSan, leak-free. ROADMAP §4 phase 3 (data model for registry deps). nurlpkg's two Dep construction sites updated for the new field.
stdlib/ext/websocket.nu
gained the full client side to match the existing server. ws_connect / ws_connect_with parse a ws://… / wss://… URL, dial out (plain or TLS-with-cert-verification via the runtime client-connect primitives), send the HTTP Upgrade request with a fresh random Sec-WebSocket-Key, and validate the 101 response's Sec-WebSocket-Accept. Outbound frames are masked with a CSPRNG-drawn 4-byte key (ws_client_send_text / _binary / _ping / _pong / _close, ws_client_write_frame, ws_serialize_frame_masked); inbound server frames are read and required to be unmasked (ws_client_read_frame / _read_message / _serve_messages, which auto-pong masked). The frame reader/assembler is now shared between both directions via an internal __ws_read_frame_ex / __ws_read_message_ex parameterised on direction — no duplicated framing logic. Regression: compiler/tests/websocket_client.nu (RFC 6455 §5.7 masked-frame byte vector, URL parsing, and a live NURL_NET_TESTS=1 client↔server echo round-trip proving interop with the server stack). Example: examples/ws_client.nu (pairs with examples/ws_echo.nu).
http_*_bytes family. The s-body
http_request / http_post / http_put family recovers the body length via strlen, so a request body with embedded NUL bytes (binary file uploads, MessagePack, protobuf) truncated at the first NUL. New length-carrying variants take the body as a ( Vec u ) and ship it via CURLOPT_COPYPOSTFIELDS + an explicit POSTFIELDSIZE, so the exact byte count is sent: http_request_bytes / http_request_bytes_to, http_post_bytes, http_put_bytes, and the streaming http_stream_open_bytes_to. The body argument is borrowed (the caller still owns it). Binary fidelity requires the libcurl backend; the WinHTTP/stub fallback round-trips through a NUL-terminated s and degrades to the old truncation. stdlib/ext/http.nu.
is closed.** proxy_stream_to_conn_with forwarded the upstream request body by converting the request's ( Vec u ) body to a NUL-terminated s and shipping it through CURLOPT_POSTFIELDS (strlen-sized), truncating binary uploads at the first NUL. Worse, the streaming opener set non-copying CURLOPT_POSTFIELDS and the proxy freed the body buffer before the first multi_perform read it — a dangling-pointer read that only escaped notice on small JSON bodies. Both are fixed by routing the length-tracked body through http_stream_open_bytes_to, which uses CURLOPT_COPYPOSTFIELDS (libcurl snapshots the bytes at open time, so the caller may free immediately and embedded NULs survive). Regression: compiler/tests/http_binary_body.nu (NURL client http_post_bytes of a 5-byte A B \0 C D body to a loopback NURL server, asserting the server parsed all 5 bytes; NURL_NET_TESTS=1). Verified clean under ASan/UBSan. stdlib/ext/http.nu, stdlib/ext/http_proxy.nu.
A trailing parameter may carry a default: @ f s a s b = x i n = 3 → R (the default is a single source token — literal / const / atom). A call may then omit defaulted trailing arguments — ( f val ) — and/or pass arguments by name in any order, mixed with leading positional ones: ( f a: 1 b: 2 ), ( f val n: 5 ), ( greet greeting: Hi name: Bob ). Implemented as a call-site desugaring to an ordinary positional call: scan_fn_sigs records each function's parameter names + default sources; gen_call fills omitted trailing defaults inline, and routes a call that uses name: labels through gen_call_kwargs, which evaluates arguments in source order and assembles them in parameter order. Existing positional calls take the unchanged path (byte-identical IR — bootstrap fixed point holds). Regression: compiler/tests/kwargs.nu. Current limits (documented in the grammar): not on generic functions, FFI/variadic, or parameters with the inout/sink convention; **kwargs-style collection is not provided (pass a Json/struct).
stdlib/std/hash_blake3.nu implements full BLAKE3 (the ChaCha-derived compression function, 1024-byte chunks split into 64-byte blocks with CHUNK_START/CHUNK_END flags, the binary Merkle tree of chaining values, and the ROOT-flagged final node), exposed via blake3_bytes / blake3_hex in stdlib/std/hash.nu (unkeyed, 32-byte output). All-NURL u32 wrapping arithmetic, little-endian, binary-clean over ( Vec u ) — no C at all (compiler and runtime untouched). Verified digest-for-digest against the official BLAKE3 reference across every structural path (empty, sub-block, the 1024-byte single chunk, the 1025-byte two-chunk boundary, balanced multi-chunk trees up to 5000 bytes); regression compiler/tests/blake3.nu; clean under ASan/UBSan/LSan. Closes the ROADMAP "Extended Hash Family" item — SHA-1/256/512, MD5, HMAC, and BLAKE3 are all shipped.
volatile_load / volatile_store compiler intrinsics for MMIO. Emit
load volatile / store volatile as pure IR (no runtime call, so they work on a freestanding target). The optimizer can no longer hoist an MMIO read out of a polling loop (LICM), reorder accesses, or coalesce repeated reads/writes — the missing piece for spinning on a device status register at -O2. The access width comes from the typed pointer argument (*T), so one pair covers i8/i16/i32/i64. stdlib/hal/mmio.nu (mmio_read32/write32/set32/clear32) now uses them, so the ESP32 UART/GPIO drivers no longer need the -O0 workaround. Regression: compiler/tests/volatile_mmio.nu; verified at -O2 the volatile load stays inside the loop body.
stdlib/hal/esp32.nu). Pure-NURL GPIO
and UART0 over the chip's memory-mapped registers (built on stdlib/hal/mmio.nu) — no ESP-IDF, no FFI. GPIO output enable / set / clear, and a blocking UART console (esp32_uart_putc / getc / puts with FIFO-count helpers), with register addresses taken from the ESP32 TRM and cross-checked against ESP-IDF's soc/*_reg.h. Demonstrated by the new fully-NURL UART echo example (examples/esp32/idf-uart).
examples/c64). A MOS 6510 / Commodore 64
emulator in pure NURL — a single core.nu engine shared by a native CLI and a WebAssembly browser front-end. The CPU core passes Klaus Dormann's 6502_functional_test (the canonical 6502 correctness oracle, validated headlessly), and with stock KERNAL/BASIC/CHARGEN ROMs the machine boots through the full power-on sequence — PLA banking, CIA1 jiffy IRQ — to the BASIC READY. prompt.
numeric scanner stopped at the first non-decimal digit, so 0x3FF44008 became two tokens (0 + identifier x3FF44008) and the reformatted source miscompiled — silently, because --check is idempotent on its own broken output. tools/nurlfmt/tokenize.nu now scans a 0x/0b/0o prefix and its body as one token. Verified by the nurlfmt_idempotent.sh gate (450 files, IR-transparent) and by restoring the hex literals in the examples/esp32/* register maps that had been worked around with decimal constants.
stdlib/ext/sqlite.nu
is now binary-safe and resource-safe: - NUL-safe text I/O. sqlite_column_text reads the column's exact byte length via sqlite3_column_bytes (was strlen, which truncated at the first embedded NUL), and sqlite_bind_text now takes a String and passes an explicit byte length to sqlite3_bind_text instead of -1 — strings with embedded NULs round-trip intact. - BLOB support. New sqlite_bind_blob (Vec u → sqlite3_bind_blob + SQLITE_TRANSIENT) and sqlite_column_blob (sqlite3_column_blob + _bytes → owned Vec u) — the binary-safe write/read path. - sqlite_open_v2 with open flags. SQLITE_OPEN_READONLY / READWRITE / CREATE / URI / NOMUTEX / FULLMUTEX / NOFOLLOW constants exposed; sqlite_open is now READWRITE|CREATE over open_v2. A read-only connection refuses writes (new SqliteReadOnly error variant) instead of silently creating a file. - sqlite_busy_timeout wraps sqlite3_busy_timeout so SQLITE_BUSY blocks-and-retries under concurrent access rather than failing immediately. - % Drop auto-close. Database and Statement implement the Drop trait; a scope-local handle — including one unwrapped from a ! Database E / ! Statement E result in a match arm — closes itself on every path (Ok, Err, early return) with no manual sqlite_close/sqlite_finalize. Teardown zeroes the handle slot after closing, so a stale internal re-entry is a no-op. Verified leak-free and double-free-free under ASan + UBSan (compiler/tests/sqlite_hardening.nu). - Tier 3 — datatypes & transactions. sqlite_bind_double / sqlite_column_double (REAL columns), sqlite_column_is_null, sqlite_begin / commit / rollback, and a closure-based with_transaction that COMMITs on Ok and ROLLBACKs on Err (propagating the original error). - Tier 4 — hardening for untrusted SQL/DB. Extended result codes are enabled on every open, so constraint failures now map to distinct variants (SqliteConstraintUnique / …ForeignKey / …NotNull / …PrimaryKey / …Check). Added sqlite_last_insert_rowid; sqlite_set_defensive / sqlite_enable_load_extension / sqlite_harden (DEFENSIVE on + extension-loading off — blocks corruption/RCE from a hostile DB); sqlite_limit (bound query complexity); a closure-based sqlite_set_authorizer / sqlite_clear_authorizer that installs a sandbox callback with the exact C ABI libsqlite expects (the closure's compiled function + captured env are passed as xAuth + pUserData, the same mechanism thread_spawn uses for pthread_create — no C bridge); and PRAGMA helpers sqlite_journal_wal / sqlite_foreign_keys / sqlite_synchronous. Verified under ASan + UBSan (compiler/tests/sqlite_tier34.nu).
% Drop
type bound as a ?? match-arm payload (e.g. ?? r { T db → … }) — or a : let inside a match arm — is now dropped at arm scope exit, on the same void-arm-only rule used for owned strings/structs. Previously such bindings were never dropped (a latent leak); this is what lets the SQLite handles above close automatically in the idiomatic result-unwrap flow.
(was v2.0) and points at spec/grammar.ebnf. Items that were marked pending but are in fact shipped are now [x]: the async runtime (stackful M:N fibers — the Coroutines-vs-async/await decision is settled), **HTTP server Phase 8 (production hardening) and Phase 9** server-side (TLS+SNI+ALPN+ mTLS+reload, HTTP/2, WebSocket — client-side remains), the **optional -lcurl sentinel-gated linking, and the nurlc_lastgood.nu refresh** lifecycle (documented via --refresh-bootstrap). Added an explicit "What's actually left" summary to the Status section (HTTP/2+WebSocket client-side; mobile/no_std targets; SQLite BLOB/double; reverse-proxy binary bodies; blake3; MCP SSE/sessions/auth; the runtime.c file-split; a compiler-embedded LLM; bench peers). Stale build-size figures left only in dated historical "shipped" entries (records, not current claims).
~480 KB nurlc.wasm (docs/PLAYGROUND.md) and ~1.6 MB nurlc_lastgood.ll (docs/BUILDING.md) figures drift every build and mislead when the real artifact differs. Build sizes belong in the changelog/release notes (tied to a specific version), not in instructional docs.
GOTCHAS.md item N / §N references out of code comments.
After docs/GOTCHAS.md lost its numbered list, ~44 source comments (in compiler/nurlc.nu, the nurlc_lastgood.nu snapshot mirror, nine compiler/tests/*.nu, and stdlib/ext/{http_middleware}.nu) still pointed at item/section numbers that no longer exist. Each now points at the real home (escape/lifetime → docs/MEMORY.md §2.3, grammar → docs/LIMITATIONS.md) or simply describes the behaviour inline. The nurlc_lastgood.nu edits are comment-only — verified to produce byte-identical IR, so the committed bootstrap nurlc_lastgood.ll is unchanged; the build still reaches its fixed point and the full test suite passes.
docs/GOTCHAS.md reduced to "Currently no known gotchas." Every
source-level trap is now a compiler diagnostic (error:/warning: with a caret + cure), so the page no longer lists a museum of resolved issues. The real content that lived there was relocated to its proper home: the fiber-runtime operational caveats (non-blocking handle flipping, runtime_run blocking, stack-borrow capture, plus runtime-maintainer notes on TLS-under-LTO and the reactor park/unpark ordering) moved to docs/ASYNC.md → Operational caveats, and the : ~-capture lifetime rule now points at docs/MEMORY.md §2.3. Updated every back-reference (docs/spec.md, docs/LIMITATIONS.md, ROADMAP.md's "5 active quirks" status line, the VS Code extension README, and stale GOTCHAS.md item N comments in stdlib/ext/toml.nu, mcp_http.nu, http_multipart.nu). All internal links verified.
docs/LIMITATIONS.md scoped to actual language/compiler limitations.
Removed the standard-library capability tables (PostgreSQL, SQLite, panic/recover) that were never language limitations — that information lives with each module (stdlib headers, ROADMAP.md). Moved the HTTPS/TLS table to docs/NETWORKING.md where it belongs. Removed two entries that were stale (the behaviour already works, verified empirically): "no tail-call optimisation" (self-recursive tail calls emit tail call → LLVM sibcall-opt; 50M-deep tail recursion runs without overflow) and "enum forward references unsupported" (scan_type_names registers type names before codegen, so a struct payload can be declared after its enum). The page now lists only language/compiler constraints (Type system, Functions/calls, Enums, Imports, Grammar).
relative link inside a rendered doc (e.g. docs/LIMITATIONS.md from the README, or ../spec/grammar.ebnf from a docs/ page) used to hit "not found". nurlapi now serves the repo doc tree by its natural path — /docs/*, /spec/*, /bench/*, and the capitalised top-level /README.md · /ROADMAP.md · /CHANGELOG.md · /CONTRIBUTING.md — rendering .md to HTML (__serve_repo_doc, path-traversal-guarded) and serving other files as text; examples/*.md renders too (.nu stays JSON for the editor). Because the route hierarchy mirrors the repo, the browser's own relative-link resolution chains correctly between docs. The container image now copies the whole docs/ tree (was only GOTCHAS.md) plus CHANGELOG.md, CONTRIBUTING.md, and bench/.
kitchen-sink README is now a ~230-line overview (why/principles, architecture, quick start, syntax-at-a-glance, a documentation index, and project layout) that links out to focused pages under docs/. New: docs/BUILDING.md, docs/TOOLING.md, docs/PLATFORMS.md, docs/PLAYGROUND.md (HTTP API + playground + MCP), docs/NETWORKING.md (sockets + MQTT), docs/LIMITATIONS.md. Syntax/type/memory sections now point to the existing authoritative homes (spec/grammar.ebnf, docs/spec.md, docs/MEMORY.md) instead of duplicating them.
hard-codes a grammar version, benchmark tables (point to bench/), the example file list, the MCP tool count, or the .vsix version. The PostgreSQL "Known Limitations" (claimed no binary protocol / async / LISTEN-NOTIFY / COPY — all shipped) and the MQTT section (TLS-only + verify-on-by-default + exactly-once QoS 2 + subscribe_many) are now accurate. Dropped references to non-existent spec/types.md / ir.md / bootstrapping.md, fixed CONTRIBUTING.md's api/ → nurlapi/, a dead HTTP_SERVER_PLAN.md link in ROADMAP.md, the compiler's prefix-arity diagnostic (pointed at the moved README section → docs/LIMITATIONS.md), and docs/GOTCHAS.md's cross-reference. All internal doc links verified.
mqtt_subscribe_many) sends one
SUBSCRIBE for N filters at a shared max QoS and validates every per-filter SUBACK reason code (a new __mqtt_check_suback that parses the property block instead of assuming a single trailing byte — mqtt_subscribe_qos now uses it too).
COPY** (stdlib/ext/postgres.nu). Closes the last Tier-5 Postgres gap; all four are pure-NURL libpq FFI (no runtime.c bridge) and are exercised end to end by the new examples/pg_advanced.nu, live-verified against PostgreSQL 16.14. - Binary result protocol: pg_exec_params_binary requests resultFormat = 1; pg_get_i16_bin / _i32_bin / _i64_bin / _bool_bin / _f64_bin decode network-byte-order cells (float8 reinterpreted from its IEEE-754 bit pattern, not a numeric cast), with pg_get_length / pg_field_format / pg_binary_tuples. - Asynchronous queries: pg_send / pg_send_params dispatch without blocking; pg_get_result (→ ?PgResult, None when finished) and the blocking convenience pg_await collect results; pg_consume_input / pg_is_busy / pg_socket / pg_flush / pg_set_nonblocking hook into an event loop. - LISTEN/NOTIFY: pg_listen, pg_notify_send, pg_notifies (→ ?PgNotify { relname, be_pid, extra }, read after pg_consume_input) and pg_notify_free. - COPY: pg_copy_start (accepts the PGRES_COPY_IN / COPY_OUT handshake that plain pg_exec rejects), pg_put_copy_data / pg_put_copy_str / pg_put_copy_end for COPY … FROM STDIN, and pg_get_copy_data (→ ?String) for COPY … TO STDOUT.
payload.** @ f !S E r → S { ?? r { T x → ^ x } } emitted ret i64 against the %S return type (an LLVM "value doesn't match function result type" error), and the same gap mishandled a ( Vec u ) handle payload and dropped the unsigned flag on a ?u parameter (sign-extending a byte ≥ 0x80). gen_fn_param now records the <param>__res_nurl_T / __res_t_llvm / __res_e_llvm / __opt_nurl_T metadata that gen_let_or_struct already records for let-bound result vars, so gen_match reconstructs struct / pointer / unsigned payloads for a parameter scrutinee exactly as it does for a let binding. Bootstrap fixed point held; regression compiler/tests/match_param_payload.nu.
{} returned from a void function emitted
invalid IR.** An empty block is the unit/void value, but gen_block left the "last type" at whatever preceded it (i64 by default, i1 inside a conditional), so ^ {} in a void function produced ret i64 undef — rejected by LLVM. The block now types as void when it has no trailing statement.
undefined SSA value with exit status 0** (PR #25 / Fixes). gen_ident's bare %<name> fallback fired for any name lacking a __ptr / __global binding — so : i x ^ a b c emitted ret i64 %a that nurlc accepted and only clang rejected. The fallback now requires a by-value parameter and otherwise dies with "use of undefined identifier". This was critic.md §4's headline contradiction of "every trap is a compiler diagnostic". Regression compiler/tests/should_fail_undef_ident.nu.
next ^ silently returned early** (PR #25 / Fixes). : i x + 1 / ^ a parsed as + 1 (^ a) → ret %a plus a dead add, exiting 0. A new g_ret_forbidden flag (armed by a gen_operand wrapper around every value-operand parse, reset by gen_stmt and the ? / ?? arm bodies) makes gen_ret refuse to emit a ret in operand position. Regression compiler/tests/should_fail_cascade_caret.nu.
??-match on a direct-call scrutinee dropped pointer/handle
payloads and option signedness** (PR #25 / Fixes). `: ( Vec u ) x ?? ( f … ) { … } / : s x ?? ( f … ) { … } left the binding undef` (no T-arm reconstruction, no result phi) for handle/pointer payloads, and `?? ( vec_get [u] … ) { T b → # i b }` sign-extended an unsigned byte. The callee's Ok/Err-payload LLVM types and option-inner token are now recorded per function and surfaced to gen_match's direct-call synthesis (with a bare-i8* inttoptr path for both arms). Regressions compiler/tests/match_bind_call_handle.nu, match_call_opt_unsigned.nu.
truncate** (PR #25 / Fixes). @ ?u { T 0x86 } emitted `insertvalue { i1, i8 } …, i64 134, 1, which clang rejected. gen_agg_lit`'s opt/res payload coercion now truncs/sexts/zexts the literal to the payload width (option = T's real width, result = i64). Regression compiler/tests/opt_lit_payload_width.nu.
QoS 2 PUBLISH was acknowledged but re-delivered to the application. The client now tracks inbound packet ids across their PUBREC…PUBCOMP window (MqttClient.qos2_rx, bounded, oldest evicted past 256), acknowledges a duplicate but delivers it only once. __mqtt_parse_publish returns ?MqttMessage (None on a de-duplicated retransmit) and the dedup policy is unit-tested in compiler/tests/mqtt_qos2_dedup.nu. The doc-drift comment on __mqtt_do_publish (claimed a fixed packet id) was corrected.
default.** mqtt_connect_cfg / mqtt_reconnect previously hard-coded verify = F, so every TLS connection was effectively --insecure (MITM-able). MqttConfig gained a tls_verify field, threaded through to tcp_connect_tls; mqtt_config defaults it to T (peer-cert chain + host name verified against the system trust store). Set it F only for a self-signed broker in a trusted environment.
pg_listen SQL injection (critical) — fixed. A channel name is a SQL
identifier and cannot be a bound parameter, so it now goes through pg_escape_identifier (PQescapeIdentifier) before interpolation; raw concatenation previously let pg_listen c "x; DROP TABLE …; --" execute the injected statement. (pg_notify_send was already safe — it binds the channel as a value to pg_notify($1, $2).)
PQgetlength-checked __pg_bin_ptr guards every pg_get_*_bin: reading an int4 cell with the 8-byte pg_get_i64_bin, or any accessor on a binary SQL NULL (0 bytes), now returns 0 instead of reading past the cell into adjacent libpq buffer memory.
pg_connect and the
file header now carry a prominent warning that libpq's default sslmode=prefer neither prevents a silent plaintext fallback nor verifies the server certificate (MITM-able), recommending sslmode=verify-full sslrootcert=… for non-local connections. Not force-defaulted, as that would break legitimate unix-socket / trusted-LAN connections. Minor: pg_get_bool gained a NULL-pointer guard, and the empty-on-NULL behaviour of pg_escape_literal / pg_escape_identifier is now documented.
A full Game Boy (DMG) emulator written in NURL now plays commercial games with sound. examples/gameboy/ passes Blargg cpu_instrs 11/11, instr_timing and 02-interrupts, is 100 %/pixel-perfect on dmg-acid2, and runs Tobu Tobu Girl end to end — full gameplay plus a complete 4-channel APU mixed to stereo — in the browser at /gameboydemo via the WebAssembly target. Building it drove three new language/compiler features (hex/binary integer literals, pointer/aggregate global initialisers, hex literals in match) and turned one silently-accepted bare-literal statement into a hard compile error.
Generics now range over option and pointer element types — Vec ?T, vec_get [?T] → ??T, ??T parameters/returns and nested ?? matching all compile (five front-end root-cause fixes). The PostgreSQL client is production-grade (stdlib/ext/postgres.nu + examples/psql.nu), including option-typed nullable params and getters (pg_exec_params_opt, pg_get_opt), verified live against PostgreSQL 16 under AddressSanitizer.
HTTP/2 + HPACK + WebSocket conformance suites remain green: h2spec 2.6.0 reports 146/146 cases against examples/h2c_server.nu; the autobahn-testsuite fuzzing client reports 294 OK / 4 NON-STRICT / 3 INFORMATIONAL / 0 FAILED across all 301 RFC 6455 cases against examples/ws_echo.nu. Both binaries run under ASan + UBSan without findings.
Bootstrap fixed point at 1 772 342 B (stage1 ≡ stage2 byte-identical IR).
examples/gameboy/. A cycle-aware Sharp
LR35902 core (every opcode + CB-prefix, exact Z/N/H/C flags + DAA, EI/DI IME enable-delay, HALT + HALT-bug, DIV/TIMA timer, interrupt dispatch) passing **Blargg cpu_instrs 11/11, instr_timing and 02-interrupts; a BG/window/sprite PPU that is 100 %/pixel-perfect on dmg-acid2** (0/23040 diff — LYC raster + window internal line counter); MBC1/3/5 mappers, joypad and OAM DMA; and a complete 4-channel APU (2 square w/ sweep, 4-bit wave RAM, 15-bit-LFSR noise, 512 Hz frame sequencer, NR50/51 mix, DMG high-pass) mixed to stereo. The engine is split into a shared core.nu with gb.nu (CLI) and gb_wasm*.nu (wasm32-wasi → canvas) front-ends; the browser demo at /gameboydemo auto-starts Tobu Tobu Girl and plays it with sound through the playground audio shim. Two sub-instruction timing fixes (TIMA increments on the DIV falling edge; the fetch M-cycle is clocked before the instruction body) took it from a title-screen crash to full gameplay. Build the wasm at -O2 (lower -O leaks the C shadow-stack pointer on the interrupt-dispatch path).
element types are now first-class generic type arguments: vec_get [?String] → ??String, Vec ?T / vec_push / vec_set / vec_free_with, ??T as a parameter and return type, and nested ?? o { T inner → ?? inner { … } } matching all compile — every one of these previously failed at compile time. Five front-end root-cause fixes, each verified by a full bootstrap + test-suite run and an ASan-clean probe: (1) parse_type_optopt for the fused ??T token; (2) capture_type_arg_src + nurl_src_to_llvm + an opt_ mangle/demangle round-trip so compound type args like [?String] are one substitutable word; (3) ;-separated closure parameter types so an aggregate type ({ i1, %String }) no longer truncates at its first space; (4) slice-vs-pointer store discrimination; (5) int → aggregate zeroinit. Test corpus on branch feature/generic-option-types (PR #21).
0xFF, 0b1010. Added to the
number lexer; the token carries the parsed value and keeps its spelling for diagnostics. Two companion compiler fixes: pointer- and aggregate-typed global initialisers (: s g 0 → global i8* null, : String g 0 → zeroinitializer, inttoptr for a nonzero address), and hex-literal normalisation in match (int-patterns `?? op { 0xCB → … } and enum field-constraints Code 0xFF → …` are rewritten to decimal before the icmp, since LLVM reads 0x… as a hex float). Regression test compiler/tests/hex_literals.nu.
psql CLI.
stdlib/ext/postgres.nu reaches production grade: a PgParams builder (pg_bind_text/str/int/bool/null) for typed + NULL parameter binds the libpq/pgx way, pg_prepare / pg_exec_prepared, pg_run, pg_begin/commit/rollback, typed getters (pg_get_int/f64/bool), pg_reset / pg_err_msg / pg_server_version / pg_escape_literal / pg_escape_identifier, and — now that generics range over option types — option-typed nullable params/getters `pg_exec_params_opt ( Vec ?String ), pg_get_opt → ?String, pg_get_opt_int → ?i`. New examples/psql.nu (aligned-table renderer, command tags, multi-line ; accumulation, \dt \d \l \du \conninfo meta-commands, -c "SQL" one-shot) and examples/pg_optional.nu. Verified live against PostgreSQL 16 under ASan (PRs #20 / #22).
env.audio_out_push host
shim streams packed-stereo i64 samples to 48 kHz Web Audio, letting WASM programs emit sound; demonstrated by examples/audio_tone.nu and used by the Game Boy demo's APU output.
[A: Trait]. A generic type
parameter may now carry one or more trait bounds: `@ my_max [A: Ord] A x A y → A { … }`. Trait-method dispatch inside a generic body already resolved to the concrete impl through monomorphisation (dispatch is keyed on the first argument's LLVM type, which becomes concrete at instantiation); the bound adds the up-front guarantee. scan_impl_decl now registers each % Trait Type {} as Trait##<llvm> in g_trait_syms; gen_generic_fn_store records per-tparam bounds; and check_generic_bounds (called from gen_call at every generic call site) verifies each bounded tparam's concrete type has the impl — turning a missing impl from a cryptic unresolved-call link error into a clear "type 'X' does not implement trait 'Y' required by bound A: Y" diagnostic. Generic detection in gen_fn_decl extended to recognise a colon anywhere in the […] (a slice param's type never contains one). This removes the need to pass Ord/Hash/eq closures into generic helpers when an impl exists. Tests compiler/tests/trait_bounds.nu (positive, i + String) and should_fail_trait_bound.nu (bound violation → COMPILE FAIL). Bootstrap fixed point holds (stage1 ≡ stage2 byte-identical at 1 730 148 B).
?? match guards + or-patterns. Two additions to gen_match:
- Guards — Pattern payloads ? <cond> → body. The guard is evaluated after payload binding (so it can read the bound payloads); a false guard falls through to the next arm. Implemented by recording the guard's source span during arm parse and replaying it via nurl_lex_set_pos at the arm body, branching to the body or the next arm. A guarded arm does NOT satisfy exhaustiveness for its variant — a catch-all (unguarded or _) is still required. Not allowed on a _ wildcard arm or combined with an or-pattern. - Or-patterns — A | B | C → body: several tag-only named variants share one body (emit_or_chain lowers the alternatives to a tag-compare chain). No payload binding or literal constraints; all listed variants count toward exhaustiveness.
Test compiler/tests/match_guards_or.nu. Bootstrap fixed point holds (stage1 ≡ stage2 byte-identical at 1 720 428 B).
integer const (: i NAME …, or u / sized ints — not b) may now take a prefix expression over integer literals instead of a single literal: + - * / << >> & | ^^ (not %, which collides with the trait/impl decl sigil at scan time). const_eval_int in gen_const_decl folds it to one value. Fixes the long-standing wart where e.g. the two's-complement minimum needed a niladic helper — stdlib/std/int.nu now exposes : i INT_MIN - -9223372036854775807 1 directly (int_min_val retained, delegating to it). Transparent (computes a value, hides no control flow); fits the parse-directed architecture. Test compiler/tests/const_eval.nu. Bootstrap fixed point holds.
select over channels — ?? { … } — Go-style select. A ??
whose scrutinee is immediately { (no value to match) is a channel select; each arm [T] ch → bind { body } receives from one channel and the construct proceeds with the first ready arm. With no _ default it BLOCKS until some channel is ready (value sent or channel closed); a _ → { … } default makes it non-blocking. bind is the ?T the receive yields (None ⇒ closed). Arms are heterogeneous (each channel may carry a different element type) and tried in source order. Implemented in gen_select (compiler/nurlc.nu) as a desugaring that synthesises NURL source from the verbatim user channel-exprs + bodies and compiles it through a sub-lexer — no raw IR, no new lexer token. The blocking rendezvous (a shared SelectWaiter armed on every channel, fired by senders/closers under the channel mutex) lives in stdlib/std/channel.nu via the type-erased chan_raw_poll / chan_raw_arm / chan_raw_disarm / select_waiter_* helpers — the element type drops out of the orchestration, so one non-generic code path serves channels of any type. Test compiler/tests/select_basic.nu (deterministic default / value / closed / priority cases always-on; concurrent blocking path gated on NURL_NET_TESTS=1). Bootstrap fixed point holds (stage1 ≡ stage2 byte-identical at 1 691 603 B).
additions (no compiler changes, each with an offline test): - stdlib/std/int.nu: int_gcd, int_lcm, int_isqrt (Newton-method exact floor sqrt). Test compiler/tests/int_extra.nu. - stdlib/std/float.nu: float_trunc, float_cbrt, float_hypot, float_log2, float_log10 (direct libm FFI) + pure-NURL float_sign. Test compiler/tests/float_extra.nu. - stdlib/core/string.nu: string_join (complement of string_split) and string_count (non-overlapping occurrence count). Test compiler/tests/string_join_count.nu. - stdlib/core/char.nu: is_upper, is_lower, is_hexdigit, to_upper_ascii, to_lower_ascii, hex_val. Predicates use the same # i <bool-expr> shape as the existing is_alpha / is_digit family — now returning a canonical 1/0 thanks to the cast fix below. Test compiler/tests/char_extra.nu.
gen_binary
produced icmp eq i8* %p, 0; comparison operators now ptrtoint any pointer operand to i64 and compare in i64, so == raw 0 null-checks compile. Found bringing postgres.nu to production grade.
^ <void-call> (returning a → v call) emitted a value return.
Returning the result of a void function now lowers to ret void instead of attempting to return a non-existent value. Found in the postgres work.
error.** Previously & m 255 0x40 (single &) silently discarded the trailing 0x40 — a bare-literal discard statement the compiler accepted — which masked a real masking bug in the Game Boy PPU's STAT-bit-6 handling. gen_block_stmts / gen_block_ret now reject a bare literal whose value is unused. (The no-workarounds dividend from debugging dmg-acid2.)
# i <bool> now zero-extends (was -1 for true). Casting a boolean
(an i1 from a comparison / & / | / !) to a wider integer emitted sext i1, so # i true was -1 instead of 1. Harmless for the ubiquitous != 0 callers, but it silently broke every predicate documented as "→ 1": is_alpha / is_digit / is_space / is_alnum_us all returned -1 for true. NURL has no signed 1-bit type, so a boolean true is canonically 1 — gen_cast now forces zext for any i1 source (comparisons never set the __last_unsigned__ side-channel that the unsigned-widen path relies on, hence the explicit guard). Latent fix across the whole stdlib; no existing test output changed (nothing depended on the -1). Regression compiler/tests/cast_bool_int.nu. Bootstrap fixed point holds (stage1 ≡ stage2 byte-identical at 1 660 838 B).
HttpOptions struct (HTTP client) — stdlib/ext/http.nu gained
`HttpOptions { i timeout_ms, i connect_timeout_ms, i follow_redirects, i max_redirects, i verify_tls, s user_agent }` bundling the per-request transport overrides that were previously hardcoded in the libcurl orchestrator. New entry points: http_options_default → HttpOptions, http_request_with_opts, and http_get_opts / http_post_opts conveniences. The orchestrator body moved into __libcurl_perform_full_opts (wires CURLOPT_FOLLOWLOCATION / MAXREDIRS / SSL_VERIFYPEER / SSL_VERIFYHOST / USERAGENT from the struct); the legacy timeout-only __libcurl_perform_full_to is now a thin shim over it, so http_request / http_request_to are behaviour-preserving. user_agent is borrowed s so HttpOptions owns nothing (no free fn, safe by-value). WinHTTP / stub backends honour only the two timeouts (redirect / TLS / UA ignored — documented). Stdlib-only; compiler IR unperturbed. Tests: offline compiler/tests/http_options.nu (always-on) + a live GET_OPTS case in compiler/tests/http_basic.nu.
examples/h2c_server.nu — minimal cleartext-HTTP/2 ("h2c,
prior-knowledge") echo server (~135 LOC). Async accept loop via stdlib/std/async.nu so h2spec's probe + test connections can be served concurrently; per-conn read timeout of 1 s keeps the sequential accept queue draining when a test deliberately leaves a connection half-open; response body sized ≥ 5 bytes so the dataLen >= 5 gate in h2spec §6.9.2/2 runs the test instead of skipping. Verified green under both ./nurl.sh and NURL_SAN=1 ./nurl.sh.
examples/ws_echo.nu — minimal WebSocket echo server (~110
LOC). Uses the stdlib ws_perform_handshake + ws_serve_messages pair against the same TCP accept loop. Per-server WsLimits raises fragment_max_count to 131 072 so autobahn §9.x's 4-MiB message split into 65 536 frames assembles successfully; per- frame and per-message byte caps stay at the stdlib defaults.
NURL_SAN=1 support in nurl.sh — drops -flto, adds
`-fsanitize=address,undefined -fsanitize-address-use-after-scope -fno-omit-frame-pointer -fno-sanitize-recover=all` at the link, and builds a side-by-side stdlib/runtime_san.o (non-LTO, matching flags) if stdlib/runtime.c is newer than the cached artefact. Matches the toolchain ./build.sh --san already uses for its own corpus.
stdlib/ext/http2_hpack.nu
__hpack_lower_name_dup) — RFC 9113 §8.2.2 mandates lowercase header field names on the wire; hpack_encode_headers now lowercases every name before encoding. Previously curl's HTTP/2 parser rejected our Content-Type response header.
__h2_send_response — when the
stream OR connection send-window is exhausted mid-response, the writer reads frames off the peer and applies WINDOW_UPDATE / SETTINGS / PRIORITY semantics in place (RFC 9113 §5.2.1, §6.9.1). HEADERS for a new stream during the pump is refused with RST_STREAM(REFUSED_STREAM) per §5.1.2. Empty DATA(END_STREAM) fallback (§6.9.1 permits zero-length DATA + END_STREAM regardless of window state) closes the stream cleanly when the pump bails.
headers in stdlib/ext/http2_conn.nu`) — RFC 9113 §8.3 / §8.2.1 / §8.2.2: lowercase names, pseudo-headers precede regular ones, exactly one :method / :scheme / :path (non-empty), no duplicate or response-only or unknown pseudo-headers, no connection-specific headers (Connection, Proxy-Connection, Keep-Alive, Transfer-Encoding), and TE — if present — holds exactly "trailers". Runs immediately after HPACK decode succeeds on both the HEADERS+END_HEADERS and HEADERS+CONTINUATION+ END_HEADERS paths.
/ PRIORITY / DATA stream-ID + length + ACK rules per §6.5 / §6.4 / §6.3 / §6.1 / §6.8.
a HEADERS frame on a stream already in open / `half-closed- local` state as the trailers section. Trailers MUST carry END_STREAM; decoded fields are discarded but end_stream_received is marked and the handler is dispatched.
now PROTOCOL_ERROR (we advertise SETTINGS_ENABLE_PUSH=0).
PRIORITY-flag — a stream MUST NOT depend on itself; rejected as PROTOCOL_ERROR.
carries a stream's or connection's send-window above 2^31-1 is now FLOW_CONTROL_ERROR (stream-level → RST_STREAM, conn-level → GOAWAY).
with sid > last_peer_stream_id (never opened) is PROTOCOL_ERROR; on a closed stream silently no-ops.
updates after any indexed/literal field in the block are COMPRESSION_ERROR (new seen_field flag in hpack_decode_block), and the new size is bounded by h2_default_header_table_size (4 096) — our advertised SETTINGS_HEADER_TABLE_SIZE — rather than the table's current max_size, which may have been lowered by a previous update in the same connection.
_mismatch) — when a request carries content-length`, the sum of DATA-payload lengths MUST equal that value. Mismatched (or unparseable, or duplicated and disagreeing) content-length becomes PROTOCOL_ERROR before handler dispatch.
payload length 1 → WsInvalidCloseCode (close code 1002, not 1000); status code outside 1000–2999 OR 1004 / 1005 / 1006 / 1015 / 1016+ → close 1002; close-reason bytes validated as UTF-8. Previously a close frame's payload was discarded outright and the server replied with WsClosedByPeer → 1000 regardless of what the peer sent.
stdlib/runtime.c,
nurl_tcp_accept) — disables Nagle's algorithm on every accepted TCP connection. Small framing-level ACKs (SETTINGS-ACK, PING-ACK, WINDOW_UPDATE) were otherwise pinned behind the previous write for up to 40 ms, which is exactly the window h2spec's per-test short timeouts can't tolerate.
h2_default_header_table_size constant in
stdlib/ext/http2_frame.nu — value 4 096, used as the upper bound for HPACK dynamic-table-size updates and matches the RFC 9113 §6.5.2 default for SETTINGS_HEADER_TABLE_SIZE.
scan_fn_sigs is now brace-depth-tracked — only TT_AT / TT_AMP
/ TT_DOLLAR / TT_PERCENT openers at depth 0 trigger their respective dispatch branches; everything inside { ... } advances silently. Matches the pattern scan_type_names already used (see the docstring there). Closes the family of param-walk-desync bugs.
per RFC 9113 §6.8 the receiver of GOAWAY MUST keep processing in- flight frames (PING, RST_STREAM, in-progress streams) until the peer closes the socket; only NEW stream creation is forbidden. Previously we hard-exited the serve loop on the first GOAWAY, which broke the h2spec GOAWAY-then-PING sequence.
preface was structurally invalid (H2FrameBadPreface); on a read error (timeout / EOF / IO) we tear down silently. GOAWAY-on- every-preface-error was being seen by h2spec's per-test probe connections and counted as the test response.
scan_fn_sigs brace-depth desync — the @ inside a closure-
shaped struct field type (( @ HttpResponse HttpRequest ) handler) was treated as the start of a function declaration; the param walker then read HttpResponse as the phantom fname and the NEXT type-name-shaped token (in stdlib/ext/http_server.nu, DosLimits, declared 5 lines later) as that phantom function's ret_ty, silently writing syms["HttpResponse"] = "%DosLimits". gen_match's wide-payload reconstruction for a : ! HttpResponse WsErr rr (...) binding then looked up syms["HttpResponse"] to size the heap-box load and emitted inttoptr i64 ... to %DosLimits* + load %DosLimits + `bitcast %DosLimits ... to i8` against the real HttpResponse pointer. Under -O1+ this manifested as a runtime nurl_peek of a misaligned sub-page address (the HttpResponse i64 status field read as a Vec ctl). Under -O0 the extra reload of the alloca round-tripped the bits exactly so the struct's field accesses happened to land at the right offsets, hiding the bug.
__h2_stream_to_request double-freed req.query when the
request had no ? in the path — the field was freed unconditionally but only reassigned inside the qi >= 0 branch. request_free then freed the dangling pointer again. Clear ASan use-after-free on the very first h2c request through h2spec.
__h2_decode_stream_headers freed the old cur.dec_dyn before
assigning the new dd.dyn** — but HpackDynTable.entries is aliased through the by-value pass into hpack_decode_block, so the two wrappers shared one Vec ctl. The free turned the new assignment into a dangling pointer; subsequent reads on connection close tripped nurl_peek.
hpack_decode_block failure path freed cur — but cur was
initialised from the input dyn (struct copy, entries Vec pointer-aliased), so freeing in the error path left the caller's dec_dyn pointing at a freed Vec entries pointer. The next h2_conn_free vec_free_with double-freed.
__h2_frame_err_to_conn returned bare enum tags from ??-arm
bodies** when the function return type wrapped them as a struct; follow the established __net_err_of convention with explicit # H2ConnErr Tag casts so the IR's ret %H2ConnErr matches the function signature.
nurl_str_slice_unsafe did pointer-load instead of pointer
arithmetic** — . rp from lowers to "load the byte at rp+from", not "compute address rp+from". The code intended an unsafe substring view (rp + from interpreted as a string pointer); now spelled # s + # i raw from (cast-add-cast).
stdlib/ext/http2_conn.nu — ( % n 6 ) and ( . rp from ). The diagnostic that rejects these landed 2026-05-22 but http2_conn.nu was never on the build/test path, so they sat silently until examples/h2c_server.nu pulled the file in.
__pow2 defined in two translation units — once in
stdlib/ext/http_response.nu (used for hex-format expansion) and once in stdlib/ext/http2_hpack.nu (used for HPACK integer width). Linker rejected the redefinition the first time both modules were used together; the HPACK helper is now __hpack_pow2.
__h2_apply_settings sign-extension — byte-shift-and-OR
assembly of the 24-bit length / 32-bit value fields used # i u to widen each payload byte without masking, so any byte ≥ 0x80 propagated as a negative i64 into the next shift, corrupting value. Fixed with explicit & 255 masks at every byte read; the same fix applied to the WINDOW_UPDATE increment decode in the main serve loop and in __h2_pump_one_frame.
autobahn-testsuite §6.4.1-4 UTF-8 fail-fast — accepted asNON-STRICT (the spec permits either streaming UTF-8 rejection or whole-message validation; we do the latter). Documented for follow-up work.
./build.sh --san ASan/UBSan corpus runs WebSocket closevalidation** end-to-end through autobahn-testsuite's first seven case sections (92/92 OK; 0 sanitizer findings).
docs/GOTCHAS.md remains empty — every gotcha surfaced
during the interop push (parenthesised-operator calls, sign- extension, __pow2 collision, enum-tag-cast-on-return) is diagnosed by the compiler at compile time.
.github/workflows/bench.yml — reproducible CI bench runner.
Triggers on push-to-main (paths-filtered to bench/, compiler/, stdlib/, bench.yml itself), workflow_dispatch, and a weekly Monday 06:00 UTC cron. Installs clang + rustup stable + the FFI libs the regular ci.yml uses, bootstraps nurlc, runs bench/run.sh 5 on a fixed ubuntu-latest 2-vCPU runner, and uploads the results as a workflow artifact. Manual / scheduled runs additionally commit a refreshed bench/RESULTS_CI.md back to main. The README's headline numbers (captured on a 12-core Intel @ 3.5 GHz) stay in RESULTS.md as the hand-captured figures; RESULTS_CI.md is the reproducible baseline.
bench/token_efficiency.py + bench/TOKEN_EFFICIENCY.md —
BPE-aware token counts using tiktoken's cl100k_base (GPT-3.5 / GPT-4 / Claude legacy), o200k_base (GPT-4o / o-series), and gpt2 (proxy for Llama-3, which is HF-gated) against every cross-language benchmark in bench/. NURL/Python BPE-aware token-count ratios on these three benchmarks are 0.82–0.95× (LCG, all encoders) / 1.88–2.06× (sieve) / 1.60–1.77× (json_parse).
bench/{lcg,sieve,json_parse}.nu cleanup — dropped the
redundant $ "stdlib/core/io.nu" import (the puts and nurl_str_int calls resolve through the compiler's libc/runtime prelude) and switched the trailing print from ( puts ( nurl_str_int x ) ) to the one-call ( nurl_print_int x ). sieve.nu also drops the redundant FFI decls for malloc and free (both pre-registered by init_syms). Net result: −29 to −80 source bytes per file, NURL token counts down ~6–10 % across every encoder, and @main LLVM IR is byte-identical for both compute benchmarks.
--strict-borrowck (off by default) — opt-in mode that
extends two existing on-by-default checks: - Aliased mutation through . obj field arguments. The default N-readers-XOR-1-writer check fires only when both aliasing arguments at a call site are bare identifiers. Strict mode also recognises . obj field as an access of the root binding obj, so ( swap c . c n ) is now flagged when one of the arguments is inout. The iterator-invalidation check is widened in the same shape. - # *T <owned-binding> raw-pointer escape. When a *T cast's source binding sits on any of the auto-drop side-tables (__owned_strings__ / __owned_slices__ / __owned_struct_fields__) OR is a non-parameter heap binding (%Struct / enum / aggregate, mirroring the move-tracker's bck_let_alias heuristic), strict mode flags the cast: the binding's auto-drop at scope exit invalidates the pointer.
compiler/tests/borrow_strict_field_alias.nu
and compiler/tests/borrow_strict_raw_ptr_escape.nu — both compile cleanly under the default checker and error out under --strict-borrowck. compiler/tests/run_tests.sh recognises any borrow_strict_* filename and adds the flag automatically.
diagnostic-only analysis pass.
play.nurl-lang.org no longer runs Python at runtime. nurlapi/ is a 3 000+-LOC NURL HTTP server with a full Model Context Protocol server over /mcp (15 tools, 7 resources, 1 prompt), serving five cross-compile build targets (native ELF, wasm32-wasi, mingw-w64 PE32+, macOS Intel, macOS Apple Silicon + the rest of the /build_target registry). One static NURL binary is PID 1 inside the runtime image.
A nurl_poke / nurl_peek byte-vs-slot index overrun in server_run_pool (and three other call sites) that scribbled 7×N bytes past a worker-handles buffer is fixed. The overrun had manifested as random route / closure corruption under thread load.
Full test corpus + sanitiser corpus (ASan + UBSan + LSan, 281 tests) green. Bootstrap fixed point at 1 620 300 B (stage1 ≡ stage2 byte-identical IR).
nurlapi/)Replaces the Python FastAPI playground end-to-end. One static NURL binary as PID 1; the runtime image is a slim Debian-bookworm stage that only needs clang-16 + the cross-compile toolchains baked in.
Route surface (POST unless noted):
/build → native Linux x86_64 ELF/build_wasm → wasm32-wasi (uses prepare_ir_for_wasi IR shim;
wasm32 ABI rename + libc shims for malloc / puts / write etc.)
/build_windows → mingw-w64 PE32+ via two-stage clang -c then
x86_64-w64-mingw32-gcc link (static libcurl chain when the runtime.win.curl marker is present)
/build_macos → Mach-O via zig cc/build_target → multi-target dispatch (linux-x64-musl,
linux-arm64-musl/-gnu, linux-riscv64-musl, macos-x64, macos-arm64, windows-x64) over a single shared _build_zig_cross helper
GET /examples, GET /examples/*name — bundled example listing +individual source
GET /stdlib, GET /stdlib/*path — recursive .nu listing (84
entries) + per-file {name, source, bytes} JSON
GET /tests, GET /tests/*path — same shape for the compilertest corpus (281 entries)
GET /targets — target registry (the dropdown the UI builds from)GET /readme / /readme.md, /roadmap / /roadmap.md,
/gotchas / /gotchas.md, /grammar.ebnf — doc passthroughs + HTML-rendered alternates (pure-NURL Markdown→HTML renderer: headings, fenced code, bold/em, code spans, links, autolinks, images, ul/ol, blockquote, hr; tables + nested lists deferred)
GET /LICENSE-MIT, /LICENSE-APACHE, /NOTICE, /license,
/license/{mit,apache} — license endpoints (raw + HTML-wrapped)
GET /openapi.json — minimal but valid OpenAPI 3.1 doc enumeratingevery public route
GET /health → toolchain status (60+ stdlib modules + per-toolliveness)
GET /mcp-info → MCP server probe (tools / resources / prompts
catalogue + a client_config_example built from the request's Host header or NURL_PUBLIC_URL)
GET /download/:id/:file → built artifact download
clients that probe .well-known/* succeed: `oauth-protected- resource (+/mcp), oauth-authorization-server, openid- configuration, POST /register, GET /authorize` (instant 302), POST /token
/, /favicon.{ico,svg}, /static/*, /*path
Build response shape mirrors api/ (Python) exactly: combined stdout / stderr, parsed nurlc_errors[] (`{file, line, col, message}), ll_artifact + binary_artifact with {name, bytes, download_url, token}, nurlc_returncode / clang_returncode`, uses_canvas / uses_audio. New shared helpers: parse_nurlc_diagnostics, combine_stderr, make_artifact_json, stamp_build_response, nurlc_failure_response, push_native_runtime_libs.
nurlapi/Dockerfile — two-stage Debian bookworm image. Stage 1 bootstraps nurlc, downloads WASI SDK 24 + Zig 0.13, builds a static libcurl-mingw for the Windows target, pre-builds the six zig-target runtime objects + warms the zig per-target libc cache so the first /build_target per target is a fast cache hit rather than a cold multi-second libc compile, compiles the nurlapi binary itself. Stage 2 is a slim runtime image (clang-16 + cross-compile toolchains only, no Python).
nurlapi.sh — bring-up wrapper. ./nurlapi.sh builds + runs on port 8000; ./nurlapi.sh bind skips the Docker rebuild and bind- mounts the freshly-built local binary + stdlib + examples + tests + root docs into the running container (inner dev loop: edit .nu → ./nurlapi.sh bind → hit endpoint, ~30 s vs ~10 min). Flags: --no-cache / --port=N / --rm / --detach / --build-only / --name=NAME / --help.
nurlapi/e2e_test.{py,sh} — end-to-end test driver covering every endpoint.
/mcp
stdlib/ext/mcp_http.nu + nurlapi/main.nu. Streamable HTTP transport (POST /mcp), FastMCP handshake parity:
initialize (protocolVersion 2025-03-26, FastMCP capabilities
shape, instructions blurb verbatim, serverInfo `nurl-playground 0.9.2`)
ping, tools/list (15 tools), tools/call (dispatch by name)resources/list (7 nurl:// URIs), resources/readprompts/list (1 prompt: nurl_coding_assistant), prompts/getnotifications/* (no reply, 202 Accepted)notifications dropped per spec)
Tools (full Python parity):
nurl_build_native, nurl_build_wasm,
nurl_build_windows, nurl_build_macos, nurl_build_target (enum-typed target id)
nurl_list_examples, nurl_list_stdlib,
nurl_list_tests
nurl_read_example, nurl_read_stdlib,
nurl_read_test, nurl_read_grammar, nurl_read_readme, nurl_read_roadmap, nurl_read_gotchas
Build tools dispatch via loopback HTTP to the server's own /build endpoints (NURL_MCP_LOOPBACK_URL, default http://127.0.0.1:8000): zero duplication of the canonical handler logic.
Reliability fixes vs Python reference:
validated Mcp-Session-Id against a per-process in-memory whitelist — fresh pod = fresh whitelist = previously-issued sids rejected. NURL approach: session ID is opaque to the server. First request (no sid) → server generates 16-hex-char sid, echoed in Mcp-Session-Id response header; subsequent requests with sid → echoed verbatim, no validation. Pod restart → client sid still accepted, no reconnect required.
request handling. NURL piggybacks on server_run_pool's 16-pthread worker pool — every POST /mcp gets its own worker thread, no serialisation. Measured: 50 concurrent tools/list in 65 ms wall on a single host (~770 req/s peak).
Streamable HTTP content-negotiation in stdlib/ext/mcp_http.nu: when the client sends Accept: text/event-stream (every official SDK does), the JSON-RPC reply is wrapped as `event: messagerndata: <json>rnrn with Content-Type: text/event-stream` + Cache-Control: no-cache. Legacy clients without the Accept header still get plain application/json.
http_router HEAD + OPTIONS
router_handle now answers HEAD and OPTIONS requests without each handler having to know about them — same shape FastAPI / Express / most modern HTTP frameworks ship by default.
route matching — falls through to the GET handler, then pins Content-Length to the GET body's would-have-been length and clears the body Vec so the wire-level serialisation emits headers + blank line + zero bytes.
handler involved. Walks every registered route, collects distinct methods whose pattern matches the request path, auto-adds HEAD (when GET is among them) and OPTIONS (always), returns 204 No Content with the assembled Allow: header. Root-level catch-alls (patterns starting with /*) are excluded from OPTIONS enumeration so they don't pollute the advertised method list.
Two new helpers: __strip_body_for_head, __router_options.
Verified live against nurlapi: OPTIONS /health → 204 `GET, HEAD, OPTIONS; OPTIONS /build → 204 POST, OPTIONS; OPTIONS /nonexistent → 404 (catch-all filter working); HEAD /health` → 200 Content-Length: 1570, body 0 bytes.
nurl_poke / nurl_peek byte-vs-slot heap overrun (critical)
server_run_pool in stdlib/ext/http_server.nu allocated an N×8-byte buffer for N worker thread handles and then wrote into it with nurl_poke thandles (* j 8) traw — passing a byte offset where nurl_poke expects a slot index. nurl_poke scales by 8 internally, so each "write at byte offset j*8" actually landed at byte offset j*64 — scribbling 7×N bytes past the buffer end.
The overrun survived for years because it consistently hit the malloc arena's slack-padding zone, which on glibc was unallocated "redzone-shaped" space between the worker-handles block and the next live allocation. The behaviour only collapsed once enough other heap traffic crowded the arena and a real load-bearing allocation landed in the spillover window — at which point one of the routes, or its RouteImpl pointer, or its closure environment, would get clobbered and the next request through that route would crash.
This was previously misdiagnosed as a Vec[Route] multi-field-struct stride hazard under pthread + clang -O2 (the boxed-handle pattern in stdlib/ext/http_router.nu was added as a workaround for the symptom). Route storage was always sound; routes just happened to share the arena page that got smashed. The http_router.nu comment block has been rewritten to credit the real cause.
ASan caught the real root cause when nurlapi/main.nu grew from 14 to 22 routes — the bigger router-build allocation budget shifted what landed in the spillover. Same anti-pattern was present in three other call sites, fixed atomically:
stdlib/ext/http_server.nu server_run_pool (3 sites: 2 poke + 1 peek)stdlib/ext/postgres.nu pg_exec_params (2 sites)compiler/tests/thread_basic.nu (2 sites — masked by malloc slack)
Each: (nurl_poke buf (* j 8) v) → (nurl_poke buf j v).
Regression test compiler/tests/nurl_poke_slot_index.nu allocates an N×8 buffer (N=32, well past any malloc-slack forgiveness zone), writes N distinct markers, reads them back, verifies bit-for-bit. Under ASan in run_san_tests.sh it fails-fast as heap-buffer-overflow on the first overrun if anyone reintroduces the byte-as-slot mistake.
<script type="module"> in api/static/index.html and nurlapi/static/index.html started with a top-level import { … } from "https://esm.sh/@bjorn3/browser_wasi_shim@0.3.0". ES-module top-level imports are awaited before any statement in the module body runs — if esm.sh is slow, blocked, or rejected by the browser (ad-blocker, CSP, offline cache, transient DNS), the module never reached its first statement, so refreshHealth(), loadExamples() and loadTargets() never fired. The health pill stayed "checking…", dropdowns stayed empty, and the page looked like the server's /health was down even though the server was fine.
Fix: replace the static import with a lazy import(WASI_SHIM_URL) inside a loadWasiShim() helper, called only from inside run() — the one place that actually needs the shim. The module body now has zero top-level awaitable imports and initialises synchronously. A CDN failure surfaces in run()'s logLine path and aborts only that one Run-click, not the whole page.
/build_windows two-step compile (clang) + link (mingw-gcc)
Mirrors Python api/'s approach: clang --target=x86_64-w64-mingw32 is great at parsing IR but its mingw linker driver can't resolve mingw-w64's installed support libraries (-lgcc, -lwinpthread, crtbeginS.o, etc.). Single-step `clang ... runtime.win.o -o out.exe therefore fails with /usr/bin/x86_64-w64-mingw32-ld: cannot find -lgcc`. Two steps fix it:
clang --target=x86_64-w64-mingw32 -c file.ll -o file.o — clangcompiles IR to a mingw-flavoured object.
[optional libcurl] -o file.exe` — mingw's own gcc owns its libgcc / libwinpthread / CRT search paths and finishes the link cleanly.
The optional libcurl chain (`-L<curl-mingw>/lib -lcurl -lws2_32 -lcrypt32 -lbcrypt -lncrypt -lsecur32 -ladvapi32`) is added only when the stdlib/runtime.win.curl marker is present — same gate Python uses. Combined stderr now layers all three stages (nurlc → clang -c → mingw-gcc link) so the playground's "BUILD FAILED" panel surfaces whichever stage actually errored; final_rc prefers the compile return code over the link return code (a failing compile produces the more actionable diagnostic).
Five compile targets now produce real binaries end-to-end:
/build → 78 KB Linux ELF/build_wasm → 232 KB wasm32-wasi/build_windows → 1.18 MB PE32+ EXE/build_macos → 19 KB Mach-O (Intel)/build_target macos-arm64 → 52 KB Mach-O (Apple Silicon)stdlib/std/bytes.nu — speed-up to the byte-walk helpers used on
parser hot paths (direct *u pointer reads where the bounds check is already proven by the loop invariant).
stdlib/ext/http_response.nu — minor allocation-count reductionon the response-build path.
bench/http_server.nu, bench/run.sh, bench/run_http.sh —small harness tweaks for the local bench host.
README.md — updated headline benchmark numbers;
nurlapi/README.md added (96-line operator manual for the pure-NURL playground + inner-loop dev workflow).
examples/{enigma,fizzbuzz,msgpack_demo,wordcount}.nu — minorpolish picked up while testing playground rendering.
nurlapi/static/viewer.html — new stdlib / tests source viewer(linked from the playground UI).
cloudflare/Dockerfile, dockerpush.sh, startdev.sh — incidentalupdates so the prod image build & local dev port (8001 vs api/'s 8000) coexist cleanly.
classes are compile errors: use-after-move, alias double-free, closure escape, aliased mutable-borrow at call sites, iterator invalidation. --no-borrowck is the escape hatch.
DNS resolver (getaddrinfo / getnameinfo).
stdlib/ext/json.nu rewritten
around a single-pass scanner; 479 ms → 14 ms on the bench/json_parse micro-benchmark.
bench/ compare NURL with Python / Rust / Nodeon three reproducible micro-benches plus an HTTP-server-vs-Rust- hyper / Node-http sweep.
(ASan + UBSan) jobs.
docs/spec.md (~1 000 lines) covers the semantic side the grammarEBNF does not.
diagnostics.
Bootstrap fixed point: stage1 ≡ stage2 byte-identical IR at 1 620 300 B. Full test corpus + sanitizers green.
Three new public surfaces:
stdlib/std/udp.nu — pure-NURL wrapper over runtime §18b. Dual-
stack IPv4/IPv6 by default (wildcard udp_bind("", 0) creates an AF_INET6 socket with IPV6_V6ONLY=0 so a single fd serves both v4 and v6 peers; literal udp_bind("127.0.0.1", 0) stays IPv4-only on purpose). Sync + fiber-aware async on every send/recv: inside a fiber, udp_recv_from / udp_send_to park on the reactor on EAGAIN; outside any fiber, they fall back to blocking I/O transparently. Exposed surface: - lifecycle: udp_bind, udp_bind_any, udp_connect, udp_close - send/recv: udp_send_to, udp_send_str_to, udp_recv_from, udp_send, udp_recv (last two for connected-mode UDP) - address: udp_peer_addr (borrowed), udp_local_addr (owned String — how the caller discovers the kernel-assigned ephemeral port after udp_bind("", 0)) - options: udp_set_timeout, udp_set_nonblock, udp_set_broadcast - multicast: udp_join_group, udp_leave_group, udp_set_multicast_ttl, udp_set_multicast_loop. iface arg is intentionally minimal (IPv4 IP literal or numeric ifindex; no if_nametoindex so Win32 doesn't need -liphlpapi).
stdlib/std/dns.nu — pure-NURL wrapper over runtime §18c.
System-resolver-based (getaddrinfo / getnameinfo), no c-ares dep. Three entry points: - dns_resolve host → ! ( Vec String ) NetErr of A/AAAA literals in the kernel's preferred order, dedup'd. - dns_resolve_port host port → same, but each entry is formatted as "ip:port" (IPv4) or "[ip]:port" (IPv6, RFC 3986 §3.2.2) ready for direct tcp_connect / udp_connect. - dns_reverse ip → ? String (Some only when there's a real PTR record — NI_NAMEREQD).
stdlib/runtime.c) — implementation:
- NurlUdp { fd, err_kind, family, peer } opaque handle (~16 % the size of NurlTcp — UDP has no TLS state to track). - 22 new nurl_udp_* and 3 new nurl_dns_* exports; WASI stub row at the bottom degrades every call to NetOther / strdup("") so wasm32-wasi builds still link. - Reuses §18's NURL_NET_ERR_* error space + `nurl__net_map_errno / _wsa` mapping helpers; multicast group-family branching uses a new nurl__parse_numeric_addr (AI_NUMERICHOST) so callers don't have to thread family flags through the NURL API. - DNS results come back as newline-separated heap strings; NURL splits and dedupes are deferred to the wrapper (__dns_split_lines).
Acceptance:
compiler/tests/udp_basic.nu — always-on (loopback only, no live
network). Covers send_to/recv_from roundtrip, peer-addr capture, connected-mode send/recv on a separate pair, zero-length datagram, wildcard dual-stack bind, broadcast + multicast TTL / loop setsockopt smoke. Exit 0, output matches correct.txt baseline.
compiler/tests/dns_basic.nu — always-on (uses literals + the
/etc/hosts localhost mapping that every Linux/macOS/Win box has, no external DNS). Covers resolve + resolve_port for both IPv4 and IPv6, IPv6 bracketed-port formatting, reverse lookup, empty- input → Err NetOther.
Runtime LOC delta: stdlib/runtime.c 4 790 → 5 606 (+816, +17 %) including the WASI stub row. Bootstrap fixed point unchanged at 1 620 300 B (stage1 ≡ stage2 byte-identical IR) — the runtime extension is pure stdlib, no compiler IR perturbation.
bench/run_http.sh + bench/http_server.{nu,js} + bench/rust_http_server/ (Cargo + hyper 1.9 + tokio multi-thread) close the long-standing "no Rust hyper / Node http peer comparison" gap that critic v0.9.0 §10 flagged. Drives oha 1.8.0 against three hello-world servers at four concurrency levels (1, 10, 50, 200), median of 3 × 10 s per cell. Captured numbers + commentary in bench/HTTP_RESULTS.md:
| Server | C = 1 | C = 10 | C = 50 | C = 200 |
|---|---|---|---|---|
| NURL | 14 451 | 68 960 | 60 897 | 59 044 |
| Rust | 14 507 | 47 703 | 86 699 | 114 694 |
| Node | 8 708 | 16 726 | 17 108 | 15 555 |
(req/s, higher is better, best in bold per column.)
Highlights:
of hyper at C=10 — NURL's 8-worker pool fits the workload while tokio's 12-worker default is over-provisioned at that concurrency.
(1.94×) at C=200.
at C=200 vs Rust's 6.19 ms and Node's 20.95 ms.
The Go net/http half of the originally-asked-for comparison is deferred: Go was not installed on the bench host at capture time. bench/run_http.sh has the lane reserved and bench/README.md documents the gap — a PR adding bench/http_server.go would re-publish a four-column table.
Two-part deliverable closing ROADMAP §6 "More Examples":
examples/find_clone.nu — grep-style recursive search overfiles / directories with three modes:
` find_clone PATTERN [PATH ...] # literal substring find_clone --list PAT[,PAT...] [PATH ...] # comma-separated alternatives find_clone --regex PAT [PATH ...] # POSIX-extended regex `
PATH is one or more files or directories — directories recurse, dotfiles are skipped, and with no PATH the tool reads stdin. Output is path:line:contents per match; exit 0 on any match, 1 on no match, 2 on usage / I/O error. Closure-shaped matchers (make_literal_test, make_list_test, make_regex_test) so scan_lines is mode-agnostic; walk_dir returns -1 on not-a-directory so the dispatcher falls back to the file scanner cleanly. Built on top of stdlib/std/fs.nu (read_file, dir_list), stdlib/ext/regex.nu (regex_compile / _test), and the existing nurl_str_* helpers. Pure CLI I/O — runs on the public playground.
examples/README.md refresh — from a 3-of-36 catalogue to all
36 rows, organised by category (CLI tools / Algorithms / Data formats / Language showcase / HTTP & RPC / LLM API / SDL canvas). Each row carries a one-line description plus a playground or local tag describing where it can run. playground = pure compute + stdin / argv / file I/O (runs on play.nurl-lang.org as-is); local = needs network, a server listening port, an ANTHROPIC_API_KEY, SDL2, or microphone access.
The critic-suggested agent-loop variants and MCP-client demo were deliberately omitted: examples/claude_agent.nu already covers the agent shape, and an MCP-client-from-the-public-playground would need either secret injection (API keys) or WASM outbound sockets, neither of which the playground exposes today.
.github/workflows/ci.yml lifts the previously-local build + test + sanitiser gate to PR-level. Two parallel jobs on ubuntu-latest:
build-test — installs clang + optional FFI dev libs
(libcurl4-openssl-dev, libssl-dev, libsqlite3-dev, libpq-dev, zlib1g-dev, libzstd-dev) so every stdlib/runtime.<lib> sentinel lights up, then runs ./build.sh (bootstrap stage1 ≡ stage2 fixed point + the full run_tests.sh corpus). 15-min timeout.
sanitizers — same setup, runs ./build.sh --san --no-tests
to build an ASan + UBSan-instrumented stack, then compiler/tests/run_san_tests.sh over the corpus. 25-min timeout.
Triggers on push to main / Improvements, PR-to-main, and workflow_dispatch (manual rerun). concurrency.cancel-in-progress cancels older runs when a new commit lands on the same ref so the queue can't fill up on a fast-typing day.
nurlfmt --check is deliberately NOT yet wired up — ~100 .nu files in the current stdlib / tests / examples corpus (and compiler/nurlc.nu itself) are not in canonical form, so adding the check today would fail every PR with an unrelated 100-line diff. The follow-up path is documented inline in the workflow comments: either a single repo-wide nurlfmt --write pass first, or grow the check scope file-by-file as canonicalisation lands.
stdlib/std/log.nu gains two structured-logging features that the critic flagged as missing-for-v1.0 (ROADMAP §2):
log_debug_kv1 / _kv2 / _kv3,
log_info_kv1..3, log_warn_kv1..3, log_error_kv1..3. Twelve fixed-arity helpers that accept 1..3 s key / s value pairs alongside a message. Same below-threshold suppression as the raw and fN variants.
log_set_json T / log_set_json F,
log_get_json. When JSON is on, every log_* call emits a single {"level":"info","msg":"…","key":"value",…} line instead of the [INFO] msg key=value text form. Compatible with jq, Logstash, Loki, CloudWatch, etc. — values are RFC 8259-compliant (named escapes for " \ \n \r \t; remaining control bytes 0x00..0x1F emit \u00XX).
The existing raw log_<level> and log_<level>fN calls route through the new shared __log_dispatch so JSON mode applies uniformly to every call site. The per-byte JSON-escape walker uses a *u pointer instead of nurl_str_get to avoid the O(strlen) per-character cost. Compiler / bootstrap untouched; regression compiler/tests/log_structured.nu exercises text mode, JSON mode, escape coverage and below-threshold suppression. jq -c . round-trips every JSON line emitted by the test.
Closes the four "grammar-legal but semantically dead" cases the external review flagged as silent compiles. Each is a small, local compiler change; bootstrap fixed point holds (stage1 ≡ stage2 byte-identical IR at 1 620 300 B); full test corpus green.
^ vs ^^ XOR confusion warning: — gen_ret peeks the
token after the returned expression. If it is on the same source line as the ^ AND is value-producing (not : / = / ; / } / ) / ] / { / EOF), the user almost certainly wrote ^ X Y intending XOR. Emits a soft warning: naming ^^ (two adjacent carets, no space) as the cure. Test: should_warn_caret_xor.nu.
error: — gen_stmt checks for
name args (no parens) at statement position. If name is a known callable (registered in syms with no __ptr / __global / __param), dies with ( name args )-cure pointer. The companion gen_ffi_decl now stamps <name>__ffi = 1 so FFI builtins like nurl_print are detected alongside @-fns. Test: should_fail_bare_ident_stmt.nu (PoC: nurl_print \oops``).
_free via wrapper error: — auto-infer sink
convention on parameters that a function passes to a destructor (*_free) or to another fn's existing sink slot. New helper bck_record_inferred_sink accumulates into __fn_inferred_sink__ per fn body; gen_fn_decl_concrete merges into g_fn_sink[fname] after body parses, deduping against the explicit sink marker. Closes the indirect ( take s ) ( read s ) use-after-free the critic exhibited. Test: should_fail_uaf_indirect.nu. Also added str_word_index helper next to str_contains_word.
opaque <generic>:1:21: synthetic filename with <generic vec_as_slice__i64 from user.nu:42>:1:21: so a parse error during the substituted-body re-parse names the call site in the user's own code. defer_instantiation now captures the call-site file + line; flush_deferred_instantiations passes them to emit_one_instantiation, which builds the synthetic filename. (Diagnostic-only — IR unchanged.)
stdlib/ext/json.nu parser ~34× faster (2026-05-25)
json_parse of the bench/json_parse payload (5 × 64 KB) dropped from 479 ms to 14 ms — now faster than Python's C-extension json (~34 ms) and within ~3× of a hand-written zero-copy Rust parser (~5 ms). Two landed changes:
*u pointer reads instead of nurl_str_get. Every
__jp_peek was paying a full strlen of the whole input (the core/string.nu helper does an strlen for the bounds check) — a 64 KB parse spent gigabytes of memory bandwidth in strlen alone, classic O(n²). Replaced with a cached *u-based byte read against . p src, plus a memchr-driven fast path in __jp_parse_string that slices the literal byte range when the string contains no \ escape (the common case).
String constructor. New
core/string.nu::string_from_bytes_packed allocates the 24-byte Vec control block and the data buffer in a single nurl_alloc(24 + n + 1). vec_free / vec_free_with / __vec_grow in core/vec.nu detect the layout by `data == ctl + 24`; the lifecycle is byte-identical to a normal String (it can still grow — the first growth pays for an unpacking copy out into a separate buffer). For JSON parsing every JNum / JStr is read-only after construction, so this exact case halves the per-string allocation count.
Bench runner (bench/run.sh) also stopped forking date twice per measurement by switching to $EPOCHREALTIME arithmetic (bash 5+), shaving ~3 ms of measurement overhead per cell.
bench/RESULTS.md and README.md updated with the new headline numbers. Bootstrap fixed point holds; full test corpus green; no API or grammar changes.
bench/ peer-comparison benchmark suite (2026-05-25)Three reproducible micro-benchmarks with one source file per language (NURL, Python 3, Rust, Node.js):
bench/lcg.{nu,py,rs,js} — 100M-step MMIX linear congruentialgenerator. Tight i64 multiply + add with a single-stream data dependency that defeats LLVM's closed-form folding.
bench/sieve.{nu,py,rs,js} — Sieve of Eratosthenes computingπ(10 000 000) = 664 579. Memory bandwidth + branch prediction.
bench/json_parse.{nu,py,rs,js} — 5 parses of a deterministic
~64 KB JSON file. Each language uses **what ships in its standard distribution** (Python json, Node JSON.parse, NURL stdlib/ext/json.nu; Rust links a small hand-written recursive-descent parser since it has no JSON in stdlib).
bench/run.sh compiles each NURL + Rust target, runs every present language N times (default 5) with a per-run timeout, and prints a median-wall-clock-ms table. Missing tools render as n/a; a cell that hits the timeout renders as >30s instead of hanging the suite.
bench/RESULTS.md captures the numbers from one specific machine:
lcg and sieve NURL lands within measurement noise of Rust —
same LLVM -O2 -flto codegen on both sides.
json_parse NURL's pure-NURL parser is ~12× slower than Python's
C json and ~50× slower than a hand-written Rust parser. The module's allocator-and-Vec-growth path through recursive descent is the explanation, and a zero-copy slice-based rewrite would close most of the gap — tracked as a follow-up.
An HTTP-server-vs-net/http peer benchmark is not included; that would need a Go install and a wrk-shaped harness.
bck_diag (use-after-move) and bck_esc_warn (escape analysis +
aliased-mut + iterator invalidation) emit : error: instead of : warning: and bump a new g_bck_errors counter. main() exits non-zero after parse_program if any violation was recorded — every error surfaces in one run (same shape as a C compiler).
borrow_* tests as expected compile
failures with an ERRORS baseline blob rather than "compile OK + WARNINGS". The exact error text remains regression-protected.
--no-borrowck remains the escape hatch; the abort message pointsat it so a user hitting a false positive is never wedged.
byte-identical with or without --no-borrowck): stage1 ≡ stage2 at 1 602 394 B.
Five bug classes are now compile errors by default: use-after-move, alias double-free, closure escape, call-site aliased mutation, and iterator invalidation. *T raw pointers and aliased mutation through nested-argument reads remain unchecked; see docs/MEMORY.md.
refactor/nurlify branch
Picks up where refactor/pure-nurl left off and drives stdlib/runtime.c the rest of the way down:
stdlib/runtime.c: 6 265 → 4 540 LOC (−1 725, −27.5 %). Combinedwith the prior branch the total reduction since v0.8.0 is **8 879 → 4 540 LOC (−4 339, −48.9 %)** — over half of the C runtime is gone. Bootstrap fixed point held on every shipped phase; full test corpus green.
rand_u64 / rand_hex_str ported to pure
NURL in stdlib/std/random.nu. Only nurl_rand_fill stays C — the getrandom / arc4random_buf / BCryptGenRandom platform branching is genuinely syscall-shaped FFI.
nurl_read_file_bytes /
_write_file_bytes / _file_read_chunk / _read_n_bytes / _errno_kind moved to pure NURL. The g_last_bytes_len sideband is gone — fread / fwrite write directly into the Vec[u] data buffer and vec_set_len records the count. EACCES / EPERM / EEXIST added to nurl_native_constant; errno_kind now lives in stdlib/core/posix.nu.
nurl_gzip_compress / _decompress moved to
pure-NURL FFI in stdlib/ext/compress.nu over deflateInit2_ / deflate / deflateEnd + inflateInit2_ / inflate / inflateEnd. Two tiny C accessors (nurl_z_setup / nurl_z_total_out) bridge the platform-varying z_stream field layout (LP64 vs LLP64 uLong width).
(status / err_kind / body / body_len / header_count / header_name / header_value) deleted from runtime.c. Pure-NURL equivalents in stdlib/ext/http.nu read the NurlHttpResponse heap struct via nurl_peek(p, slot) over its 6-i64 slot layout. Static asserts in runtime.c pin the layout at compile time so a future field reorder breaks the native build instead of silently miscompiling NURL reads. nurl_http_response_free stays C because it walks headers[] deallocating each name / value pair plus the body.
driven from pure NURL. Sync nurl_http_perform_full_to and multi-stream _open_to / _next / _pump_headers plus the 5 stream accessors live in stdlib/ext/http.nu; 22 monomorphic trampolines stay C (nurl_curl_* setopt / multi / stream-state) because libcurl's variadic curl_easy_setopt and the raw-fn-pointer callbacks (nurl__http_write_body / _write_header) can't cross the FFI directly. NurlHttpStream's three historical int fields widened to long long for a clean 14×i64 slot layout; static_assert pins it. Live verified against httpbin.
(stdlib/ext/csv.nu, −508 C). The vectorised newline / delimiter scanner is now NURL @-fns over nurl_peek of a heap-side byte window.
runtime.c prose cleanup (commits d558844, f88d7bb):trimmed verbose multi-paragraph explanations, phase-by-phase migration history and prose that just restated what the code does — kept one-line function-purpose intros and the non-obvious "why" notes (TLS / SNI race discipline, fiber park-unlock ordering, wasm32 layout caveats, libz LP64 / LLP64 differences). Net −913 comment-only LOC.
JSON-to-production branchjson ext goes production-ready. stdlib/ext/json.nu:
- Typed JsonError replaces the bare ParseErr — carries kind (BadFormat / Empty / TrailingGarbage / Overflow), pos (0-based byte offset), line (1-based) and col (1-based). Location is computed once per failure and travels with the error value — no global state, so nested json_parse calls and multi-threaded use are both safe. json_format_error renders the standard message; build your own from the fields if you need a custom shape. - RFC 8259 strict mode. Non-conforming numbers (leading zeros like 01, +5, lone .5, 1.) are now BadFormat instead of parsing to the prefix — json_stringify ∘ json_parse is guaranteed-valid JSON. - New constructors — json_int n / json_float x from primitives (no i8* roundtrip), json_arr_new / json_obj_new for empty containers. - Duplicate-key behavior documented. Parser preserves duplicate keys as-is; json_obj_get returns the first match in source order; json_obj_set replaces the first match in source order. - Call sites updated across stdlib/ext/anthropic.nu, stdlib/ext/mcp{,_client,_http,_stdio}.nu, nurlapi/main.nu, examples/serde_demo.nu, tools/nurl-lsp/jsonrpc.nu.
refactor/pure-nurl branch
The refactor/pure-nurl branch took the bulk of stdlib/runtime.c out of C and into pure NURL — either as pure-NURL @-fns or as direct & \c` / & `pthread` / & `sqlite3`` FFI declarations.
stdlib/runtime.c: 8 879 → 6 265 LOC (−2 614, −29.4 %). Thebootstrap fixed point held on every shipped phase and the full test corpus stayed green.
compiler/nurlc.py and
compiler/src/*.py are gone. Stage 0 now links the committed compiler/nurlc_lastgood.ll snapshot directly via clang. The only build-time dependency is clang/LLVM 14+. Refresh the snapshot with ./build.sh --refresh-bootstrap when a grammar/runtime-ABI change leaves the current snapshot unable to compile current nurlc.nu.
Box[T] / Cell[T] / Rc[T] / Arc[T] heap-stable
allocator surface — stdlib/core/box.nu, stdlib/core/cell.nu, stdlib/std/rc.nu, stdlib/std/arc.nu. % Drop auto-fires; nurl_native_sizeof + nurl_atomic_i64_* runtime primitives added. This unblocked Phases 6 / 8 / 11 / 12 of the purification.
- Phase 1 §3 char classification (stdlib/core/char.nu, −11 C) - Phase 2 §15 logging level (stdlib/std/log.nu, −7 C) - Phase 3 §11 libm + integer helpers (& \m` / & `c`` FFI, −17 C) - Phase 4 §17 crypto MD5/SHA-1/256/512 + HMAC (stdlib/std/hash_*.nu, −541 C) - Phase 5 §2 string ops over libc (strlen/strcmp/strncmp/strstr/ memcmp/memmem/atoll/atof/memcpy/strdup via preamble, −682 C) - Phase 6 §19 threads / mutex / cond (pthread & \pthread`` FFI in stdlib/std/thread.nu, −162 C; mingw-w64 winpthreads linked via -lpthread) - Phase 7 §4 + §13 file & dir syscalls — incremental over many batches (realpath / write_file_safe / file_size / mmap / fread fallback / dir_list POSIX, −158 C combined) - Phase 8 §16 + §16b process spawn (fork/exec/poll, || and && added as language tokens for the spawn-error sideband, −245 C) - Phase 9a §7 + §8 codegen counters + last-type sideband (pure-NURL @-fns in nurlc.nu, −71 C) - Phase 9b §6b symbol table (3 parallel grow-by-2× arrays, inner loops via direct *s / *i pointer arithmetic, −72 C; ~0.95× of C runtime — LTO inlines everything and the parallel layout is cache-friendlier than the C interleaved struct) - Phase 9c §5 HashMap deleted entirely (the canonical stdlib/std/hashmap.nu HashMap[s i] is the one-true map for every consumer; the migration also fixed hash_string from O(n²) → O(n) by switching from per-byte nurl_str_get to a direct *u byte walk, −101 C) - Phase 10 §6a Lexer (the big one, −592 C). Full state machine + 4-deep lookahead ported to pure-NURL @-fns over a 280-byte heap handle. Uncovered + fixed a subtle escape-handling bug: only \n \t \r \\ are real escapes; any other \X (including ` ) writes the lone \ and advances one byte. - Phase 11 §23 DoS protection (stdlib/std/dos.nu, −180 C) - Phase 12 §21 SQLite bridge — pure-NURL FFI over 18 libsqlite3 symbols (stdlib/ext/sqlite.nu, −330 C) - §12 Time — clock_gettime + nanosleep FFI (stdlib/std/time.nu, −38 C; macOS uses CLOCK_MONOTONIC = 6 vs 1 elsewhere, read at runtime via nurl_native_constant) - §13 batch 2/3 — stdin + dir_list POSIX FFI (−80 C) - §11 strtod sideband eliminated with an endptr buffer (−20 C)
|| and && operators added as language tokens — strict
binary, bool-only short-circuit. Alternative to the chainable | / & for cases that are more readable as a || / && chain. Grammar v2.0 documents them. Same LLVM IR as | / & on i1 left operands.
./check.sh <file.nu> — per-file syntax/type check tool;
runs nurlc against a single source file in ~0.2 s vs build.sh ~60 s. Use in iterate-fix loops before kicking the full build.
success.txt + failures.txtso a failed test is greppable without scrolling through the green output.
( begins a call, so
( . obj field ) / ( | a b ) / ( + x y ) etc. now produce a precise call-site error: instead of a far-away LLVM-verifier complaint. (Listed earlier in this section under the original feature work; reiterated here as it landed in this branch.)
checked against the callee's declared parameter count; a mismatch points at the call site (same listing remark).
operator over-reads now name the offending token and point back at the line where the cascade started.
mcp_response_get_result — mcp_client's 1-arg resultextractor renamed for consistency with the rest of the surface.
refactor/pure-nurl branch
(2026-05-24). nurl_errno_get / nurl_errno_set / nurl_wait_is_exited / _exit_status / _is_signaled / _term_sig paluut + parametrit widened int → long long in stdlib/runtime.c. On x86_64 SysV the int return's upper 32 bits were undefined and accidentally zero; wasm-ld validates signatures strictly and refused to link until the C side agreed with the NURL FFI's → i (i64) declaration. memmem added to api/app/main.py:LIBC_WASM32_ABI (the playground's wasm-build IR rewriter), since wasm32 size_t is i32 but nurlc.nu's preamble emits memmem(i8*, i64, i8*, i64).
WIFEXITED lvalue requirement. The widened
nurl_wait_* functions originally passed (int)status as an rvalue to the W*-macros; macOS's <sys/wait.h> expands them to *(int*)&(x) which needs an lvalue. Fixed by binding int s = (int)status; first inside each wrapper. Restores the zig macOS-arm64 / macOS-x64 cross-build.
stdlib/ext/serde.nu gained from_msgpack_i /
from_msgpack_f / from_msgpack_b / from_msgpack_string — decoding MessagePack bytes straight to a built-in value. There is no % MsgpackSerialize trait: MessagePack and JSON share a data model, so a value is encoded by composing the existing to_json with msgpack_encode. The decoders return !T MsgpackErr (not ParseErr — MsgpackErr is the richer error type and represents every failure losslessly); MsgpackErr gained a MsgpackTypeMismatch variant for a value that decoded cleanly but is the wrong shape. Demo examples/msgpack_demo.nu; regression compiler/tests/msgpack_serde.nu. With this the serde story covers JSON, TOML and MessagePack — all reusing one JsonSerialize impl per type.
stdlib/ext/serde.nu gained its TOML side: a
% TomlSerialize [T] { @ to_toml T x → TomlValue } trait with impls for i / b / s / String, and from_toml_i / from_toml_b / from_toml_string decoders returning !T ParseErr — the same shape and error type as the JSON helpers. There is no f impl: the TomlValue AST has no float variant. stdlib/ext/toml.nu gained toml_stringify, the inverse of toml_parse: a TomlValue is rendered as TOML text — top-level key = value lines, nested tables and arrays inline, strings escaped with the \\ \" \n \r \t set the parser accepts, so toml_parse ∘ toml_stringify round-trips. Regression compiler/tests/toml_serde.nu; verified leak-free under ASan/UBSan/LSan.
stdlib/ext/msgpack.nu is a faithful binary
codec between the Json value and the MessagePack wire format: msgpack_encode Json → ! ( Vec u ) MsgpackErr and `msgpack_decode ( Vec u ) → ! Json MsgpackErr`. The encoder emits the smallest signed integer format, float64 for reals, and length-appropriate str / array / map headers; the decoder accepts every integer and float format plus all str / array / map sizes. bin / ext and non-string map keys are reported as MsgpackUnsupported; truncation and malformed input have their own MsgpackErr variants; a recursion cap guards both directions. Three runtime helpers — nurl_f64_bits, nurl_f64_from_bits, nurl_f32_from_bits — provide the IEEE-754 bit access needed for the float wire format. Regression compiler/tests/msgpack_basic.nu (37 assertions: round-trips, msgpack.org encode vectors, non-canonical-format decoding, malformed inputs); verified leak-free under ASan/UBSan/LSan. First of the three Serde-completion ships (codec, then TOML serde, then MsgPack serde).
nurl_f64_bits,
nurl_f64_from_bits, nurl_f32_from_bits in stdlib/runtime.c.
( begins a function call,
so the token after it must be a function name. An operator token there — ( . obj field ), ( | a b ), ( + x y ) — meant an operator expression was wrongly wrapped in parentheses. nurlc used to take the operator's lexeme as the callee, emit a call to a function literally named . / | / +, and let the build fail far from the source at link time with use of undefined value. gen_call now rejects a binary operator, member access ., the cast # or the caret ^ immediately after ( with a precise error: at the call site — `operator '.' cannot be a call target: '(' begins a function call, but operator expressions are written without parentheses` — and a caret on the operator. Regression compiler/tests/should_fail_paren_operator.nu.
stdlib/std/path.nu gained a Path { String inner }
typed, owning wrapper over a path string, with a concise Rust-PathBuf-style verb API — path_new, path_str (borrow the inner buffer), path_len, path_is_empty, path_clone, path_free, path_eq, path_push (join one component), path_parent, path_name, path_is_abs — and the two operations the existing string-level layer lacked: path_canonical (realpath: absolute, symbolic links resolved) and path_relative_to (a purely lexical relative path between two paths). Both return ? Path — None for a missing / inaccessible path or a not-comparable pair. The string-s-based path_* functions stay the raw layer underneath; the typed functions never consume their arguments. One runtime helper, nurl_realpath, is reached through the pure-NURL & \c`` FFI model, so the compiler is unchanged and the bootstrap fixed point is byte-identical. Regression compiler/tests/path_typed.nu; verified leak-free under ASan/UBSan/LSan.
nurl_realpath (realpath on POSIX, _fullpath
on Windows) in stdlib/runtime.c.
stdlib/std/hash.nu gained sha512_bytes / sha512_hex (FIPS 180-4 SHA-512, 64-byte digest), md5_bytes / md5_hex (RFC 1321 MD5, 16-byte digest) and hmac_sha512_bytes / hmac_sha512_hex (RFC 2104 HMAC over SHA-512). All are binary-clean — they take ( Vec u ) and are length-aware, so NUL bytes are preserved — mirroring the existing sha1_bytes. The three algorithms are self-contained in runtime.c §17 (no libsodium / OpenSSL dependency) and are reached through the pure-NURL & \c`` FFI model, so the compiler is unchanged and the bootstrap fixed point is byte-identical. MD5 and SHA-1 are documented as compatibility-only — both are collision-broken and must not authenticate data or hash secrets. Regression compiler/tests/hash_extended.nu checks every algorithm against published vectors (RFC 1321 §A.5, FIPS 180-4, RFC 4231 HMAC cases 1/2/6); verified leak-free under ASan/UBSan/LSan.
nurl_md5_bytes, nurl_sha512_bytes,
nurl_hmac_sha512_bytes in stdlib/runtime.c.
stdlib/std/fs.nu gained
recursive directory operations and a streaming file reader: dir_create_all is mkdir -p — it creates every missing parent directory and treats an already-existing directory as success; dir_remove_all is recursive rm -rf — it walks the tree removing every entry before removing the directory itself, and unlinks a symlink rather than descending through it. file_open / file_read_chunk / file_eof / file_close read a file in fixed-size byte chunks over a new File handle, so a binary input far larger than RAM can be processed without ever being fully resident (line-oriented streaming stays with stdlib/std/bufio.nu). Three new runtime.c filesystem helpers back this — nurl_path_type (an lstat-based entry classifier), nurl_file_read_chunk and nurl_file_eof — all reached through the pure-NURL & \c`` FFI model, so the compiler is unchanged and the bootstrap fixed point is byte-identical. Regression compiler/tests/fs_advanced.nu; the new paths are verified leak-free under ASan/UBSan/LSan.
nurl_path_type,
nurl_file_read_chunk, nurl_file_eof in stdlib/runtime.c.
gen_call now checks every call against
the callee's declared parameter count and rejects a mismatch with a precise error: at the call site — e.g. `call to 'add' has the wrong number of arguments: expected 2, got 1`. Previously a wrong-arity call to a known function either miscompiled silently (too few arguments) or emitted a malformed call the LLVM verifier complained about far from the source (too many). scan_fn_sigs records each non-generic @-function's parameter count through a new pure-lexical type skipper (scan_skip_type — no parse_type call, which would desync the scan); a name carrying two definitions of differing arity is marked ambiguous and skipped rather than mis-blamed. Generic and variadic-FFI callees are out of scope for v1.
of operands and over-reads into the following statement — the classic NURL cascade, since operators have fixed arity and no closing bracket — the resulting "unexpected token" error now names the offending token and points back at the line where the short-an-argument statement began, instead of blaming the innocent next line.
compiler/tests/{call_arity_ok,
should_fail_call_arity_few,should_fail_call_arity_many, should_fail_prefix_cascade}.nu.
mqtt_topic_matches
in stdlib/ext/mqtt.nu implements the MQTT 5.0 §4.7 + / # wildcard rules — + matches one topic level, # matches the remainder (zero levels included, so sport/# also matches the parent sport) — including the §4.7.2 guard that a filter beginning with a wildcard never matches a $SYS/... topic. The intended use is client-side dispatch when one connection carries several subscriptions.
compiler/tests/mqtt_codec.nu
exercises the Variable Byte Integer round-trip, the unsigned byte reader, MQTT UTF-8 string framing, the CONNECT byte layout, CONNACK reason extraction, MQTT 5 user-property parsing, the typed MqttErr names, and topic matching — no network, CI-safe.
api/ browser
playground replaces its four fixed build buttons (WASM / native / Windows / macOS) with a grouped Target dropdown + one Build button. New compile targets, all driven by the zig cc pipeline already shipped for macOS:
* linux-x64-musl, linux-arm64-musl, linux-riscv64-musl — fully-static ELF (runs on any Linux of that arch, no libc pin). * linux-arm64-gnu — dynamic glibc 2.31+ ELF. * macos-x64, macos-arm64 — Mach-O; Apple Silicon was previously unreachable (the only macOS target was Intel).
Backed by POST /build_target (target id in the body) and GET /targets (the registry the UI builds its dropdown from); the three near-duplicate build endpoints now share one _build_zig_cross helper. POST /build_macos is kept as a thin wrapper for MCP / older clients. The MCP server (both /mcp and the REST companion) gains a nurl_build_target tool — valid target ids are inlined as a schema enum so clients need no separate lookup; the existing per-OS build tools are unchanged. canvas/audio FFI is rejected on the cross targets and HTTP falls back to the runtime's no-op stubs — same contract macOS had.
Image cost is ~negligible: zig already bundles musl / glibc / libSystem for every arch, so each target adds only one ≈125 KB runtime.<id>.o. The Dockerfile cross-compiles those at build time and pre-warms each target's libc/compiler-rt into a baked ZIG_GLOBAL_CACHE_DIR, so the first build per target is a fast cache hit rather than a cold multi-second libc compile; the warm link doubles as a build-time smoke test. No riscv64 glibc target — zig 0.13's bundled glibc for RISC-V is incomplete; riscv64-musl covers RISC-V until the image moves to zig ≥ 0.14.
stdlib/std/time.nu calendar completion. On top of the existing
Time struct and Hinnant civil_from_days conversions:
* is_leap_year, days_in_month (leap-aware), time_yday (1..366). * time_make Y Mo D H Mi S — range-checked civil-time constructor (rejects e.g. 2023-02-29 and month 13). * time_cmp / time_eq / time_before / time_after — order timestamps (by Unix-second value). * time_add_seconds / time_add_days (negative subtracts; rolls month/year/leap-day over) / time_diff_seconds. * time_format t fmt — strftime subset (`%Y %y %m %d %H %M %S %j %a %A %b %B %%), alongside the fixed time_format_iso` / time_format_http.
Regression compiler/tests/time_calendar.nu; calendar_time.nu updated. All pure NURL arithmetic, ASan/UBSan/leak-clean.
stdlib/std/bufio.nu. BufReader pulls
a file (or stdin) through a 64 KiB buffer one fread at a time, so an input far larger than RAM is processed without ever being fully resident — the streaming counterpart to read_file / csv_reader_new, which load the whole file first. Built for fast ETL over logs / CSV / JSONL:
* bufreader_open path → ! BufReader IoErr, bufreader_stdin → BufReader. * bufreader_read_line br → ? String — a fresh owned line each call, \n / \r\n stripped; None at EOF. `bufreader_read_line_into br dst → b` — refills the caller's* String with the next line, so a steady-state read loop allocates nothing after warm-up (one memcpy per line, no malloc). The recommended ETL hot path. * bufreader_eof, bufreader_close.
Line terminators are found with memchr (glibc SIMD), not a byte loop. Lines longer than the buffer grow it; lines straddling a refill boundary are compacted with memmove. Neither read entry point hands back a pointer into the internal buffer, so there is no invalidated-view foot-gun. Implemented as pure-NURL & \c`` FFI (fread / memchr / memmove / fdopen) — no runtime.c change. New supporting primitive string_push_bytes (stdlib/core/string.nu) appends a known-length raw byte range to a String. Regressions compiler/tests/bufio_basic.nu (mixed terminators, empty + trailing unterminated line, missing-file error) and bufio_stream.nu (50 001 lines across ~17 refills + a 200 000-byte line forcing buffer growth); both ASan + UBSan + leak-detection clean.
without hand-rolling a per-type clone. NURL has no type-class dispatch, so — exactly like the existing vec_free / vec_free_with split — the clone is delivered as a per-call closure rather than a language-level Clone trait:
* string_clone String → String (stdlib/core/string.nu) — independent deep copy of an owned String; embedded NUL bytes preserved verbatim. The stock element-clone for owned-String containers. * vec_clone [A] v → ( Vec A ) — bitwise shallow copy, trivial element types only. vec_clone_with [A] v ( @ A A ) clone → ( Vec A ) — deep copy that runs clone per element; use for Vec[String], nested Vec, Vec[HashMap]. * vec_filter_with [A] v ( @ b A ) pred ( @ A A ) clone → ( Vec A ) — owned-safe filter: kept elements are cloned into the result instead of bitwise-aliased, so source and result can both be freed with vec_free_with without a double-free. Plain vec_filter stays the fast path for trivial elements. * map_clone [K V] m → ( HashMap K V ) — bitwise, trivial K/V. map_clone_with [K V] m ( @ K K ) clone_k ( @ V V ) clone_v — deep copy. Both preserve the source slot layout verbatim (same cap, probe positions, tombstones), so the clone needs no rehash.
Before this, vec_filter / vec_extend / map_keys / map_values bitwise-copied elements — silent UB / double-free for any owned element type (Vec[String], HashMap[String _], nested containers), an unsafe copy the borrow checker cannot see because it happens inside a monomorphised generic. The _with variants close that hole. Regression compiler/tests/clone_basic.nu — verified independence (mutate source → clone unaffected) and ASan + UBSan + leak-detection clean.
inout field targets. An argument of the form . obj field at
an inout parameter now passes the address of that struct field, so the callee mutates exactly that field of the caller's struct in place — finer-grained than passing the whole struct inout:
` @ add100 inout i x → v { = x + x 100 } : ~ Game g @ Game { @ Counter { 1 99 } 0 } ( add100 . g turns ) // g.turns is mutated in place `
obj must be a mutable (: ~) struct binding — or an inout struct parameter, which carries the same backing-pointer shape. The field's address is a getelementptr resolved through the <sname>__<field>__idx roster, so plain and generic structs both work; the field may itself be a struct (( bump . g score )). Single-level access (. obj field) only. Regression test compiler/tests/inout_field.nu (+ should_fail_inout_field_immut.nu).
inout / sink parameter conventions on generic functions.
A generic function may now mark a parameter inout (exclusive mutable borrow) or sink (the callee consumes it), exactly like an ordinary function:
` @ store_g [A] inout i slot A item → v { = slot + slot 1 } ( store_g [i] n 7 ) // n is mutated in place `
A parameter convention is a property of parameter position, not of the type arguments, so the inout / sink index sets are computed once from the generic template (the new compute_generic_inout_sink, keyed by the generic name) and a call site resolves them by that name — the mangled-instantiation entry only appears once the deferred monomorphisation is compiled, which is too late for the call that triggered it. Previously a generic inout argument was passed by value into a <T>* parameter (an LLVM type mismatch). A forward call to a generic inout function is rejected with the same define-before-call diagnostic as the non-generic case. Regression tests compiler/tests/inout_generic.nu and should_fail_inout_generic_forward.nu.
variant whose payload is a struct or enum declared later in the same file now parses correctly:
` : | Shape { Dot Box Geom } // Geom used before it is declared : Geom { i w i h } `
Previously the compiler only recognised a payload type already registered in the symbol table, so a forward-referenced payload was misread as a phantom extra variant — and any ?? match over the enum then failed with a bogus non-exhaustive-match error. A new linear pre-pass (scan_type_names) registers every top-level struct / enum / generic-struct name before the main compile pass, following $-imports. Regression test compiler/tests/forward_enum_payload.nu.
time_parse_iso now returns ! i ParseErr (Unix seconds), not
! Time ParseErr. A Unix timestamp is the more composable parse result — directly sortable, comparable and storable — and time_from_unix reconstructs the broken-down Time when its fields are needed. The new time_make constructor uses the same ! i ParseErr shape for consistency.
~ while-loop condition no longer drops an
iteration.** gen_loop speculatively parsed the condition (to tell a while loop from a complement expression used as a statement) and left that speculative IR in the output — so the condition was evaluated one extra time up front. For a pure condition this was harmless dead code; for a side-effecting condition (~ ( read_next x ) { … }) the first evaluation's side effects happened with no matching body run, silently dropping one iteration's work. gen_loop now emits the condition straight into the loop-check block and only then looks for the { — the condition is parsed exactly once and evaluated exactly (bodies + 1) times; no speculative IR. Regression compiler/tests/loop_cond_sideeffect.nu.
?? ( call ) on a wide-payload ! T E result no longer truncates.
Matching directly on a function-call expression whose Result payload is a wide value struct (a multi-field Time-like type) silently produced garbage fields: gen_match's heap-box-unboxing reconstruction was keyed on <name>__res_nurl_T, which only exists when the scrutinee is a named binding — so ?? ( f … ) skipped it and the T-arm binding received the raw heap-box pointer reinterpreted as the struct. gen_match now synthesises the binding metadata from the callee's NURL return type (__last_nurl_call__, cleared before the scrutinee is evaluated), so the direct-call form reconstructs exactly like ?? r. Narrow payloads (i, pointers, handle structs) were always fine. Regression compiler/tests/match_call_wide.nu.
pattern-matches correctly.** Building @ E { Variant structValue } used to store the struct value straight into the variant's pointer slot (an LLVM type error: a multi-field struct is not a pointer), and the matching ?? arm mis-unboxed it. Both sites are fixed: construction heap-boxes the %Name payload (nurl_alloc + store) and the match arm loads it back through the slot pointer. Pointer payloads (*Ast) and single-pointer-handle structs (String, Vec) keep their existing direct-store path. This bug was independent of forward references — it affected backward-declared payload types too — but went unnoticed because no test constructed such a value.
#-cast from a named aggregate (enum or struct) to an integer
now works.** # i someEnumOrStruct (or any sized integer destination) recovers field 0 — an enum's variant tag, or a struct's first field. Previously the cast emitted no instruction, so the i64-typed use site (a return, nurl_print_int, arithmetic) failed the LLVM verifier. gen_cast now has one unified branch: extractvalue field 0, normalise it to i64 (sext a narrow integer field, ptrtoint a pointer field, fptosi a float field), then trunc to a narrower destination. A struct whose field 0 is itself an aggregate is a hard error. Regression tests compiler/tests/enum_to_int_cast.nu and compiler/tests/struct_to_int_cast.nu.
@ S { v }
where S's field is i8 / i16 / i32 and v is a wider value (e.g. an i64 literal) used to emit insertvalue ... i64 … into the narrow field — an LLVM verifier error — forcing an explicit # i8 / # i16-cast at every construction site. gen_agg_lit now coerces each named-struct field value to its declared field type: trunc into a narrower field, sext into a wider one. Regression test compiler/tests/struct_narrow_field.nu.
mcp_stdio_call write-failure classification was racy. Pinging a
server that had already exited surfaced either McpStdioEof or McpStdioIo depending on whether the write hit EPIPE before the child's exit was observed — a non-deterministic result (and a flaky mcp_stdio_basic test). On a failed write mcp_stdio_call now consults proc_eof: if the child's stdout is also at EOF the server is simply gone (McpStdioEof, matching the write-lands-then-reads-EOF path), otherwise it is a genuine transient pipe fault (McpStdioIo). The dead-server outcome is now deterministic.
^^ XOR operator. Two adjacent carets lex as a single
^^ token (the lexer pairs them only when adjacent — ^ ^ with a space is still two return tokens). ^^ is a strictly-binary operator lowered to LLVM xor: bitwise XOR on integer operands, logical XOR on b operands. Float operands are a compile error (LLVM has no float xor). Replaces the old (a | b) - (a & b) identity workaround. ^ alone remains the return operator. Grammar (spec/grammar.ebnf) and nurlfmt updated; regression tests xor_op.nu + should_fail_xor_float.nu.
inout and sink parameter conventions. in / inout /
sink are contextual keywords recognised only as a parameter's leading token (no lexer change); in is the default. A parameter marked inout is an exclusive mutable borrow: the callee mutates the caller's binding in place. inout T lowers to a by-address <T>* parameter — the body reads/writes the caller's storage with no local copy — replacing the *T-parameter and return-the-struct mutation idioms. The argument must be a mutable (: ~) binding; an inout function must be defined before it is called. Exclusive-access check: a binding passed inout must be the only argument path to its value at that call — passing it again, as a second inout or a plain by-value argument, is a warning:. A parameter marked sink consumes (takes ownership of) its argument: it lowers to an ordinary by-value parameter, and the borrow checker records the argument binding as moved so a later use is a use-after-move. sink v1 applies to Vec and other manually-managed handles; passing a compiler-auto-dropped value (owned string / slice / Drop value / struct with owned fields) to a sink parameter is rejected pending drop-ownership transfer.
pass (disable with --no-borrowck) that never changes generated code — a borrow-clean program compiles to byte-identical IR. Closes four bug classes with warning: diagnostics: use-after-move (a binding read after its ownership moved), alias double-free (: T b a of an owned heap value moves a), stack-reference escape (a closure capturing a : ~-mutable struct by pointer that is returned, pushed into a container, spawned onto a thread, or assigned into a longer-lived binding — a region-based check), and iterator invalidation (mutating a container — vec_push/vec_free/… — from inside a ~-foreach that iterates it). Ownership + borrow rules documented in the new docs/MEMORY.md.
gen_ret
now flags the upcoming return-value expression as tail-position; gen_call snapshots + clears the flag on entry, so only the outermost call in the return expression is treated as tail (argument-evaluation recursions stay non-tail). In the regular @-fn dispatch path the LLVM call becomes tail call when (a) the flag was set, (b) rlt == fn_ret_ty so LLVM accepts the marker, (c) the callee is not variadic, and (d) gen_ret saw no pending owned-string / owned-slice / owned- struct-field / user-drop / defer in scope at flag-set time (any of those would emit drop calls between the tail call and ret, which LLVM would silently demote).
Deliberately chose tail over musttail: tail is a hint LLVM may drop when its safety analysis can't confirm the rewrite (alloca-escape through an arg, etc.), so a misclassification only costs an optimisation. musttail is verifier-enforced and would fail on NURL's owning ABI where the same source-level signature lowers to different LLVM types across call sites.
Effect: tail-recursive functions no longer blow the stack — compiler/tests/tco_deep_recursion.nu runs a 5_000_000-deep countdown in O(1) stack (~7 ms wall-clock). Trait/impl, closure-loaded var, and fn-pointer-parameter dispatch paths intentionally still emit a plain call (different shapes; not the deep-recursion targets TCO exists for).
Coexists with --g DWARF emission: tools/dwarf_test.sh still passes all five phases.
generic-instantiation handles (%Vec__u8, %String, %FmtTok, user % Point, …) now resolve under nurlc --g to a !DICompositeType(tag: DW_TAG_structure_type, …) carrying one !DIDerivedType(tag: DW_TAG_member, …) per field — instead of the previous i64 placeholder. gdb ptype Point lists the fields with their NURL names + base types; print p renders the value as {x = 3, y = 7}; print p.x evaluates a single field.
Field roster lives in the existing symbol table next to the per-field __idx_N__type entries — gen_struct_decl and the generic-instantiation emitter now also record <sname>__field_count and <sname>__idx_N__name. New helpers dbg_size_bits / dbg_align_bits / dbg_align_up compute LLVM-natural cumulative field offsets so the emitted !DIDerivedType member offsets match the actual layout clang/LLVM uses. Self-referential structs (a cell holding a pointer to itself, etc.) are safe — the composite id is interned in g_dbg_type_syms before the per-field recursion descends through dbg_type_id_for, so a back-edge returns the cached id instead of looping.
Regression: compiler/tests/dwarf_struct.nu exercises the codegen path in the standard test corpus; tools/dwarf_test.sh picks up a fifth phase that drives gdb in batch mode to assert ptype + print + field-access over the new test. Bootstrap fixed point holds — non-debug IR is byte-identical.
Per-instantiation source-line precision for generics remains deferred.
JsonSerialize trait + decoder helpers
(stdlib/ext/serde.nu).** A NURL trait `JsonSerialize [T] { @ to_json T x → Json } with first-arg dispatch and impls for i / b` / f / s / String, paired with per-type from_json_<T> helpers (from_json_i / _b / _f / _string / _str_borrow) that return !T ParseErr. User types add their own `% JsonSerialize MyStruct { @ to_json MyStruct x → Json { ... } }` impl and a hand-written mystruct_from_json. The shape mirrors Serde: format-specific traits (JsonSerialize stands alone today; TOML / MsgPack get their own trait when those formats land) and a Deserialize-by-naming-convention because NURL's first-arg-dispatch cannot carry a trait whose receiver is Json — every impl would collide. Demo: examples/serde_demo.nu round-trips a Point through JSON text. Regression: compiler/tests/serde_basic.nu.
README — empty stub now.** The historical "gotchas" list existed to compensate for compile errors that lacked enough context to fix the source. As of v0.7.1+ that gap is closed: every old item now surfaces as a file:line:col: error: / warning: with a pointing caret + concrete cure inline (see "Source-level compiler diagnostics" below for the seven new emit sites + the four shipped prior). The residual edges — prefix-arity strictness, ^ not being XOR — are grammar properties, not surprises; they live in README's Known Limitations → Grammar table next to the existing imports / FFI limitations, and grammar.ebnf's bin_expr / ret_expr productions now carry explanatory comments. The GOTCHAS.md file is preserved as a redirect stub so external links (and the MCP nurl_read_gotchas resource) keep working, but new code should not add items there — extend the compiler diagnostics or the grammar comments instead.
Bare @-fn used as a closure value (error: at the use site with the \ args → R { ( fn args ) } wrap), and the ? cond bare-then bare-else { … } { … } shape (warning: — the n-ary &/| foot-gun where & only consumed 2 of the operands and the { … } blocks became side-effect statements). Both ride on the same die/warn infrastructure; verified via the bare '@-fn' / ?-with-{ smoke programs.
Previously each surfaced as either silent UB or a cryptic LLVM / arity error far from the source. Each now emits a file:line:col diagnostic with a caret + the concrete cure, and is mirrored in docs/GOTCHAS.md items 6-10 (the Quick-reference table gained an "Auto-diagnosed?" column). * ^ ?? value { ... } with ^-arms — error: augments the existing return expression has no value message with the : ~ T rc init / ?? { … = rc v } / ^ rc refactor (item 6). * nurl_str_len (libc, expects s) called on a %String, and string_len (stdlib, expects %String) called on a raw i8* — both error: at the call site (item 7). * Parameter named entry — error: at the param parse, naming the LLVM entry: block-label collision (item 8). * # T { ... } where T is a registered struct/enum — error: at the cast site suggesting @ T { ... } (item 9). `: ~ T mutable pointer bindings — warning:` at the decl pointing at the long-loop miscompile (item 10). Warn rather than die because trivial isolated cases work; the advisory catches the hoist patterns that crash deterministically ~tens of thousands of iterations in.
./install.sh). Bootstraps the
compiler (skipped if build/nurlc already exists), builds nurl-lsp, symlinks it into ~/.local/bin/nurl-lsp so VS Code / Cursor / Windsurf find it without any settings tweak, packages the VS Code extension (vsce package) and installs it via the editor's CLI when one is on PATH. Idempotent: re-run any time to pick up a newer checkout. Flags: --no-vscode, --no-path, --force, --uninstall, --help.
tooling/vscode-nurl bumped 0.3.0 → 0.4.4 (matches the
nurl-lsp server version it pairs with). README rewritten to document the actual feature set — go-to-definition (single + cross-file via $ path imports), hover, document outline, workspace-wide IDENT completion, Ctrl-T symbol search, folding ranges, and nurlfmt-backed formatting — replacing the stale "coming in later iterations" line that misrepresented an already-shipped server. New packaged extension: tooling/vscode-nurl/nurl-0.4.4.vsix.
protocolVersion bumped from 2024-11-05 to 2025-11-25
(current stable revision)** + centralized + drift-check tooling. All seven hardcoded pinnings across stdlib/ext/mcp.nu, mcp_registry.nu, mcp_client.nu, mcp_stdio.nu now route through a single mcp_protocol_version → s helper. A companion mcp_protocol_version_legacy → s returns 2024-11-05 for callers that need to explicitly negotiate the older shape (server MAY agree to whatever the client requests as long as it's a revision the server supports).
MCP revisions only bump on backwards-incompatible changes per the spec's versioning page, so a server advertising the latest revision serves earlier clients fine — pinning to an old date pushes negotiation the wrong way.
New helper: tools/mcp_spec_drift_check.sh fetches the spec site's versioning page, parses the current revision, compares to NURL's pinned value, exits 1 on drift with a pointer at the changelog URL. Drop-in for CI or a weekly cron.
foo.nu now emits LLVM !DICompileUnit` / !DIFile / per-fn !DISubprogram / per-stmt !DILocation / per-:-binding !DILocalVariable + llvm.dbg.declare metadata. nurl.sh --debug foo.nu forwards --g, drops -flto (which silently strips DWARF in the current LLVM/gcc-ld pipeline), and side-by-side rebuilds stdlib/runtime_debug.o with -g so the link preserves .debug_info end-to-end. gdb then resolves break fizzbuzz, break foo.nu:42, print x, whatis x (with NURL type names — i/u8/b/f/s/...), and backtrace with source file + line for every NURL frame. Closures and generic monomorphisations get their own subprograms with mangled names. nurl_panic now dumps a stack trace via libc's backtrace_* API before aborting; pipe each frame's offset through `addr2line -e <binary> to recover .nu:LINE`. End-to-end regression: ./tools/dwarf_test.sh (gracefully skipped if gdb is absent). Composite-type rendering (!DICompositeType for %Vec / user structs) and per-instantiation source-line precision for generics are not currently implemented.
vec_push / vec_insert /
vec_set / thread_spawn.** Extends the existing 2026-05-15 ^-return escape check (gen_ret reading __last_closure_byref__ + <name>__captures_byref) to four more sites where a closure takes ownership across a scope boundary. gen_call snapshots __last_ident_name__ per-argument; when the callee's fname is one of the four AND the argument names a binding tagged __captures_byref = 1, emits a soft warning: line consistent with the existing escape diagnostic. By-name only — struct-wrapping (@ Slot { cb } then push the slot) passes through silently, which is acceptable: we catch the obvious one-line foot-gun rather than every conceivable indirection. Closes gotcha #8 to the same scope as #5. Regression: compiler/tests/should_warn_closure_escape_vec.nu (positive thread_spawn + 2 negative controls).
docs/GOTCHAS.md §5 updated to document the extended coverage; the vec_push [(@ v)] form remains untested because anonymous closure types aren't yet accepted as generic-arg type names — a separate parse_type_paren extension would unlock it.
stdlib/ext/mcp_registry.nu (~550 LOC) replaces the previous "write your own JSON-RPC dispatch loop" workflow with a uniform register-tool-with-handler API. The Channel[A] generic-propagation fix (2026-05-17) unlocked closure-in-Vec storage, which is what makes this possible.
Three first-class entity types: - McpTool { name, description, input_schema, ( @ Json Json ) handler } - McpPrompt { name, description, arguments_schema, ( @ Json Json ) handler } - McpResource { uri, name, mime_type, description, ( @ Json ) handler }
Surface: - mcp_registry_new name version → McpRegistry - mcp_registry_add_tool / _add_prompt / _add_resource — register entities with closure handlers. - mcp_registry_dispatch r method ?params → Json — single entry point routing JSON-RPC method names through the per-method dispatchers. Covers initialize, tools/list, tools/call, prompts/list, prompts/get, resources/list, resources/read, ping. Unknown methods return an {__error__: "method not found"} envelope. - mcp_registry_envelope r req → ?Json — transport-agnostic single-request adapter. Notifications (no id) return None.
Transports: - stdio server — mcp_serve_stdio r → ! v McpServeErr reads JSON-RPC frames off stdin (line-delimited per spec), dispatches via the registry, writes responses to stdout. NURL is now a complete bidirectional MCP party (server-side stdio pairs with the existing client-side stdio from mcp_stdio.nu). - HTTP adapter — mcp_http_dispatch_for_registry r → ( @ ? Json Json ) returns a closure that plugs straight into the existing mcp_http_handler from stdlib/ext/mcp_http.nu (batch requests + CORS + session-id echo + SSE stub already covered there). - Bearer-auth middleware — mcp_http_with_bearer_auth handler expected_token decorates any HTTP handler with Authorization: Bearer <token> enforcement. Missing or mismatched → 401 with WWW-Authenticate: Bearer realm="mcp".
Spec out-of-scope for v1 (tracked): * resources/subscribe + change notifications (needs an SSE push channel from a custom accept loop). * completion/complete (rarely-used spec feature). * sampling/createMessage (server→client reverse RPC).
Regression: compiler/tests/mcp_registry.nu exercises every dispatch path including 2 tool invocations (echo + add(17,25)=42), prompt rendering with arguments, resource read, unknown-method error envelope, and ping.
json_as_str / json_as_int / json_as_bool accessors in
stdlib/ext/json.nu. Convenience extractors for the leaf-typed variants of Json, returning the unwrapped value (or empty/zero for the wrong variant). json_as_str returns a BORROWED view into the underlying JStr's String backing buffer — copy via string_from for longer-lived references.
against connection-exhaustion attacks: - DosLimits { max_concurrent_conns, max_conns_per_ip } declares the caps. dos_default_limits → DosLimits { 1024 16 } covers the typical single-VM-public-HTTPS shape; CG-NAT'd clients (10s of users behind one public IP) stay under 16 in real usage. - server_new_with_dos listener handler limits constructs an HttpServer with a runtime-side NurlDosState (mutex-protected counter + linear per-IP table, up to 256 distinct active IPs). - At accept time, server_run_once calls nurl_dos_state_try_acquire. Over-cap conns are closed immediately at the TCP layer (no canned 503 response — cheapest possible rejection, keeps the server's per-cap cost low). - Per-connection cleanup releases the counter on conn end; multi-worker pools (server_run_pool) share the same state via the shared HttpServer handle. - server_active_conn_count exposes the live counter for /metrics-style observability endpoints.
Runtime additions in runtime.c §23: NurlDosState struct + four public entry points (nurl_dos_state_new / _try_acquire / _release / _free) + _active accessor. Cross-platform mutex (pthread_mutex_t POSIX, CRITICAL_SECTION Win32). Linear scan with O(1) last-element-swap eviction on count→0 keeps the IP table compact under steady churn.
Verified live (NURL_NET_TESTS=1): compiler/tests/http_server_dos.nu opens 4 concurrent TCP conns from 127.0.0.1 against a server with max_per_ip=2; the first two complete the handshake + handler, the last two are rejected pre-handshake → 2 accepted + 2 rejected.
that close the critical-path tuotantopuutteet in the TLS stack built on top of the existing tcp_listen_tls listener — the new operations attach to an already-created listener and take effect on subsequent handshakes.
- tcp_tls_add_sni listener hostname cert_path key_path → !v NetErr Registers a per-virtual-host cert/key against the listener using OpenSSL's SSL_CTX_set_tlsext_servername_callback. Clients that offer no SNI extension OR offer an unknown hostname fall through to the default cert (set at listen time) — matches RFC 6066 §3 server semantics. Idempotent on re-add (replaces the stored pair for an existing host). Required for multi-tenant HTTPS where one listener serves multiple virtual hosts.
- tcp_tls_reload listener ?hostname cert_path key_path → !v NetErr Atomically swaps the listener's default SSL_CTX (empty hostname) OR a matching SNI entry's SSL_CTX with one built from the new cert/key files. Per-listener mutex serialises the swap against the concurrent accept loop; OpenSSL refcounts the old SSL_CTX so in-flight conns that already wrapped an SSL handle from it stay valid until they close. Natural shape for Let's Encrypt cert rotation triggered from SIGHUP or a control endpoint.
- tcp_tls_require_client_cert listener ca_bundle_path b strict → !v NetErr Sets SSL_VERIFY_PEER on the listener; when strict is true, adds SSL_VERIFY_FAIL_IF_NO_PEER_CERT so the handshake fails outright for unauthenticated clients (mTLS-mandatory). tcp_peer_cert_subject TcpConn → String reads the peer's X509 DN in OpenSSL one-line format (e.g. "/CN=test-client/O=NURL/C=FI") for the application's authorisation decisions.
Verified live (NURL_NET_TESTS=1): compiler/tests/http_server_tls_extras.nu runs three sections — SNI hostname dispatch (api.example.com → CN=api cert, www.example.com → CN=www cert, unknown.example.com → fallback to CN=localhost default), live reload (CN=localhost → swap → CN=reloaded.example.com), and mTLS (no client cert → rejected, valid client cert → 200 OK). All probes driven via `openssl s_client` shell-outs.
Runtime additions in runtime.c §18: NurlTcp grew sni_entries + sni_count + sni_cap + a per-listener cross-platform mutex (pthread_mutex_t on POSIX, CRITICAL_SECTION on Windows). Four new C entry points (nurl_tcp_tls_add_sni, nurl_tcp_tls_reload, nurl_tcp_tls_require_client_cert, nurl_tcp_peer_cert_subject). tcp_close extended to free the SNI registry + destroy the mutex; in-flight SSL_CTX refs are released via SSL_CTX_free's refcount decrement, so cleanup is safe under concurrent shutdown.
Full HTTP/2 server stack (RFC 9113 + RFC 7541) alongside WebSocket (RFC 6455), gzip wire format (RFC 1952), and an AddressSanitizer + UndefinedBehaviorSanitizer quality gate. Three compiler fixes (single-pointer-handle Result coercion, ?u match-arm unsigned propagation, gen_assign last-type publishing) + one new language feature (integer-literal match arms) round out the release. Bootstrap fixed point holds; 0 SAN_FAIL across the 208-test sanitized corpus.
plus one runtime extension for ALPN. Same ( @ HttpResponse HttpRequest ) handler contract as HTTP/1.1 — application code unchanged.
Modules: - stdlib/ext/http2_frame.nu (~360 LOC) — binary framing: 9-byte header + all 10 frame types + connection preface validation + pure round-trip helpers + socket I/O + one-shot senders for SETTINGS / PING / GOAWAY / WINDOW_UPDATE / RST_STREAM. - stdlib/ext/http2_hpack.nu (~900 LOC) — RFC 7541 header compression: 61-entry static table + dynamic table with FIFO + size-based eviction + N-bit prefix integer codec + string codec (literal or Huffman) + all 6 header-field representations + complete Huffman decoder covering all 257 Appendix B codes across 21 length buckets (5..30 bits). - stdlib/ext/http2_conn.nu (~770 LOC) — connection + stream state machine: SETTINGS exchange + apply, stream state diagram per RFC 9113 §5.1 (idle → open → half-closed → closed), HEADERS+CONTINUATION assembly with §6.10 interleaving check, DATA flow control with connection-level WINDOW_UPDATE replenishment, PING/GOAWAY/RST_STREAM dispatch, request assembly from HTTP/2 pseudo-headers (:method/:path/:scheme/:authority) to the existing HttpRequest shape, response emission with §8.2.2 hop-by-hop header stripping. - stdlib/ext/http2_server.nu (~100 LOC) — http2_serve + server_run_h2_capable. The latter accepts a connection, checks ALPN selection via tcp_alpn_protocol, and routes to h2 OR the existing HTTP/1.1 keep-alive loop transparently.
Runtime extension: - `nurl_tcp_listen_tls_alpn(host, port, backlog, cert, key, "h2 http/1.1") — wraps SSL_CTX_set_alpn_select_cb` over the existing TLS listener. Wire-format packing of the server's preference list happens C-side. NurlTcp handle gained alpn_wire + alpn_wire_len fields. - nurl_tcp_alpn_selected(handle) — reads SSL_get0_alpn_selected post-handshake, returns heap-owned NUL-terminated string ("h2" / "http/1.1" / ""). - NURL surface: tcp_listen_tls_with_alpn + tcp_alpn_protocol in std/net.nu.
v1 scope intentionally excludes: * Client-side h2 (symmetric to server; ship when a consumer asks). * h2c (HTTP/1.1 → h2 cleartext upgrade — TLS+ALPN is the universal modern shape). * PUSH_PROMISE / server push (deprecated by RFC 9113 §8.4). * PRIORITY frames (obsoleted by RFC 9218 — default ordering OK).
Verified offline against RFC 9113 §4 framing vectors and RFC 7541 Appendix C / C.4.1 HPACK + Huffman vectors via compiler/tests/http2_basic.nu. Bootstrap fixed point holds. ASan + UBSan: 0 SAN_FAIL across the 208-test corpus.
Usage: ` : !TcpListener NetErr ll ( tcp_listen_tls_with_alpn 127.0.0.1 8443 16 cert.pem key.pem h2 http/1.1 ) ?? ll { T listener → { : HttpServer s ( server_new listener my_handler ) ( server_run_h2_capable s ) } ... } `
?? value { 1 → ... 42 → ... -1 → ... _ → ... }
is now valid wherever value has an integer LLVM type (i / i8/16/32/64, u/u16/u32/u64). Each arm emits a single icmp eq <match_type> and branches; the wildcard _ arm catches the residual (required — exhaustiveness is not statically checked across the full integer domain). Skips the enum-variant lookup, payload-binding, and duplicate-arm tracking paths that named-variant arms exercise. stdlib/ext/http2_hpack.nu's 280+ line ? == x N { ^ Y } {} cascade for the HPACK static table + Huffman per-length lookup tables was rewritten on top of this and is significantly more readable. Regression: compiler/tests/match_int_literal.nu.
manual entry points: - ./build.sh --san rebuilds the runtime + every bootstrap stage with `-fsanitize=address,undefined -fsanitize-address-use-after-scope -fno-omit-frame-pointer -fno-sanitize-recover=all`. LTO is dropped because clang's LTO+sanitizer combo produces opaque link-time errors on NURL's cross-module function pointers (the runtime/user-code inline win isn't the point of a san run). LeakSanitizer is disabled during the bootstrap itself (ASAN_OPTIONS=detect_leaks=0) because nurlc_py/nurlc_self intentionally don't free their process-lifetime str-pool / sym-arena globals at exit. - compiler/tests/run_san_tests.sh runs the full .nu corpus under the sanitized runtime, captures stdout / stderr per-test separately, scans stderr for ASan/UBSan/LeakSanitizer markers, and reports PASS / SAN_FAIL / COMPILE_FAIL / LINK_FAIL. Non-zero exit codes without sanitizer markers count as PASS (several tests in the corpus deliberately return computed values as exit codes — native_sum returns 55, test_immutable_assign_error aborts to prove the runtime check fires, etc.). Skips should_fail_* compile-negatives and helper modules without main(). Leak detection is opt-in via LSAN_DETECT_LEAKS=1.
First sweep result: 188 PASS, 0 SAN_FAIL across 206 tests. The infrastructure stays manual — invoke when validating a release candidate or triaging a memory-shape bug, not on every build.
stdlib/ext/websocket.nu
(~570 LOC pure NURL). Composes on the HTTP/1.1 stack: client sends Upgrade: websocket + Sec-WebSocket-Key + Sec-WebSocket-Version: 13, server validates via ws_perform_handshake[_with] and writes the 101 Switching Protocols response, both sides switch to the binary frame protocol over the SAME TcpConn. TLS works transparently — wss:// routes through tcp_listen_tls + the polymorphic TcpConn SSL dispatch with zero additional code in this module.
Surface: - Handshake: ws_is_upgrade, ws_accept_key, ws_handshake_response_for, ws_perform_handshake[_with] (_with accepts an optional subprotocol echo). - Low-level frame I/O: ws_serialize_frame (pure, testable builder), ws_read_frame, ws_write_frame. - Convenience writers (server-side, never masked per RFC §5.1): ws_send_text, ws_send_binary, ws_send_ping, ws_send_pong, ws_send_close i code s reason. - Message reader: ws_read_message assembles continuation frames per RFC §5.4, auto-pongs incoming pings, surfaces peer close as WsClosedByPeer. Text payloads validated UTF-8 before return. - Serve loop: ws_serve_messages reads messages, dispatches to a ( @ ! v WsErr WsMessage ) handler, performs the full close handshake on exit (mapping errors to RFC §7.4 close codes: WsInvalidUtf8 → 1007, WsProtocol* → 1002, WsMessageTooLarge → 1009, everything else → 1011). - `WsLimits { max_frame_bytes, max_message_bytes, read_timeout_ms, fragment_max_count }`; defaults are 16 MiB / 64 MiB / 60 s / 128. - ws_validate_utf8 (RFC 3629 strict) is exposed publicly — useful outside the WebSocket context too.
Validation rigour: RSV1–3 bits must be 0 → WsProtocolReservedBit; opcode must be in {0,1,2,8,9,10} → WsProtocolBadOpcode; control frames MUST have FIN=1 (WsProtocolControlFragmented) and payload ≤125 B (WsProtocolControlTooLarge); client→server frames MUST be masked (WsProtocolUnmasked); text payloads MUST be valid UTF-8 — overlongs, UTF-16 surrogates (U+D800-U+DFFF), and codepoints above U+10FFFF all rejected; close codes constrained to 1000–4999; the fragment-count cap defends against a ping-flood interleaved with continuation frames.
Verified against RFC 6455 vectors: - §1.3 accept-key worked example (dGhlIHNhbXBsZSBub25jZQ== → s3pPLMBiTxaQ9kYGzzhZRbK+xOo=). - §5.7 unmasked text frame "Hello" round-trips to 81 05 48 65 6c 6c 6f. - Length-encoding transitions: 126-byte payload triggers the 16-bit extended-length header; 65536-byte triggers the 64-bit form (with the spec-required MSB-clear check on the top byte).
Regression: compiler/tests/websocket_basic.nu (6 sections, 30 assertions). v1 scope is server-side only — a symmetric client-side API is tracked for follow-on work if a real consumer asks. No permessage-deflate extension; subprotocol header echo is the only negotiation surface.
enable the WebSocket handshake's Sec-WebSocket-Accept derivation; exposed via new sha1_bytes (length-aware, binary-safe → 20 raw bytes) and sha1_hex (→ 40-char lowercase) in stdlib/std/hash.nu. SHA-1 is documented as protocol-compatibility-only — not recommended for new security-sensitive code; use sha256_hex for that.
stdlib/ext/http_full.nu now imports ext/websocket.nu so one
$-include brings the full HTTP stack including WebSockets in scope.
signal_basic.nu — F-arm pattern no longer binds the undef
Option payload.** The previous F e → { ( string_free e ) ... } shape passed the F-tag's undef String handle to string_free, which deep-dispatched into nurl_peek(NULL, 0) and tripped UBSan's "applying zero offset to null pointer". Replaced with bare F → ... (Option's None arm carries no data). Surfaced by the first sanitized run of the corpus and was a silent crash even outside of ASan (EXIT 139 / dumped core in the baseline — now EXIT 0 cleanly).
stdlib/runtime.c nurl_peek / nurl_poke defensively handle
NULL base.** nurl_peek returns 0 instead of dereferencing; nurl_poke silently no-ops. Safety net for the same caller-side mistake the signal_basic fix patched at source — a future stdlib binding that hands an Option-F-arm payload to a vec-style API will log a soft warning under ASan/UBSan but not crash the process.
?u → T b → match-arm now propagates the unsigned flag
to the payload binding.** parse_type_opt stashes the inner-T NURL token in __last_opt_nurl_t__; gen_let_or_struct copies it to <name>__opt_nurl_T; gen_match's T-arm payload binding tags <pv0>__unsigned = 1 when the inner T is u / u16 / u32 / u64. Without this fix the alloca dropped the unsigned-ness and a downstream # i b cast in the arm body emitted sext instead of zext for high-bit-set bytes, surfacing as wrong hex nibbles in bytes_to_hex over SHA-1 / SHA-256 digests. bytes_to_hex reverted from its temporary direct-pointer workaround back to the natural vec_get [u] + match-arm path.
! (Vec u) E Ok-arm now coerces the i64 payload to the
{ ptr }-shaped single-handle struct.** Two-part fix: parse_type_res stashes __last_res_t_llvm__ (LLVM type of T) so gen_let_or_struct can store <name>__res_t_llvm for paren-compound T like ( Vec u ) whose NURL-source name is just (; gen_match's reconstruction path uses it as a fallback after the NURL-name lookup fails. Additionally, coerce_store_val gained an i64 → single-pointer-handle-struct case (one-field struct whose field 0 is a pointer — covers Vec[A], String, Channel[A], Thread, Arena) that wraps via inttoptr + insertvalue at field 0. Without these, ?? r { T pb → @ Frame { … pb } } over ! ( Vec u ) E generated invalid IR (insertvalue %Frame, i64) forcing callers into vec_with_cap + vec_extend copy workarounds. ws_read_frame reverted from the copy workaround back to the direct payload pass-through.
gen_assign now publishes the LHS type via
nurl_set_last_type.** Without this, an =-assignment as the last expression of a match arm (F e → { = err e }) reported the RHS-expression's pre-coerce type to the surrounding gen_match, causing the phi to be typed for the RHS while the actual stored register held the coerced LHS type. LLVM verifier rejected the mismatch. Surfaced in ws_read_frame's ?? hdr_r { T … F e → { = err e } } which previously needed a trailing ( nurl_print ) to push the arm's last-expression type back to void; that workaround removed.
stdlib/ext/compress.nu gains
gzip_compress / gzip_compress_at level / gzip_decompress — byte-identical interop with the gzip / gunzip CLI tools and HTTP Content-Encoding: gzip. Magic + 10-byte header, raw deflate body, CRC-32 + ISIZE trailer, all per RFC 1952. Decompress auto-detects gzip OR zlib wire format on the input side (libz's inflateInit2_(windowBits=15+32)), so a single helper handles both shapes coming back from heterogeneous peers. Decompress also reads the ISIZE trailer to pre-size the output buffer, avoiding the grow-and-retry loop on the common path (sub-4 GB inputs). Errors map to the existing CompressErr enum (CompressData / CompressMemory / CompressBufTooSmall / CompressOther). Empty input passes through to an empty Vec[u] with no magic-byte production, matching the zlib/zstd shape. Regression: compiler/tests/compress_gzip.nu (round-trip + magic bytes 0x1f 0x8b 0x08 + level-0 store-only + empty + auto-detect zlib + garbage rejection).
runtime.c §22 — gzip bridge. nurl_gzip_compress /
nurl_gzip_decompress wrap libz's streaming deflateInit2_(windowBits=15+16) / inflateInit2_(windowBits=15+32) + deflate(Z_FINISH) / inflate(Z_FINISH) + matching End. ABI mirrors compress2 / uncompress (in/out dst_len, return 0 on success or libz error code on failure; sentinel -98 when the build lacked zlib). The C-side bridge stays because z_stream's sizeof and field layout are platform-specific (88 B on Win64 LLP64, 112 B on Linux/macOS x64 LP64), and deflateInit2_ checks an exact-sizeof match — mirroring the struct from NURL would be brittle across toolchains. Same architectural pattern as the sqlite3 borrowed-view bridge: thin, ABI-faithful, no state caching beyond what libz needs.
stdlib/ext/compress.nu header comment updated: the
zlib-vs-gzip wire-format gap section now documents the gzip helpers shipped alongside, with a pointer to runtime.c §22 for the bridge rationale.
build.sh zlib detection now sets
ZLIB_CFLAGS="-DNURL_HAVE_ZLIB ..." and threads it into the runtime compile step so the §22 bridge compiles in when zlib1g-dev is present. Without zlib, nurl_gzip_* short-circuit to the NURL_GZIP_ERR_UNSUPPORTED sentinel which the NURL surface maps to CompressOther — graceful runtime degradation rather than a link error.
stdlib/ext/csv.nu
has implemented RFC 4180 quoting via the `CSVDialect { delimiter, crlf, quote_char } struct (and the matching CSVTable` arena's escape_buf) since the v2 arena rewrite. The roadmap line was a leftover from the pre-arena CSV prototype; surfaced and removed during the critic-cleanup sweep.
side-by-side compose freely: a generic function that returns ( Outer A ) while internally allocating *( Inner A ) and writing its fields now compiles, and a generic struct whose field types reference another generic (e.g. Wrap[A] { ( Vec A ) items, … }) emits its inner instantiation before the outer typedef. Fix is in compiler/nurlc.nu — emit_one_instantiation re-scans the substituted generic-function body so concrete inner refs trigger ensure_struct_instantiated, and ensure_struct_instantiated itself re-scans the substituted generic-struct body for the same reason. Bootstrap fixed point holds (stage1 ≡ stage2 byte-identical IR at 1 187 843 B). Regression: compiler/tests/generic_nested_struct.nu (Inner/Outer + Wrap/Vec, both [i] and [s] instantiations).
Channel[A] — generic over the element type. stdlib/std/channel.nu
rewritten on top of the nested-generic fix. Channel[A] { s ctl } wraps ChannelImpl[A] { Mutex m, Cond c, ( Vec A ) q, i closed }; every call site supplies the element type via [A] (chan_new [i], chan_send [s] ch "hello", chan_recv [i] ch → ?i, etc.). Closes the long-standing v0.3.0 roadmap item that previously forced i64-only channels with # i ptr for handle payloads. compiler/tests/channel_basic.nu migrated to the new API (still exercises [i] so behaviour-equivalent); the regression test above exercises both [i] and [s]. Naming: uses [A] (the existing stdlib tparam convention) — T is the boolean true literal in NURL so cannot be a tparam name.
stdlib/std/bytes.nu gained six
read helpers and six write helpers covering u16 / u32 / u64 in both big-endian (network) and little-endian byte orders: bytes_read_uN_be/_le → ?T (None when offset is negative or runs off the end of the buffer), and bytes_push_uN_be/_le → v for the symmetric appends. Unblocks binary protocol work (gzip CRC-32 + ISIZE trailers, MessagePack header bytes, BSON length prefixes, raw network packet headers). Regression: compiler/tests/bytes_endian.nu (round-trip + boundary values + byte-layout sanity + OOB + negative-offset rejection).
?? on a bare-enum value from an F arm of ! T E now
compiles.** gen_match was always emitting extractvalue to recover the discriminant tag, even when the matched value was already a bare scalar (e.g. an IoErr bound by ?? r { F e → ?? e { … } }, where e is just i64). The pre-existing i1 short-circuit is generalised to cover both i1 AND i64 match types — extractvalue is only emitted on aggregate types now. Closes gotcha #6. Regression: compiler/tests/nested_match_enum.nu (direct ?? on Color, nested ?? per-variant on DbErr-from-! i DbErr, plus the wildcard arm).
gen_field_store's struct-pointer branch now routes `= . obj field val to the field-store path when the IDENT after .` is a function parameter AND a registered field of the destination struct. Pre-fix the int-width check ran first and treated the param as an array index, emitting `getelementptr %S, %S* %obj, i64 %field` (value-as-index, no field offset). Local non-param int variables that coincide with field names — like vec.nu's len/idx/i array-store kernels — still route through the array path. Closes gotcha #10. Regression: compiler/tests/param_field_shadow.nu (Box param-shadow positive + Pt array-store negative control).
i64 recognised as a type keyword. The C and Python lexers'
multi-char TYPE_KW whitelists already covered i8/i16/i32/u16/u32/u64/f32 but not i64, so any source line writing : i64 name … silently took the inferred-type branch (i64 as the binding name, the rest as the value) and produced IR with undefined SSA names. llvm_type was missing the i64 → i64 row symmetrically — even after the lexer fix, the chain fell through to the %i64 named-type fallback and LLVM rejected the resulting alloca %i64 as unsized. Both ends fixed; closes gotcha #7. Regression: compiler/tests/sized_int_binding.nu covers literal + FFI-call RHS for every sized integer width.
*u pointers.
gen_member now snapshots __last_unsigned__ before parsing the index expression and restores it after the load, so a subsequent # i ( . p k ) cast emits zext i8 → i64 instead of sext. Prior to this, a byte with the high bit set (0x89, 0xFF, …) would sign-extend to 0xFFFFFFFFFFFFFF89 and silently corrupt shift-and-add accumulators in the byte-decoding code that triggered the discovery. Covers both the literal-index and variable-index load paths; struct-field loads not affected.
Channel is no longer a type alias for the i64 channel. All
callers must specify the element type at use site. The single in-tree caller (compiler/tests/channel_basic.nu) was updated.
CSV stdlib consolidates around the arena layout, runtime link-time optimization lands across the toolchain, and a couple of long-running infrastructure papercuts get resolved.
build.sh --no-tests flag. Bootstraps the compiler (with the
byte-identical-IR fixed-point gate still enforced) and exits before the test suite. Replaces the older verbosebuild.sh script that Docker images relied on. api/Dockerfile and nurlapi/Dockerfile both updated to ./build.sh --no-tests.
nurl.sh link line — full runtime-feature parity. The user-
facing wrapper now auto-links -lssl -lcrypto (when stdlib/runtime.openssl sentinel present), -lsqlite3 (stdlib/runtime.sqlite3), and -lpq (stdlib/runtime.pq) in addition to the existing -lcurl auto-detection. Mirrors the central build.sh link line; closes the v0.4.3 follow-up to centralise the link-flag set across multiple build scripts.
stdlib/runtime.o is now compiled with `-O2
-flto, and every clang invocation that consumes it (build.sh`, nurl.sh, compiler/tests/run_tests.sh, tools/{nurlfmt,nurl-lsp,nurlpkg}/build.sh) carries the matching -flto flag. The LTO link pipeline inlines Vec / String / IO FFI calls (vec_push, vec_data, vec_reserve, nurl_peek/poke, nurl_parse_int_range, cmp_int, …) across the runtime ↔ user- code boundary. Measured on the 1 M-row × 8-col CSV bench (Linux i7-5930K, 5 runs median):
| Stage | no LTO | LTO | Δ |
|---|---|---|---|
| load | 315 ms | 272 ms | -14 % |
| filter | 146 ms | 139 ms | -5 % |
| sort | 65 ms | 40 ms | -38 % |
| total | 529 ms | 451 ms | -15 % |
Sort wins the most because the indexed-permutation comparator was bottlenecked on un-inlinable nurl_parse_int_range / cmp_int / vec_data. Native binary size dropped 172 888 → 25 408 B (-85 %) as LTO drops unused runtime symbols. Bootstrap fixed-point IR unchanged (LTO runs at link time only — nurlc's LLVM IR generation is invariant) — stage1 ≡ stage2 still at 1 185 386 B.
stdlib/ext/csv.nu API consolidation. The legacy v1
CSVTable / CSVRow per-cell-malloc layout is gone. The arena- backed CSVTableA is now THE CSVTable — every csv_table_* call reaches the (offset, length) arena parser directly, and RFC 4180 quoting is the default for every load/write. New accessor surface:
- csv_table_view t row col → s — zero-copy borrowed pointer into the content / escape buffer. NOT NUL-terminated; pair with csv_table_view_len. - csv_table_view_by_name, csv_table_view_len. - csv_table_get t row col → ?String — owned-String fallback for callers that want an independent lifetime. - csv_table_get_by_name.
All sort / filter / truncate / find / select_cols paths wired through the arena. Predicate signature for csv_table_filter is now ( @ b *CSVTable i ) → b (table + row index) instead of the old CSVRow-based shape — match the closure-cached-pointer pattern used by compare/nurl_analysis.nu. csv_table_a_* functions and CSVTableA deleted outright (no deprecation cycle — NURL is not yet in wide enough use). Removed files: stdlib/ext/csv_hoist_test.nu, compare/nurl_analysis_arena.nu, compiler/tests/csv_sort_indexed.nu, compare/csv_demo.nu (latter two were duplicates of csv_arena / examples/csv_demo.nu). Callers updated: examples/csv_demo.nu, compare/nurl_analysis.nu, compare/test_quoting.nu, `compiler/tests/{csv_arena, repro_csv_table_quotes}.nu`. CSV bench at 451 ms (post-LTO) vs Polars 95 ms (~4.7×). RFC 4180 quoting verified across read + write round-trips.
compiler/tests/run_tests.sh
now skips files matching *_mod.nu, *_helper.nu, *_lib.nu — they are imported by other tests and have no main function, so the old framework recorded them as COMPILE OK / LINK FAIL in the baseline. Five stale entries removed from correct.txt: alias_rewrite_types_mod, should_fail_alias_import_mod, should_fail_group_d_lib, should_fail_pub_helper, should_fail_pub_type_helper.
verbosebuild.sh — duplicated build.sh's logic without
test execution. Folded into build.sh --no-tests.
CSVTableA + every csv_table_a_* function in
stdlib/ext/csv.nu (see "API consolidation" above).
stdlib/ext/csv_hoist_test.nu — stranded Phase 2c hoistexperiment, never imported by any caller.
The package manager lands. nurlpkg is a Cargo-shaped CLI that covers the full dependency lifecycle: scaffold a manifest, declare dependencies, resolve them transitively, lock the resolution, and verify the lockfile hasn't drifted. This release also ships the TOML and Manifest stdlib modules that back the package manager, plus a new fs_symlink primitive in stdlib/std/fs.nu.
tools/nurlpkg/ — NURL package manager. Single-binary CLIwith ten subcommands:
- nurlpkg init <name> — write a nurl.toml skeleton (refuses to overwrite an existing one). - nurlpkg info — pretty-print the typed manifest fields. - nurlpkg deps — list each [dependencies] entry, one per tab-separated line (pipe-friendly). - nurlpkg add <name> [--path P] [--version V] — append a dependency to [dependencies] via surgical text edit (preserves user comments and formatting; refuses duplicates). - nurlpkg remove <name> — delete a dependency entry the same way (errors if the name isn't declared). - nurlpkg install — BFS-resolve every path-based dependency transitively, create deps/<name> symlinks via libc's symlink(2), regenerate nurl.lock as a side effect. Idempotent: rerunning on a fully-installed tree returns 0 silently. Diamond dependencies dedupe. - nurlpkg lock — regenerate nurl.lock from the on-disk deps/ tree without reinstalling. - nurlpkg verify — compare deps/ against nurl.lock and exit 1 on any drift (missing entries OR unexpected entries). Intended for CI / pre-build gates. - nurlpkg version / --version — print the nurlpkg version. - nurlpkg help — usage.
stdlib/ext/toml.nu — TOML parser. Recursive-descent parser
producing a TomlValue tagged-union tree (TStr / TInt / TBool / TArr / TTable). Handles both [section] headers and [[array.of.tables]], inline tables, dotted keys, and comments. Used internally by the package manager but also available to any stdlib consumer.
stdlib/ext/manifest.nu — typed manifest view. Pulls the
well-known [package] and [dependencies] fields out of a TomlValue tree into a typed `Manifest { name, version, description, license, Vec[Dep] dependencies }`. Single-table inline-table dep form and bare-string version form both supported. Returns ! Manifest ManifestErr with a small set of named error variants (ReadFailed / ParseFailed / MissingName / MissingVersion / BadShape).
fs_symlink s target s linkpath → !v IoErr (stdlib/std/fs.nu).
Thin wrapper over libc's symlink(2) exposed via pure-NURL FFI (& \c` @ symlink → i32`). POSIX-only; Windows callers should fall back to copying since CreateSymbolicLinkW needs a privilege most accounts lack.
compiler/tests/toml_basic.nu covers the
parser; compiler/tests/manifest_basic.nu covers the typed manifest extraction (well-formed + missing-required-field cases).
Two codegen issues surfaced while writing the package manager and remain as quirks until separately addressed:
?? on an enum value extracted from ! T E emits
extractvalue on an i64, which is invalid LLVM. Workaround: flatten with ? + ==, or restructure to avoid needing the inner match (__cmd_install checks file_exists before dir_create to skip the AlreadyExists arm entirely).
: i64 n ( ffi_call … ))
emit store i64 %n, … before the call defines %n, producing "use of undefined value." Workaround: bind FFI integer returns to : i n (…) (the default 64-bit type).
LSP server gains the last three "quick win" features and the Language Server protocol surface is now feature-complete enough for daily editor use without falling back to other tooling.
textDocument/formatting — pipes the active buffer through
build/nurlfmt --stdin and returns a single TextEdit covering the entire document. Shift+Alt+F in VS Code triggers it. Uses process_run's stdin_str parameter — no temp file needed.
workspace/symbol — fuzzy-search across every indexed
top-level symbol (functions, struct/enum types, enum variants, global constants, FFI symbols). Case-insensitive substring match, empty query returns the full set. Ctrl+T / Cmd+T in VS Code. Reuses the g_all_names :list TSV index built by the decl scanner.
textDocument/foldingRange — emits FoldingRange for every
multi-line { … } block. Backtick strings and // comments are skipped so braces inside them don't confuse the matcher. Single-line blocks (e.g. { ^ 0 }) are filtered out. Nested blocks each get their own range so the editor can fold any level independently.
Tier D ecosystem advances on two axes: a working Language Server (nurl-lsp) with the five most-used IDE features wired end-to-end, and a small but generally-useful binary-stdin primitive in core/io.
tools/nurl-lsp/). Stdio JSON-RPC server
written in NURL itself, wired to VS Code / Windsurf through the refreshed tooling/vscode-nurl extension (v0.3.0). Features: - Live compile-driven diagnostics on didOpen / didChange (errors + warnings stream from nurlc stderr into LSP publishDiagnostics). - Go-to-definition across files. Transitive $-import index populated per workspace; jump works for @-functions, struct/enum types, enum variants, global : constants, and & \lib`` FFI symbols. - Document outline (textDocument/documentSymbol) with the right SymbolKind per decl shape — visible in VS Code's Outline panel and via Ctrl+Shift+O. - Hover popups (textDocument/hover) showing the symbol's kind label, signature line (Markdown-formatted code block), and source location. - Completion (textDocument/completion) filtered by the IDENT-prefix immediately left of the cursor. CompletionItemKind mapping covers the same five decl shapes.
Build: ./tools/nurl-lsp/build.sh produces build/nurl-lsp.
stdlib/core/io.nu read_n_bytes i n → ( Vec u ). Owned-Vec
binary stdin reader. Used by the LSP server's Content-Length framing; useful for any framed-protocol consumer (DAP, raw JSON-RPC, length-prefixed RPC). Backed by nurl_read_n_bytes in runtime.c §1 — single fread + side-channel byte count via the existing nurl_last_bytes_len.
tooling/vscode-nurl extension v0.3.0. Spawns nurl-lsp over
stdio via vscode-languageclient. Server-path fallback order: nurl.server.path setting → <workspaceFolder>/build/nurl-lsp → PATH lookup for nurl-lsp. Graceful syntax-only fallback when no binary resolves. New configuration knobs nurl.server.path and nurl.server.trace. Packaged as nurl-0.3.0.vsix.
tools/nurlfmt/build.sh linker line. The formatter's build
script was matching only the libcurl sentinel; missing openssl / sqlite3 / libpq linker flags led to `undefined reference to TLS_server_method` once OpenSSL was wired into the runtime. Now mirrors tools/nurl-lsp/build.sh and the central build.sh by checking all four runtime sentinels (`stdlib/runtime.{curl, openssl,sqlite3,pq}) and appending the corresponding pkg-config --libs` to the link line. Same pattern that breaks when a new runtime dependency is added across multiple build scripts — centralising into tools/_link_flags.sh is a follow-up.
The v0.4.0 panic model #include <setjmp.h> made runtime.c unbuildable under --target=wasm32-wasi (wasi-sdk's setjmp.h errors out unless -mllvm -wasm-enable-sjlj is set against the unfinalised Wasm Exception Handling proposal). Same for clock(), which is deprecated on wasi-sdk without _WASI_EMULATED_PROCESS_CLOCKS. Both are now #ifndef __wasi__-guarded. On WASI, nurl_recover degrades to "run-the-closure-inline, return 0"; nurl_panic prints the message and aborts (same shape as the no-frame path on native targets); nurl_panic_last_msg returns "". The degraded recover semantics line up with WASI's other single-threaded fallbacks (signals, processes, threads). Native builds unchanged — bootstrap fixed point still at 1 184 466 B.
Tier A correctness/safety holes from the v0.3.0 external review all closed; Tier B HTTP production-hardening complete end-to-end (TLS 1.2+, per-request timeout, configurable parser limits, handler panic recovery); Tier C module-system extended (pub for types/enums/ consts, alias rewrite for everything); Tier D ecosystem advanced (SQLite + PostgreSQL FFI, compile-time FFI library check).
The full per-feature breakdown follows.
stdlib/ext/postgres.nu (pure-NURL). First
example of the runtime-less FFI model: every libpq symbol is declared directly via & pq @ ... → ... — no runtime.c bridge. Surface: `pg_connect / _close / _err_msg / _exec / _exec_params / _result_status / _result_is_ok / _ntuples / _nfields / _get_value / _get_is_null / _field_name / _clear. pg_exec_params` accepts a Vec[String] and builds the parallel char ** pointer array for libpq. v1 scope: text format only, no async, no LISTEN/NOTIFY, no COPY streaming. Build-time dep detected via `pkg-config --exists libpq`; missing → clear compile-time error from the new lib-check (below). Regression: compiler/tests/postgres_basic.nu (NURL_PG_TESTS=1 + PG_CONNINFO=... to enable).
(__ffi_lib_check).** Every &-FFI library name is normalised (strip lib-prefix, whitelist always-linked system libs c / m / pthread / dl) and checked against stdlib/runtime.<lib> sentinels written by build.sh. Missing sentinel → die at the &-decl site with `FFI library '<name>' is required but no build-time sentinel '...' found - install lib<name>-dev (or equivalent) and run build.sh again`. Replaces cryptic linker errors like undefined reference to PQconnectdb. Smoke-validated by moving stdlib/runtime.pq aside and recompiling a postgres-using program.
stdlib/ext/sqlite.nu. Thin wrapper over
libsqlite3 with idiomatic ! T SqliteErr returns. Surface: `sqlite_open / _close / _exec / _prepare / _bind_int / _bind_text / _bind_null / _step / _column_count / _column_type / _column_int / _column_text / _reset / _finalize. : Database / : Statement` value handles, : | SqliteErr with 9 variants. sqlite_step returns !b SqliteErr (T=Row, F=Done). v1 scope: int64 + text binds/columns only (no BLOB / double), no transaction helpers, no statement cache, no ATTACH — those compose with raw SQL. Build- time dep detected via pkg-config --exists sqlite3; without it, every entry returns SqliteUnsupported. Runtime side at stdlib/runtime.c §21. Regression: compiler/tests/sqlite_basic.nu (in-memory CRUD round-trip with prepared statement reuse).
global constants.** $ path alias now renames every top-level decl in the imported file to alias__name, not just @-functions. Use sites reach them with alias::Name, which the lexer merges into the single IDENT alias__Name. FFI declarations and trait/impl methods are NOT renamed — FFI is linker-level ABI, trait dispatch is type-mangled. collect_alias_targets grew handling for : / : | / : TYPE_KW / pub prefixes. Regression: compiler/tests/alias_rewrite_types.nu + helper module.
pub visibility for structs, enums, and global constants.
Extends the v2.0 pub @ greet rule that already covered @-fns to cover pub :, pub : |, and pub : i FOO 7 declarations. Enum variants inherit the parent enum's visibility (no per-variant syntax). Enforcement at parse_type_base (cross-file %Name resolutions) and gen_ident (cross-file __global loads). FFI and trait/impl decls accept pub forward-compat but don't enforce (FFI is an ABI contract; trait dispatch is type-mangled, not name- routed). Diagnostic: `private type 'X' is not visible across files; defined in 'Y' (and the global / function` variants on the same template). Regressions: pub_type_visibility.nu (positive) + should_fail_pub_type_neg / _const_neg / _variant_neg + should_fail_pub_type_helper.nu (shared helper).
Strategic value: package management now has the public API surface it requires.
parse_request_head now returns ! ParsedHeadOk HttpReqErr
(was ParsedHead { head, consumed, ok, err }). The v0.3.0-era tagged-struct workaround for the multi-field-Result-Ok-arm hole is gone — heap-boxing of multi-field Ok payloads shipped 2026-05-14 unblocked the idiomatic shape. Callers in stdlib/ext/http_server.nu (__read_request_head + keep-alive loop), stdlib/ext/http_proxy.nu (proxy_serve_run_with), and compiler/tests/http_request_parser.nu migrated from ? . ph ok / .ph err branching to `?? ph { T pho → ... F e → ... }. parsed_head_free and __parsed_head_err` removed — the new shape needs neither. Bundled cleanup: stale vec_get [Header] miscompile comments in header_get / __parse_headers updated to reflect current reality (the miscompile shipped a fix May 14; direct-pointer iteration is retained where it's still the right shape, not as a workaround).
stdlib/std/panic.nu module: panic s msg → v for explicit aborts, recover ( @ v ) closure → ! v PanicInfo for setjmp/longjmp-based catch. Built on nurl_recover / nurl_panic / nurl_panic_last_msg runtime primitives (stdlib/runtime.c §20, thread-local jmp_buf stack). NOT Rust-style stack unwinding — owned allocations inside a recover scope that don't run their auto-drop leak. Signal faults (SIGSEGV / SIGFPE / SIGBUS) are NOT caught. Recover is crash- mitigation, not a routine error path. HttpServer's __serve_keepalive_loop wraps the handler call in recover: panic in the handler → server logs the message to stderr + substitutes a stock 500 response + keeps serving. Compiler fix bundled: simple_capture_analysis now captures assignment targets as well as read references — the recover-with-byref-capture pattern depended on it (pre-fix the closure body referenced the outer's alloca register directly, producing invalid IR). Regressions: compiler/tests/recover_basic.nu (offline; Ok / panic / typed-byref round-trip cases) and compiler/tests/http_server_panic.nu (NURL_NET_TESTS=1).
cert_path key_path → !TcpListener NetErr in stdlib/std/net.nu` is a drop-in replacement for tcp_listen; NurlTcp runtime struct made polymorphic via SSL *ssl + SSL_CTX *ssl_ctx fields, so nurl_tcp_read / _write / _close dispatch via libssl when the handle was wrapped at listen time. **HttpServer integration is zero code changes** — callers just swap the listener constructor. TLS 1.2 minimum. Build-time dependency detected via `pkg-config --exists openssl; without it, calls return NetTlsCtxInit`. v1 scope: no SNI, no ALPN, no client-cert auth, no live cert reload. New NetErr variants: NetTlsCtxInit / NetTlsCertLoad / NetTlsKeyLoad / NetTlsHandshake. Regression: compiler/tests/http_server_tls.nu (NURL_NET_TESTS=1; generates a self-signed cert at setup time).
shipped: - Configurable parser limits via new `HttpLimits { i head_max_bytes, i header_max_count, i body_default_max } struct + http_default_limits` ctor in stdlib/ext/http_request.nu. parse_request_head_with / __parse_headers / __finish_body plumbed; HttpServer extended with an HttpLimits limits field; new server_new_complete constructor exposes every knob. Existing server_new / _with_timeout / _full keep v0.3.0 defaults so every existing call site builds unchanged. - Per-request total timeout via new HttpServer.request_total_timeout_ms field (0 = disabled). __serve_keepalive_loop snapshots now_ms after each head parse; if the handler runs over budget, its response is dropped and a stock 504 sent instead with forced `Connection: close`. Enforcement is post-handler only (NURL has no thread- cancellation primitives) — per-conn idle timeout still covers slow reads.
Acceptance: compiler/tests/http_server_limits.nu (NURL_NET_TESTS=1). Mirror call site in stdlib/ext/http_proxy.nu uses http_default_limits.
docs/GOTCHAS.md items 3 + 8. Two
non-fatal warning: diagnostics now surface the two soundness- adjacent foot-guns flagged by the v0.3.0 external review: - Same-line parameter shadowing (: i z + z 7 where z is a function parameter): per-fn __fn_param_names__ roster shadowed inside closure bodies so the check stays scoped. Zero false positives across the entire stdlib + compiler + test corpus. - Closure-byref escape on `^`-return: closures that take a : ~-mutable multi-field capture by pointer (via the existing __is_capture_byref predicate) get tagged with __last_closure_byref__ at the closure-literal site; the tag is propagated onto the binding (<name>__captures_byref) by gen_let_or_struct; gen_ret reads either form and emits the warning. vec_push / thread_spawn escape sites are NOT yet checked (documented as follow-up).
New should_warn_* test category in compiler/tests/run_tests.sh: compile stderr is captured into a WARNINGS block (absolute paths stripped via sed $ROOT_DIR/). Regressions: compiler/tests/should_warn_param_shadow.nu and compiler/tests/should_warn_closure_escape.nu. docs/GOTCHAS.md items 3 + 8 marked "Compiler-warned 2026-05-15" in the quick-ref table.
$-import dedup keys are now canonicalised. Pre-existing dedup
tables in three compiler passes (scan_generic_structs, scan_fn_sigs, gen_import_decl) keyed on the raw path string, so $ \stdlib/x.nu` and $ `./stdlib/x.nu`` (same physical file, different strings) defeated the dedup and produced `redefinition of type errors at link. New __norm_import_path` helper strips leading ./ segments at every $-path read site. Symlink-equivalent paths still collide as separate imports (no realpath FFI yet — intentionally deferred). Acceptance: compiler/tests/import_dedup.nu. README "Known Limitations" updated to drop the stale "no duplicate-include guard" / "alias parsed but ignored" claims (alias DOES rewrite top-level @-fns; dedup HAS worked for exact-string matches since the original $-import implementation).
previously copied all bytes past a parsed head wholesale into req.body, which silently corrupted req1 and dropped req2 entirely when a peer pipelined two requests in one send(). The fix introduces a connection-level Vec[u] carry buffer that survives across keep-alive iterations: __read_request_head drops only the .consumed bytes off the front after a successful parse; __finish_body drains exactly Content-Length bytes off carry's front before topping up from the socket; any remaining bytes feed the next iteration. Mirror call site in stdlib/ext/http_proxy.nu also updated. Acceptance: compiler/tests/http_server_pipelined.nu (NURL_NET_TESTS=1).
Grammar moved from v1.9 → v2.0: visibility control with pub is the headline feature. printf-family direct-call (variadic FFI) shipped in the same window. nurlfmt learned the canonical layout and ships as build/nurlfmt. Bootstrap fixed point holds with byte-identical LLVM IR across stages 1 and 2.
pub (grammar v2.0). A top-level decl
may carry a leading pub keyword to mark it public:
`nurl pub @ greet → v { ( nurl_print hello\n ) } @ __priv → v { ( nurl_print internal\n ) } `
Per-file strict-vis mode is OPT-IN: a source file enters strict mode the first time any of its decls carries pub. In strict mode, every unmarked @-function is private to that file; calls from another file are rejected with private function 'X' is not visible across files; defined in 'Y'. Files without any pub decl stay in legacy mode — the entire existing stdlib + test corpus continues to build unchanged.
Implementation: LTT_PUB = 44 in stdlib/runtime.c (the lexer recognises the bare identifier pub); compiler/nurlc.nu tracks per-fn origin + per-file strict-mode in a new g_vis_syms map, the current source file is saved/restored across nested $-imports, and gen_call enforces the rule at @-fn dispatch sites. Forward-compat parse paths for pub on : / & / % decls accept the prefix but do not yet enforce — wider enforcement is on the roadmap. nurlfmt learned to glue pub onto the following decl-starter so pub @ greet stays on one line through the formatter. Regression tests: compiler/tests/pub_visibility.nu (positive, runs hello from pub + hello from priv) and compiler/tests/should_fail_pub_visibility_neg.nu (negative, expected COMPILE FAIL). Bootstrap fixed point holds with byte-identical IR across stages 1 and 2.
FFI declarations may end the param list with the literal ... token to mark the C function variadic. New LTT_ELLIPSIS = 43 in stdlib/runtime.c; gen_ffi_decl records <fname>__variadic + <fname>__variadic_fixed side-channels; gen_call applies the C default argument promotions (ISO C §6.5.2.2) to every argument beyond the fixed count — float → double via fpext, narrow ints (i1 / i8 / i16, signedness from the binding's __unsigned flag) → i32 via sext / zext. i32 / i64 / double / pointers pass through unchanged. Unlocks direct printf / snprintf / fprintf / scanf from NURL without per-call hand-widening. Closes docs/GOTCHAS.md §9 — every remaining §1-10 entry is now an intentional design choice rather than a real bug. Canonical example:
`nurl & libc @ printf s fmt ... → i32
: i32 a 42 : f32 c # f32 3.5 ( printf i32=%d f32=%g\n a c ) // both args auto-promoted `
Regression: compiler/tests/variadic_ffi.nu (every promotion rule in one exit-0 program). Bootstrap fixed point holds at 1 125 285 B (stage1 ≡ stage2 byte-identical, +11 426 B vs Phase 1B). nurlfmt round-trips ... as a single OP token (added 6b branch in tools/nurlfmt/tokenize.nu). Snapshot: spec/grammar_v1.9.ebnf.
nurlfmt — canonical source formatter. First-class tooling
for deterministic NURL source layout. Written in NURL itself (eats its own dogfood) and built automatically by ./build.sh to build/nurlfmt. Specification lives in docs/FORMAT.md. CLI mirrors gofmt/rustfmt: nurlfmt (stdin→stdout), --stdin (explicit), --check (CI-friendly idempotence gate), --write (in-place), plus multi-file fan-out and the conventional 0/1/2 exit-code semantics.
Architecture: token-stream walker — tools/nurlfmt/tokenize.nu rebuilds a comment-and-newline-preserving token vector from source, tools/nurlfmt/pretty.nu emits the canonical layout by tracking brace depth, top-level decl boundaries, and type- prefix sigil tightness (*Expr, ?i, [T]). No CST is built; NURL's regular prefix grammar lets a token walker do the work that gofmt needs an AST for.
Acceptance: compiler/tests/nurlfmt_idempotent.sh enforces two invariants on every .nu file under stdlib/, examples/, compiler/tests/, tools/nurlfmt/, and compiler/nurlc.nu: fmt(fmt(x)) == fmt(x) (formatter is a fixed point on its own output) AND nurlc(fmt(x)) == nurlc(x) byte-for-byte (the reformat changes zero bytes of emitted LLVM IR). 263 files pass idempotence; 251 are IR-equivalence covered (12 are include fragments that don't compile standalone and are skipped for the IR pass).
v1 deliberate scope: no automatic line wrapping, no cascading-construct extra-indent (a user-written newline inside a ternary cascade gets re-indented to the surrounding block, not bumped by one level — see FORMAT.md §7), no comment reflow, no range formatting.
First post-bootstrap release. The grammar moved from v1.7 → v1.8, adding fixed-size integer and float types. Six long-standing compiler quirks closed; the standard library no longer carries workarounds for them. Bootstrap fixed point holds at 1 113 859 B (stage1 ≡ stage2 byte-identical LLVM IR).
tokens i8, i16, i32, u16, u32, u64, f32 recognised by the lexer. LLVM mappings: i8 / i16 / i32 → iN; u16 / u32 → i16 / i32 with signedness carried in a per-binding side- channel (LLVM IR has no unsigned types); u64 → i64; f32 → float. Cast (#), let-binding store, and function-parameter store all consult the binding's signedness to pick sext (signed) vs zext (unsigned) on widening, trunc on narrowing. Float ↔ double conversions use fpext / fptrunc; mixed integer/float paths use fptosi / sitofp.
gen_binary now picks
udiv / urem / lshr / icmp u* when either operand is declared u16 / u32 / u64 (matching the existing 8-bit u behaviour). Bitwise & / | are sign-agnostic at the LLVM level and previously rejected i8 / i16 operands; that gate was broadened to all integer widths.
CONTRIBUTING.md with contribution guidelines and the byte-identical-IR bootstrap acceptance criterion.
README.md for one-click try-out.compiler/tests/fixed_size_types.nu,
compiler/tests/unsigned_arith.nu, compiler/tests/result_multifield.nu, compiler/tests/result_multifield_try.nu, compiler/tests/option_multifield.nu, compiler/tests/option_multifield_try.nu, compiler/tests/mutable_enum_binding.nu, compiler/tests/multifield_struct_mut.nu, compiler/tests/function_param_mut.nu.
Added). Per-binding __nurl_type + __unsigned side-channels drive cast / store / binop selection. No breaking changes to existing v1.7 programs.
stdlib/ext/http_server.nu server_run rewritten to carry the
failing NetErr variant directly through a : ~ NetErr last_err mutable binding. The previous had_err: b sentinel-flag dance plus cheap-re-issue trick is gone.
docs/GOTCHAS.md rewritten for the v0.2.0 surface: historicalbug-fix entries removed, current quirks and design notes only.
! T E Ok arm. Previously
multi-field T couldn't fit through the i64 payload slot, forcing callers to wrap state in a single-pointer-handle struct or carry a parallel tagged-struct. The compiler now heap-boxes multi-field T transparently at construction (gen_agg_lit), unboxes at ?? match destructure (gen_match), and unboxes at \ try-propagate (gen_try_expr). Single-pointer-handle T continues to use the cheaper ptrtoint path — no allocation.
? T Option Some arm. Option's natural
{ i1, %T } layout already handles multi-field T inline, but the standard @ ? T { F # T 0 } None-payload idiom in vec_get / hashmap_get / iter combinators emitted invalid IR when T's first field was a non-pointer named type (e.g. %String inside Header). gen_cast's i64 → struct branch now returns zeroinitializer for that shape, so the None idiom works uniformly across stdlib.
: ~ NetErr e NetOther and the
symmetric immutable case : NetErr e NetOther no longer produce type-mismatched IR. coerce_store_val wraps i64 → %Enum with an insertvalue before the store, detected via the <name>__variants side-table. Bare-variant reassignment (= last_err NetTimeout) works for narrow and wide enums.
: ~-boundmulti-field struct is captured by a closure, the closure's env block now stores the caller's alloca pointer instead of snapshotting the value. Writes through the closure reach the caller's memory; immutable captures still snapshot. Lifetime caveat: captures are borrows — the closure must not outlive the binding's scope.
= . p field val
on a struct parameter previously emitted invalid IR (empty GEP base). gen_fn_decl_concrete now calls __alloca_struct_params right after the function's entry: label; it backs each multi- field-struct parameter with an alloca + store and registers the pointer as the binding's __ptr. Value semantics are preserved — the function mutates a local copy; callers thread mutation back through the return value.
compiler/tests/.Initial public commit. Self-hosted NURL compiler targeting LLVM, grammar v1.7. Establishes the baseline against which subsequent releases are measured.
compiler/nurlc.nu) with Python bootstrap
(compiler/nurlc.py) and byte-identical-IR fixed-point bootstrap acceptance.
(mingw-w64 + libcurl + Schannel TLS), macOS x86_64 (zig cc + libSystem only), wasm32-wasi.
browser via @bjorn3/browser_wasi_shim.
(phases 1, 2A, 2B, 2C, 2D), user Drop trait, foreach-borrow semantics, scope-exit cleanup.
stdlib/:
* core/: option, result, vec, pair, string, errors, mem, io. * std/: fmt, fs, path, time (Howard Hinnant civil-time algorithms, ISO-8601 + RFC 7231), random, sort, iter, hash, hashmap, set, cmp, encode, channel, thread, signal, process, log, net, bytes, int, float. * ext/: JSON, CSV, regex, UUID v4 + v7 (RFC 9562), env, the full HTTP server stack (http, http_json, http_request, http_response, http_server, http_router, http_static, http_auth, http_middleware, http_multipart, http_proxy, http_full aggregator), Anthropic Claude client (streaming SSE, prompt caching, extended thinking, tool-use loops), MCP client over HTTP and stdio transports.
keep-alive (~38× speedup) + 7 (static / auth / cookies / form) + 8 mostly (access log, Prometheus metrics, idle timeout, graceful shutdown) + 9 partial (multipart/form-data, reverse- proxy streaming pass-through).
compiler/tests/run_tests.sh runner.README.md (project overview),
docs/GOTCHAS.md (compiler quirks), spec/grammar.ebnf (v1.7 grammar).
tooling/vscode-nurl/), Dockerised
compile-server (api/), browser playground (nurlweb/).
[Unreleased]: https://github.com/nurl-lang/nurl/compare/v0.72.0...HEAD [0.72.0]: https://github.com/nurl-lang/nurl/compare/v0.71.0...v0.72.0 [0.71.0]: https://github.com/nurl-lang/nurl/compare/v0.70.0...v0.71.0 [0.70.0]: https://github.com/nurl-lang/nurl/compare/v0.69.1...v0.70.0 [0.69.1]: https://github.com/nurl-lang/nurl/compare/v0.69.0...v0.69.1 [0.69.0]: https://github.com/nurl-lang/nurl/compare/v0.68.0...v0.69.0 [0.68.0]: https://github.com/nurl-lang/nurl/compare/v0.67.0...v0.68.0 [0.67.0]: https://github.com/nurl-lang/nurl/compare/v0.66.0...v0.67.0 [0.66.0]: https://github.com/nurl-lang/nurl/compare/v0.65.0...v0.66.0 [0.65.0]: https://github.com/nurl-lang/nurl/compare/v0.64.0...v0.65.0 [0.64.0]: https://github.com/nurl-lang/nurl/compare/v0.63.0...v0.64.0 [0.63.0]: https://github.com/nurl-lang/nurl/compare/v0.62.0...v0.63.0 [0.62.0]: https://github.com/nurl-lang/nurl/compare/v0.61.0...v0.62.0 [0.61.0]: https://github.com/nurl-lang/nurl/compare/v0.60.0...v0.61.0 [0.60.0]: https://github.com/nurl-lang/nurl/compare/v0.59.0...v0.60.0 [0.59.0]: https://github.com/nurl-lang/nurl/compare/v0.58.0...v0.59.0 [0.58.0]: https://github.com/nurl-lang/nurl/compare/v0.57.0...v0.58.0 [0.57.0]: https://github.com/nurl-lang/nurl/compare/v0.56.0...v0.57.0 [0.56.0]: https://github.com/nurl-lang/nurl/compare/v0.55.0...v0.56.0 [0.55.0]: https://github.com/nurl-lang/nurl/compare/v0.54.0...v0.55.0 [0.54.0]: https://github.com/nurl-lang/nurl/compare/v0.53.0...v0.54.0 [0.53.0]: https://github.com/nurl-lang/nurl/compare/v0.52.0...v0.53.0 [0.52.0]: https://github.com/nurl-lang/nurl/compare/v0.51.0...v0.52.0 [0.51.0]: https://github.com/nurl-lang/nurl/compare/v0.50.0...v0.51.0 [0.50.0]: https://github.com/nurl-lang/nurl/compare/v0.49.0...v0.50.0 [0.49.0]: https://github.com/nurl-lang/nurl/compare/v0.48.0...v0.49.0 [0.48.0]: https://github.com/nurl-lang/nurl/compare/v0.47.0...v0.48.0 [0.47.0]: https://github.com/nurl-lang/nurl/compare/v0.46.0...v0.47.0 [0.46.0]: https://github.com/nurl-lang/nurl/compare/v0.45.0...v0.46.0 [0.45.0]: https://github.com/nurl-lang/nurl/compare/v0.44.2...v0.45.0 [0.44.2]: https://github.com/nurl-lang/nurl/compare/v0.44.1...v0.44.2 [0.44.1]: https://github.com/nurl-lang/nurl/compare/v0.44.0...v0.44.1 [0.44.0]: https://github.com/nurl-lang/nurl/compare/v0.43.0...v0.44.0 [0.43.0]: https://github.com/nurl-lang/nurl/compare/v0.42.0...v0.43.0 [0.42.0]: https://github.com/nurl-lang/nurl/compare/v0.41.0...v0.42.0 [0.41.0]: https://github.com/nurl-lang/nurl/compare/v0.40.0...v0.41.0 [0.40.0]: https://github.com/nurl-lang/nurl/compare/v0.39.0...v0.40.0 [0.39.0]: https://github.com/nurl-lang/nurl/compare/v0.38.0...v0.39.0 [0.38.0]: https://github.com/nurl-lang/nurl/compare/v0.37.1...v0.38.0 [0.37.1]: https://github.com/nurl-lang/nurl/compare/v0.37.0...v0.37.1 [0.37.0]: https://github.com/nurl-lang/nurl/compare/v0.36.0...v0.37.0 [0.36.0]: https://github.com/nurl-lang/nurl/compare/v0.35.1...v0.36.0 [0.35.1]: https://github.com/nurl-lang/nurl/compare/v0.35.0...v0.35.1 [0.35.0]: https://github.com/nurl-lang/nurl/compare/v0.34.0...v0.35.0 [0.34.0]: https://github.com/nurl-lang/nurl/compare/v0.33.0...v0.34.0 [0.33.0]: https://github.com/nurl-lang/nurl/compare/v0.32.0...v0.33.0 [0.32.0]: https://github.com/nurl-lang/nurl/compare/v0.31.1...v0.32.0 [0.31.1]: https://github.com/nurl-lang/nurl/compare/v0.31.0...v0.31.1 [0.31.0]: https://github.com/nurl-lang/nurl/compare/v0.30.0...v0.31.0 [0.30.0]: https://github.com/nurl-lang/nurl/compare/v0.29.0...v0.30.0 [0.29.0]: https://github.com/nurl-lang/nurl/compare/v0.28.0...v0.29.0 [0.28.0]: https://github.com/nurl-lang/nurl/compare/v0.27.0...v0.28.0 [0.27.0]: https://github.com/nurl-lang/nurl/compare/v0.26.0...v0.27.0 [0.26.0]: https://github.com/nurl-lang/nurl/compare/v0.25.1...v0.26.0 [0.25.1]: https://github.com/nurl-lang/nurl/compare/v0.25.0...v0.25.1 [0.25.0]: https://github.com/nurl-lang/nurl/compare/v0.24.1...v0.25.0 [0.24.1]: https://github.com/nurl-lang/nurl/compare/v0.24.0...v0.24.1 [0.24.0]: https://github.com/nurl-lang/nurl/compare/v0.23.0...v0.24.0 [0.23.0]: https://github.com/nurl-lang/nurl/compare/v0.22.0...v0.23.0 [0.22.0]: https://github.com/nurl-lang/nurl/compare/v0.21.0...v0.22.0 [0.13.0]: https://github.com/nurl-lang/nurl/compare/v0.12.0...v0.13.0 [0.12.0]: https://github.com/nurl-lang/nurl/compare/v0.11.3...v0.12.0 [0.10.12]: https://github.com/nurl-lang/nurl/compare/v0.10.11...v0.10.12 [0.10.11]: https://github.com/nurl-lang/nurl/compare/v0.10.10...v0.10.11 [0.10.10]: https://github.com/nurl-lang/nurl/compare/v0.10.9...v0.10.10 [0.10.9]: https://github.com/nurl-lang/nurl/compare/v0.10.8...v0.10.9 [0.10.8]: https://github.com/nurl-lang/nurl/compare/v0.10.7...v0.10.8 [0.10.7]: https://github.com/nurl-lang/nurl/compare/v0.10.6...v0.10.7 [0.10.6]: https://github.com/nurl-lang/nurl/compare/v0.10.5...v0.10.6 [0.10.5]: https://github.com/nurl-lang/nurl/compare/v0.10.4...v0.10.5 [0.10.4]: https://github.com/nurl-lang/nurl/compare/v0.10.3...v0.10.4 [0.10.3]: https://github.com/nurl-lang/nurl/compare/v0.10.2...v0.10.3 [0.10.2]: https://github.com/nurl-lang/nurl/compare/v0.10.1...v0.10.2 [0.10.1]: https://github.com/nurl-lang/nurl/compare/v0.10.0...v0.10.1 [0.10.0]: https://github.com/nurl-lang/nurl/compare/v0.9.19...v0.10.0 [0.9.14]: https://github.com/nurl-lang/nurl/compare/v0.9.13...v0.9.14 [0.9.13]: https://github.com/nurl-lang/nurl/compare/v0.9.12...v0.9.13 [0.9.12]: https://github.com/nurl-lang/nurl/compare/v0.9.11...v0.9.12 [0.9.11]: https://github.com/nurl-lang/nurl/compare/v0.9.10...v0.9.11 [0.9.10]: https://github.com/nurl-lang/nurl/compare/v0.9.9...v0.9.10 [0.9.9]: https://github.com/nurl-lang/nurl/compare/v0.9.8...v0.9.9 [0.9.8]: https://github.com/nurl-lang/nurl/compare/v0.9.7...v0.9.8 [0.9.7]: https://github.com/nurl-lang/nurl/compare/v0.9.6...v0.9.7 [0.9.6]: https://github.com/nurl-lang/nurl/compare/v0.9.5...v0.9.6 [0.9.5]: https://github.com/nurl-lang/nurl/compare/v0.9.4...v0.9.5 [0.9.4]: https://github.com/nurl-lang/nurl/compare/v0.9.3...v0.9.4 [0.9.3]: https://github.com/nurl-lang/nurl/compare/v0.9.2...v0.9.3 [0.9.2]: https://github.com/nurl-lang/nurl/compare/v0.9.1...v0.9.2 [0.9.1]: https://github.com/nurl-lang/nurl/compare/v0.9.0...v0.9.1 [0.9.0]: https://github.com/nurl-lang/nurl/compare/v0.8.1...v0.9.0 [0.8.1]: https://github.com/nurl-lang/nurl/compare/v0.8.0...v0.8.1 [0.8.0]: https://github.com/nurl-lang/nurl/compare/v0.7.3...v0.8.0 [0.2.0]: https://github.com/nurl-lang/nurl/compare/v0.1.0...v0.2.0 [0.1.0]: https://github.com/nurl-lang/nurl/releases/tag/v0.1.0