NURL

Memory and ownership

Single-owner auto-drop, parameter conventions, and the borrow checker.

NURL has no garbage collector, and ordinary values are not reference-counted: shared ownership is opt-in, through Rc and Arc. NURL uses single-owner auto-drop to manage heap memory. The compiler tracks which binding owns each heap allocation and inserts the matching free at the end of that binding's scope. A scope drops its values newest first, as Rust and C++ do, so a value built from a view of another is dropped before what it views. A % Drop destructor that may read such a view counts as a use of it, and the borrow checker rejects a source that would end first (docs/MEMORY.md §2.14).

Ownership

A : binding becomes the owner of a heap resource when its value is a fresh allocation. This includes a slice literal, a slice-returning call, an allocating string call, or a struct literal whose fields are fresh allocations. At the end of that binding's scope, the compiler frees it automatically. A function that returns a fresh allocation transfers ownership to the caller. It does not free the allocation locally.

@ main → i {
    : String s ( string_from `hi` )   // s owns this allocation
    ( puts ( string_data s ) )
    ^ 0
}   // s is freed here, automatically

A raw string in a value is the exception. In safe code, a raw string (s) held by a struct, an option, an enum, a slice or a container is a view that owns nothing, so a fresh string cannot be stored there. Hold a String instead, or bind the string first and store the binding.

Copying an already-owned binding into another binding does not register a second drop. The compiler only tracks drops for allocations it directly sees created. This makes the base model conservative. It never double-frees on its own.

Parameter conventions

A function parameter is one of three conventions, written as a keyword before the type:

ConventionMeaning
(default) / inimmutable borrow by value — the callee gets a copy
inoutexclusive mutable borrow — the callee writes through to the caller
sinkownership transfer — the caller may not use the value afterward

in (default)

The runtime copies a struct-typed parameter into the callee at entry. Writes to it inside the callee do not reach the caller.

inout

@ bump inout Counter c → v { = . c n + . c n 1 }

: ~ Counter c @ Counter { 0 10 }
( bump c )                       // c.n is now 1, in the caller

The argument must be a mutable (: ~) binding, and the callee's writes land on the caller's storage. You must define an inout function before you call it.

sink

@ give_away sink ( Vec i ) g → v {}   // g is dropped when give_away returns

: ( Vec i ) xs ( vec_new [i] )
( give_away xs )     // xs is consumed; using it afterward is a compile error

A sink parameter owns what it is given: the callee drops it, keeps it or passes it on, and the caller's binding is moved. String, Vec, owning structs, library handles and Drop values can all be passed this way. An owned slice, or a struct whose literal gave it one, cannot go to a sink yet; the compiler rejects it rather than risk two owners.

What you still free by hand

In safe NURL, nothing is released by hand. The compiler automatically drops String, Vec, owning structs, collections (HashMap, Set, Deque, BTree, Box, Rc, Arc), recursive enums (Json, TomlValue), closure environments, and every standard-library handle type — including TcpConn, TcpListener, TlsStream, UdpSocket, UnixConn, UnixListener, File, PFile, Channel, Mutex, Cond, Atomic, RwLocked, ProcChild, FsWatch, and Sqlite. Every OS and container handle wraps ( Shared T ) or Arc and closes or releases automatically when its last owner goes out of scope.

Explicit _close and _free helpers (string_free, vec_free, file_close, tcp_close_conn, udp_close, chan_free, etc.) remain only as an optional early release before the end of a scope, and --lint flags any call that sits right where scope-exit drop would happen anyway ([redundant-free]).

Only raw memory inside unsafe functions is managed by hand:

  • raw allocations from nurl_alloc behind a *T pointer inside an unsafe function (release with nurl_free);
  • values explicitly surrendered inside an unsafe function via ( mem_forget x ).

Views are values

A Slice (slice_from_vec, slice_window, slice_of_str, string_bytes, file_map) is a first-class, non-owning view (ptr + len). Views are ordinary values: you can bind them, pass them to functions, store them inside structs, Option, or Vec, and return them from functions whose first parameter owns the backing storage.

The borrow checker tracks the backing owner for every view and prevents mutating, growing, or dropping the owner while any view derived from it is still live:

: ( Vec i ) v ( vec_new [i] )
( vec_push [i] v 10 )
: ( Slice i ) view ( slice_from_vec [i] v )
// ( vec_push [i] v 20 )   // compile error: cannot mutate v while view is live
( nurl_println_int ( slice_get [i] view 0 ) )

The borrow checker

A static analysis pass runs by default. It catches the mistakes the auto-drop layer alone cannot find. These mistakes include using a value after a move, aliasing a value that has a mutable borrow, and a closure capture that would outlive the stack frame it points into. Diagnostics produce hard compile errors. The checker never changes the generated code. A program compiles to the same output whether the checker runs or not. --no-borrowck still turns the pass off, but it is deprecated and warns: a program compiled without the checker is not held to the ownership rules, so nothing guarantees it is memory-safe.

What it checks, briefly:

  • Use-after-move — reading a binding after you consume it (by freeing it, sending it to a sink parameter, or copying it into another owning binding).
  • Alias / double-free — the checker treats an immutable copy of an owned binding as a move, not a second owner.
  • Escaping closures — you cannot return, store, or send a closure to a thread, a fiber or a signal handler if it captures by reference: a : ~ binding it assigns or hands to an inout parameter, or a : ~ multi-field struct.
  • Exclusive access at a call site — a binding you pass inout cannot also have an alias from another plain argument in the same call. The same holds for any container the callee may change, inout or not: it cannot also reach the call through another argument, as the same binding, as a view or borrow of it, or as a closure that changes it.
  • Iterator invalidation — you cannot mutate a foreach loop's collection from inside the loop body.
  • Loop-carried moves — the checker rejects consuming a binding inside a loop body and reading it again on the next iteration. This catches the same problem as a free-inside-a-loop double-free in C.

Soundness boundary and mechanized Lean 4 proof

Together, auto-drop, the borrow checker, and the unsafe / raw boundary (docs/spec.md §3.3d–e) guarantee that every program the compiler accepts outside unsafe functions is memory-safe, leak-free, and data-race-free (docs/MEMORY.md §6.2, §6.5). Every soundness hole ever closed is locked down by a regression probe in tools/fuzz/holes/ (an open one is listed in docs/LIMITATIONS.md), and the ownership, borrow, and concurrency rules have a mechanized Lean 4 soundness proof in formal/ (phases A1–A3: A2.memory_safe, A2.no_leak_end, A3.claim_full; no sorry). Raw *T pointers, pointer casts, raw struct fields (raw :), and foreign (FFI) calls are gated behind unsafe @.

Threads and shared state

Sending a value across a thread boundary follows the same ownership rules:

  • The compiler checks Send / NotSend bounds on everything captured by a thread_spawn closure and rejects non-atomic handles like Rc.
  • Arc (stdlib/std/arc.nu) provides atomic reference counting for shared read-only payloads (arc_get returns an immutable view).
  • Mutable state shared across threads lives in Locked [T] (locked_new, locked_with in stdlib/std/thread.nu), which pairs an atomic reference count with a mutex so concurrent accesses are serialized safely; locked_await waits until a condition on it holds.
  • State many threads read at once lives in RwLocked [T] (stdlib/std/rwlock.nu): any number of read guards, which lend frozen (read-only) views, or one write guard. A single word threads count or bump is an Atomic (stdlib/std/atomic.nu), and a file many threads read and write at offsets is a PFile (stdlib/std/pfile.nu).

Reading NURL's compiler errors

NURL treats compiler diagnostics as the primary place to learn the rules. A rejected program's error names the problem and, in most cases, the fix. Read it before you search elsewhere. See Common pitfalls for the handful of surprises the diagnostics point at most often.

Next

Last updated on

On this page