Skip to main content

Memory Management

Nolang is a GC-free language. Memory safety is guaranteed by compiler-inserted free calls. This document describes the implemented memory design and ownership semantics.

Core Principles​

Single Ownership​

Each heap data buffer has exactly one owner. Ownership can be transferred via move; after transfer, the original owner relinquishes free responsibility. For = between local variables, a deep clone makes both variables independently own their data.

Assignment Semantics​

b = a selects one of the following semantics based on context:

SemanticTriggerBehavior
Value copyPrimitive types (i64/f64/bool, etc.) and can_slot_rebind not satisfiedDirect value copy, no heap data
Stack-slot rebindlocal b = a, a is a stack type (i64/u64/i128/u128/txt) and can_slot_rebind satisfiedg.varAlias[b] = a, b and a share the same stack slot, 0-copy (better than value copy); otherwise degrades to value copy
Deep cloneb = a between locals, a is heap-owning (vec/arr/str/cloneable struct/?str, ?[]T) and still referenced after the assignmentmalloc new data + memcpy + recursively clone elements; a and b independently own data, each freed at function exit
move (dead source)b = a between locals, a is heap-owning but no longer referenced after the assignmentZero-copy transfer of ownership; source skips free. Observably equivalent to a deep clone, minus one copy
moveOutput param out = x, vec.push(x)Shallow copy struct + mark source as moved; source skips free

Value-to-value assignment always deep-clones; zero-copy aliasing is forbidden. b = a (RHS is a whole named variable) has exactly two lowerings — source still live ⇒ deep clone, source dead ⇒ move. It may not become "two names sharing one buffer with a single free" (that lowering once existed in the read-only forward optimization and has been removed). Element / field reads (b = a[i], b = a.f) keep their view-alias semantics and are not affected.

Stack-type move (stack-slot rebind / slot-rebind)​

i64/u64/i128/u128/txt are all stack types (values live directly in the alloca stack slot, no heap data), yet b = a still defaults to value copy (memcpy a's stack value into b's stack slot). To avoid pointless copies, the compiler performs a stack-slot rebind for stack-type b = a that satisfies the can_slot_rebind constraint: set g.varAlias[b] = a so b and a share the same stack slot (0-copy, better than value copy).

can_slot_rebind constraint (conservative correctness)​

After rebind, b and a point to the same stack slot, so any later read of b observes "a's storage". The rebind is therefore only allowed when the source a has no subsequent reference (read or write) at all; otherwise it degrades to a full value copy (semantics unchanged, just one extra memcpy).

  • Safety is solved statically by computeSlotRebindSafety (main) / computeMoveEligibility (user functions) via stmtContainsVarRefAny / exprContainsVarRefAny: scan every statement after the move (including branch / loop back-edges); if the source is referenced in any way, slotRebindSafe[stmt] = false → take the copy path.
  • The reference scan must enumerate every AST node that can reference a variable; an uncovered write reference would cause an unsafe rebind (corrupted alias slot → wrong output or even an infinite loop). Covered: statement-level LetStatement / ExpressionStatement / ForStatement (incl. Init/Update/Condition/CountExpr/Body and the IterRange iteration collection) / ReturnStatement / MultiAssignStatement / UnwrapAssignStatement (?= unwrap) / BlockStatement (bare block); expression-level Identifier / AssignExpression / Infix / Prefix / Call / Dot / Index / IfExpression (arm bodies) / Slice / Conditional / Grouped / AwaitExpression / CastExpression / RangeExpression / RunExpression (coroutine spawn, defensive; whole-function disable still handled by curHasUnsafeConstruct). Any new variable-referencing construct must be added to both scanners.
  • match is desugared into IfExpression; references inside its arm bodies are covered by the analysis above, so match does not trigger a disable (verified against baseline on tests/match.no / tests/option.no).

Disable scenarios (always degrade to value copy)​

Disable conditionReason
Target is output param / global / heap-type variableOutput params are passed by pointer, globals are cross-function visible, heap types need deep free — rebind breaks ownership
Source is a param / globalParams are references to the caller's data; rebind would corrupt the caller's stack frame
stdlib function (curIsStdLib, set via SetStdModules)stdlib internal alias bindings (e.g. fmt's it aliased to local n) are hard for the reference analysis to fully model
User function body contains a closure (FunctionLiteral) or coroutine spawn (RunExpression) (curHasUnsafeConstruct, scanned recursively by bodyHasUnsafeConstruct)A closure evaluates captured variables in a separate function context; a coroutine runs on another thread. Both have variable lifetimes beyond the current function's single-function g.varAlias alias scope, so rebind would corrupt the shared stack slot

Design trade-off: closures/coroutines use a whole-function disable rather than per-variable precision, because single-function alias analysis fundamentally cannot model cross-function / cross-thread storage sharing. A whole-function disable only forfeits the optimization (degrades to copy) and never introduces a bug — consistent with the "conservative correctness" principle.

Alias invalidation​

  • When the target is reassigned, delete(g.varAlias, name) first clears any stale alias, otherwise the generic assignment path varAddr(name) still points at the old source slot and pollutes the old source.
  • The rebind performs transitive resolution along the g.varAlias chain to find the final source slot (a → c → …), avoiding multi-hop alias misalignment; g.varTypes[name] is synced so later type queries stay consistent.

Orthogonality with heap-type move​

Stack-slot rebind only affects the value copy semantics of stack-type b = a; it is fully orthogonal to the deep-clone / move (output param) paths of heap-owning types, which are unaffected by slotRebindSafe.

Test references​

  • tests/slot-rebind.no: i64/u64 rebind, i128/u128 via ==, txt via .len-bytes(), degrade-to-copy (da reused → db/dc copy), reassign-after (ra=10), param-move (pm_fn source is a param → copy), rebind-then-reassign (rr_fn → rr=99), consume (consume(m) → cm=84). Expected output: 42 7 1 1 17 5 5 10 100 99 84.
  • tests/slot-rebind-unsafe.no: coroutine (run/awy) capturing a stack variable, verifying curHasUnsafeConstruct disables rebind and output matches baseline.

Compiler-Inserted Free​

  • Function exit: free all non-moved local heap variables
  • Before reassignment: free the old value
  • Struct fields: recursively free fields containing heap data

Type Layout​

Nolang TypeMemory LayoutFieldsAllocation
[]T (slice)24 byteslen, cap, datamalloc (heap)
[N]T (fixed array)16 byteslen, dataalloca (stack) or malloc
str (long string)24 byteslen, cap, datamalloc (heap)
structsum of fieldseach fieldalloca (stack)

Shallow Free vs Deep Free​

Shallow Free​

Only frees the container's data buffer without iterating elements. Applies to:

  • %str-long (string data is a character buffer, no nested heap-owning elements)
  • vec/arr whose elements are primitive types (i64, double, etc.)

Deep Free​

Iterates each element to recursively free its heap data, then frees the container's data buffer. Applies to vec/arr whose elements are heap-owning types:

  • []str (elements are %str-long)
  • [][]i64 (elements are %vec)
  • []MyType (elements are user structs; recursively free fields)

NULL Check​

All frees are preceded by icmp eq i8* %ptr, null to avoid free(NULL) or freeing uninitialized pointers.

Ownership Transfer (move)​

Single Return Value move​

get-slice = () (out []i64) {
local = [1, 2, 3]
out = local ; local marked as moved, not freed at function exit; out managed by caller
}

v = get-slice() ; v owns data, freed at function exit

Multi-Return Value move (by parameter position order)​

get-pair = () (a []i64, b []i64) {
x = [1, 2]
y = [3, 4]
a = x ; first output param, x marked as moved
b = y ; second output param, y marked as moved
}

a, b = get-pair() ; a owns x's data, b owns y's data

Processing order: Output parameters are processed in their declaration order in the function signature. Each out = src assignment independently marks the source variable as moved.

Note: If a and b reference the same source variable (e.g., a = x; b = x), within the callee only one move occurs (x marked moved); both a and b receive a shallow copy of x (sharing the same data pointer). But in the caller, a and b are independent local variables, each tracked as a heap variable, and both will be freed at function exit → double-free. Nolang currently has no reference/borrow semantics; b does not automatically become an alias of a. Avoid this pattern.

Deep clone in vec.push​

inner = [1, 2, 3]
outer.push(inner)
; inner's data is deep-cloned into outer's new element slot
; inner still owns its independent data; at function exit inner and outer each free separately

For heap-owning element types (%str-long/%vec/%arr/user structs), push performs a deep clone: malloc a new data buffer + memcpy + recursively clone the elements. The source variable and the outer vec each own independent data, so no move marking is needed, which avoids double-free. For primitive element types (i64/f64 etc.) the value is stored directly.

Runtime move tracking (bitmap indexed by heap-variable slot)​

Move under conditional branches poses a challenge: the compiler cannot statically determine whether a move actually occurs.

cond-move = (flag i64) (out []i64) {
x = [1, 2, 3]
if flag == 1 {
out = x ; move only happens when flag==1
}
; when flag==0, x still owns data and must be freed at function exit
; when flag==1, x's ownership has been transferred and must skip free
}

Nolang solves this with a bitmap indexed by heap-variable slot. At compile time each local heap variable is assigned a unique varIdx, and each bit of the runtime bitmap corresponds to one heap variable (not an output parameter).

Compiler state​

FieldTypePurpose
heapVarIndexmap[string]intheap-variable name -> varIdx (local heap variables only)
outBindState[]intthe heap-variable slot currently bound to each output parameter (-1 = unbound, -2 = indeterminate)
movedVarBitset[]uint64compile-time moved bitmap (used when there is no runtime bitmap)
movedBitmapBasestringruntime bitmap variable-name prefix (e.g. %__mb, empty = not allocated)
bitmapCountintnumber of u64 bitmap blocks (= maxVarIdx/64 + 1)

Index mapping rule​

One u64 block holds 64 marker bits; for a heap-variable slot varIdx:

  • block number = varIdx / 64
  • offset within the block = varIdx % 64
  • mask = 1u64 << offset

These are computed as constants at compile time, so there is no runtime computation cost. Multiple u64 blocks support an arbitrary number of heap variables, with no parameter/result count limit.

Move-assignment handling (overwrite clears the old bit)​

Each time a heap variable is moved into an output parameter:

  1. If that output parameter was previously bound to a different variable (outBindState[outIdx] >= 0), clear the bit of the old variable first
  2. Then set the bit corresponding to the current variable to 1
  3. Update the variable slot bound to that output parameter (outBindState[outIdx] = srcVarIdx)

Free at function end​

Walk all heap variables, laying out independent if checks: free when the corresponding bit is 0; a bit of 1 means ownership was moved away, so skip the free.

On-demand bitmap allocation​

The bitmap variable is allocated only when necessary, avoiding overhead in branch-free code:

ScenarioBitmap allocationFree behavior
No movenonefree all
move not in a branch (deterministic move)nonecompile-time movedVarBitset skips the free directly
move in a branch (conditional move)allocateruntime bitmap check: bit=1 skip, bit=0 free

Before generating the function body, the compiler pre-scans the AST (detectBranchMoveToOut) to detect whether a move assignment to an output parameter exists inside an IfExpression/ForStatement/ConditionalExpression branch, and allocates the runtime bitmap variable only when such a pattern is present. The bitmap alloca is inserted after the function body is generated (at which point nextHeapVarIdx has its final value) and written into the entry block.

This mechanism applies to all heap types (vec/str-long/arr/user structs).

Deep Clone (Assignment Between Locals)​

a []i64 = [10, 20, 30]
b = a ; deep clone: malloc new data + memcpy + recursively clone elements
b[0] = 99
; a[0] == 10 (a unaffected)
; b[0] == 99 (b modified independently)

Deep Clone Flow​

  1. Free the target variable's old value (if it already has heap data)
  2. malloc a new data buffer, memcpy source data to new data
  3. Recursively clone each heap-owning element:
    • %str-long element: malloc + memcpy string data
    • User struct element: memcpy struct + recursively clone heap-owning fields
  4. Write new data pointer, len, cap into the target variable
  5. Track target as a heap variable (freed at function exit)

Cloneable Types​

TypeDeep cloneableNotes
%vec / %arr (primitive elements)Yesmemcpy data suffices
%vec / %arr (elements are %str-long)Yesper-element malloc+memcpy of string data
%vec / %arr (elements are cloneable structs)Yesper-element recursive clone of struct fields
%vec / %arr (elements are %vec / %arr)Yesrecursively clone the inner container's elements via the elemElemType mechanism
%str-longYesmalloc + memcpy string data
User struct (no nested container fields)Yesmemcpy struct + recursive clone of heap fields
User struct (with nested container fields)Yesmemcpy struct + recursively clone fields holding nested containers (via elemElemType)
?strYesinline-payload option: clone the option and its string data
?[]TYesinline-payload option: clone the option and the inner slice's data (per-element clone; recurses when the element owns heap, e.g. ?[]str)

?str / ?[]T are inline-payload but heap-owning. Their 24-byte payload lives directly in the option slot, but the str data / slice backing store inside it is heap-allocated ⇒ the clone must deep-copy the payload, and the free must release it, and the two sides must stay paired. Freeing ?[]T goes through the same deep-free helper as []T (recursing by element type), so "deep clone pairs with deep free" holds here too. ⚠️ An ordinary (non-?str/?[]T) option's payload is a borrow or a plain value and does not own heap; clone and free leave it alone.

Difference from move​

  • Deep clone: source and target each independently own data; each freed at function exit
  • move: source relinquishes ownership (marked moved), target takes over data, source skips free

Decision rules for b = a:

  1. If a is the source of an output param → move
  2. If a is the source of vec.push → move
  3. Otherwise, if a is a heap-owning type but no longer referenced after the assignment → move (zero-copy, observably equivalent to a deep clone)
  4. Otherwise, if a is a heap-owning type and deep-cloneable → deep clone
  5. Otherwise value copy

Stack-type move (stack-slot rebind / slot-rebind)​

i64/u64/i128/u128/txt are stack types (non-heap-owning). When b = a (RHS is a variable) and the source a is not referenced afterward, instead of a value copy the compiler performs a stack-slot rebind: g.varAlias[b] = a makes b and a share the same stack slot (0-copy). This beats a value copy (which still emits load+store), and is especially beneficial for large stack types such as the 256-byte %txt.

can_slot_rebind constraint (conservative correctness)​

Stack-slot rebind is stricter than the heap-move moveEligible:

  • moveEligible: source a not read afterward → move allowed (heap types; after move the source skips free, target unaffected).
  • can_slot_rebind (slotRebindSafe): source a has no subsequent reference (read or write) → rebind allowed. After rebind, b and a share the same slot, so a later write to a would corrupt b's value.

Computation: generateFunctionDefinition calls computeMoveEligibility (fills both moveEligible and slotRebindSafe); generateMainFunction calls computeSlotRebindSafety (only fills slotRebindSafe, never touches moveEligible — HEAD's main does not enable heap moves, kept disabled to avoid SEGFAULT). Both scan via stmtContainsVarRefAny (branch/loop-aware, including AssignExpression LHS and loop back-edges).

When can_slot_rebind is not satisfied (source referenced later) → degrade to a full value copy; never unsafe.

Disabled scenarios (must use the normal assignment path)​

ScenarioReason
stdlib function (curIsStdLib)stdlib contains match/closure/coroutine constructs the reference analysis cannot fully model; rebinding would corrupt shared stack slots (e.g. fmt's internal it aliased to a local n). Detected via g.stdModules (SetStdModules from checker.KnownStdModules() in transpiler.go), falling back to g.funcOwner[fd.Name]
target is an output paramoutput param is a caller-passed pointer; rebinding would point it at the local source slot, caller can't read it
target is a global varrebinding makes the global name resolve to a local source slot, breaking global semantics
target is a heap-type varstack-slot rebind only applies to stack types
source is a paramparams are references to the caller's data; rebinding corrupts the caller's stack frame
source is a global varglobal address aliased to a local source, semantically wrong

Alias invalidation on reassignment​

When a variable is reassigned (b = ...), delete(g.varAlias, name) clears any stale alias first, so the generic assignment path does not write through the old alias into the source's slot via varAddr(name). When setting a new alias, follow the varAlias chain for transitive resolution (src := ident.Value; for { if n2,ok := g.varAlias[src]; ok { src = n2 } else break }) to the ultimate source slot, avoiding multi-hop misalignment.

Orthogonality with heap move​

Stack-slot rebind only affects stack types; it does not involve ownership transfer or free, and is fully independent of the heap clone/move machinery.

Test: tests/slot-rebind.no (covers i64/u64/i128/u128/txt rebind, degrade-to-copy, alias invalidation after target reassignment, param-move degrade, consume passthrough; expected output 42 7 1 1 17 5 5 10 100 99 84).

FFI extern str Return Values​

FFI extern functions (marked with #{c}) return C string pointers (i8*) that may point to static memory (e.g. getenv, strerror) or external buffers (e.g. strchr returns a pointer into its argument). Wrapping them directly into %str-long would cause emitHeapFree to free() non-heap memory → UB.

The compiler inserts a safe copy on the FFI extern str return path:

  1. NULL check: if C returns NULL, construct a nil %str-long (data=0), making s == nil true
  2. Non-NULL: strlen + malloc + memcpy + null-terminate, copying into an independent heap buffer
  3. PHI merge: merge both paths and construct the %str-long return value
#{c}
strchr = (s str, c i64) (r str)

find = () (r str) {
r = strchr('hello', 108) ; C returns a pointer into 'hello'
; compiler auto malloc+memcpy copies, r independently owns data
; emitHeapFree safely frees r.data at function exit
}

This mechanism is consistent with the clib RetCStrToStr path (used by built-in functions like get-env, get-wd), ensuring all C string return values have independent ownership.

Module-Level Variable Free​

Module-level heap variables (vec/str/arr/structs) are compiled as LLVM globals (@name); their data buffers are malloc-initialized by top-level statements in the main entry.

The compiler calls the following before ret i32 0 in the C entry main:

  1. emitHeapFree — frees top-level local heap variables (not in globalVars)
  2. emitGlobalHeapFree — iterates moduleVarTypes, frees all heap-owning types in globalVars
GLOBAL-STR = 'hello' ; LLVM @GLOBAL-STR = global %str-long zeroinitializer
GLOBAL-VEC = [1, 2, 3] ; LLVM @GLOBAL-VEC = global %vec zeroinitializer
; top-level statements malloc data and store into global
; emitGlobalHeapFree frees data before main ret

This prevents memory accumulation leaks in long-running services (e.g. daemons with loops). No impact on one-shot CLI tools (process exit reclaims via OS).

Slice Views​

A slice expression arr[1..3] produces a view (zero-copy) that shares the original array's data. Three fates of a view:

TargetBehaviorOwnership
Local var v = arr[1..3]zero-copy viewshares original data
Output param out = arr[1..3]clone (malloc+memcpy)independent
Explicit []T type v []i64 = arr[1..3]cloneindependent

Reason: Output params escape to the caller; the original array may be freed before the function exits, so the view must clone to independent data.

Reassignment and Old Value Free​

s = 'hello' ; malloc data buffer
s = 'world' ; free 'hello's data, malloc new data

When reassigning a heap-owning type, the compiler automatically frees the old value's data before the assignment to prevent leaks.

Struct Field Free​

Node {
name str
items []i64
}

n = Node{
name: 'hello'
items: [1, 2, 3]
}
; At function exit, recursively free:
; - n.name.data (%str-long field)
; - n.items.data (%vec field)

When freeing a struct, all fields are traversed; heap-owning type fields are recursively freed.

Fixed Array Reassigned to Slice​

local [4]i64 = [100, 200, 300, 400] ; local is fixed array (16 bytes)
local = [100, 200, 300] ; reassigned as slice (24 bytes)

Fixed arrays (%arr, 2 fields) and slices (%vec, 3 fields) have different memory layouts. On reassignment the compiler automatically allocates a new %vec variable and redirects all subsequent accesses to avoid buffer overflow.

Verified Test Cases​

Tests are in tests/mem-safety/:

TestVerifies
deep-clone.nob = a deep clone ([]i64/[]str/str/struct) independence
deep-free-str.no[]str deep free
deep-free-nested-vec.no[][]i64 deep free + push moved
deep-free-struct-vec.no[]MyType deep free (recursive struct)
struct-field-leak.nostruct field heap data free
slice-view-escape.noslice view assigned to output param clone
reassign-leak.noreassignment old value free
vec-push-leak.novec.push moved marking
ffi-str-return.noFFI extern str return value safe copy
global-heap-free.nomodule-level heap variables freed at main exit
double-move-same-source.noa=x; b=x from one source: first clone + last move
move-clone-liveness.noLiveness pre-analysis decides clone/move (incl. conditional branches, triple assignment)
rule1-binding.noRule 1: b = a write independence for str/[]i64/struct/?str/?[]i64, read-only aliasing, dead-source move
nested-container-clone.nodeep clone / deep free duality for nested containers ([][]str, vec inside struct, map value being a vec — 12 cases)

Known Limitations​

map Container​

hashmap does not implement deep free of key/value; map container heap data leaks.

Loop Temporary Variables​

loop {
s = 'temp' ; each iteration mallocs new data, old data not freed
}

Slice View + Original Array move​

view = arr[1..3] ; view shares arr.data
arr = [9, 8, 7] ; free old arr.data → view dangling

async Shared Data (fixed 2026-09-30)​

The run/awy spawn boundary now deep-clones heap-owning arguments; tasks no longer share buffers with the caller.

Coroutines are cooperative, single-threaded, so "sharing mutable heap data across coroutines" does not hold at the language level; the only thing that was shared was the caller's copy that it still holds after spawn. Before the fix, argbuf was merely a bit-copy of the argument descriptor; data was shared with the caller, and the task ran only when awy was called — if the caller reassigned or left scope before that point, the buffer was freed, and the task read freed memory (observed: str printed 40 NUL bytes, []i64 printed 0; both with rc=0 and no diagnostic).

Spawn now deep-clones at the call site; the wrapper frees the copy after the callee returns:

Parameter typeSpawn boundaryWrapper free
str@str_clone@str_free
[]T (T has deep-clone helper)vecDeepClone@vec_free
Heap-owning struct (inlined str leaves / pointer fields)in-place leaf clone + pointee copyrecursive struct destructor
Other (pure value types)bit-copy (correct)free container only

Two additional boundary bugs were fixed: awaiting the same handle twice (was SIGSEGV, now a defined no-op with a diagnostic to fd 2), and cancelled tasks no longer leak argbuf.

Regression: tests/async-ownership.no.

struct Reassignment Leak (fixed 2026-09-30)​

holder { s str }
h holder = holder { s: 'first' }
h = holder { s: 'second' } ; the old 'first' buffer used to leak

When rebinding a local variable, the old value is freed only if its type is Type.Owned (str/vec/[]T/map/?owned). A struct is deliberately not classified as Type.Owned (that tag doubles as the lowerer's criterion for "is this binding an alias"), so h = holder { … } overwrites the whole struct without ever freeing the old s buffer. The drop machinery actually does know about structs (dropOwnsHeap → typeOwnsHeap, and emitDrop uses the recursive destructor to free fields); only the lowerer's gate was too narrow. After the fix, old fields are freed before rebinding.

Measured with 2,000,000 reassignments: peak RSS dropped from 66.1 MB to 33.8 MB; output is byte-for-byte identical.

struct Literal First Field Value Is a Method Call (fixed 2026-09-30)​

holder { s str }
h = holder { s: n.to-str() } ; before fix: 'holder' is not defined

The classification of { … } depends on a few-token lookahead. A match arm's separator is also :, so s: n.to-str() looks like both "a match arm with pattern s" and "a struct literal field". The only disambiguator is the token after ident.ident — a method call can only be a field value. The old branch missed (, so the whole block was classified as match, and the struct name was not found in match context (a plain function call as field value worked fine).

After the fix h = holder { s: recv.method(args) } parses correctly. Nolang match arms cannot start with name : X( (the arm separator is ->; arms starting with : are wildcard/default and their body won't have a parenthesized call), so allowing ( does not misclassify any legitimate match as a struct literal. Regression: src/parser/struct_literal_field_value_test.go.

Await Result Type Reassigned Across Handle (fixed 2026-09-30)​

holder { s str }
echo-holder-async = (h holder) (r str) { r = h.s }
t i64 = 0
t = run echo-holder-async(h) ; reassignment, not a new binding
print(awy t) ; before fix: printed 40 (the str length), rc=0

A task's result type is recorded in a table keyed by MIR value id. awy <handle-var> resolves the handle through the local variable's current value id, which is the new slot where the OpRun result was moved, not the OpRun result id itself — the reassignment path did not propagate the type, so await was treated as untyped i64, and the str result's buffer was reinterpreted, printing the length.

The fix required three coordinated changes; missing any one leaves a symptom:

  1. The lowering stage moves the result type to the target slot on both rebind paths (local variable, module-level global); when the source is not a handle, clear the target slot's record to prevent a later awy from picking up a stale type from a previous task.
  2. The checker no longer returns i64 for all *AwaitExpression: awy <call> returns the callee's return type; awy <handle-var> returns "unknown" and skips type checking (the handle is an opaque i64; statically the type cannot be recovered). The old behavior rejected the perfectly valid v str = awy t.
  3. The transpiler's string check treats *AwaitExpression as potentially a string, otherwise the valid v str = awy t would be caught by "cannot assign non-string value to string variable".

Regression: tests/async-ownership.no test 9 / test 10.

Coroutine Waiter Table Alias (fixed 2026-09-30)​

The scheduler originally used @nolang_waiters = global [256 x i8*], indexed by ptrtoint(task) & 255. Tasks are allocated by @malloc with 16-byte alignment; the low bits of the pointer have only 4 useful bits — 256 slots collapsed to effectively 16 (measured: only 8 were ever used), making it easy for two concurrently awaited tasks to overwrite each other's waiter and wake the wrong task.

Now the entire table is removed; waiters are stored in %task's own 5th field (%task grew from 24 to 32 bytes): @nolang_async_wait writes the current task into the waited-upon task's field; @nolang_async_done reads that field, enqueues the waiter if non-null, then clears it. Lookup is now precise; no address aliasing.

Note: the MIR backend currently drives await synchronously and never calls @nolang_async_run/@nolang_async_wait, so this was a latent defect, not an observable fault. Regression: src/mir/async_boundary_ownership_test.go.

run / awy are swallowed when they are not the first statement of a block (fixed 2026-09-30)​

skipToStatementEnd() advances until it reaches a token that can begin the next statement, and that whitelist — isStatementBoundary() (src/parser/stmt.go) — was missing run and awy. Both are prefix keywords (they can only open an expression; parseStatement has no case for them, so they fall through to parseExpressionStatement) and can therefore legitimately begin a statement. NEWLINE is deliberately not a boundary, so when they are not the first statement of a block (the first one never goes through skipToStatementEnd) the keyword is skipped over together with its newline. Both consequences are rc=0 with no diagnostic:

SourceBefore the fixAfter the fix
awy tdegraded to a bare t, so an if-chain arm's value became the task handle (a heap pointer), printed as a garbage integer (e.g. 4367685392)correctly unwraps the task result
run dbl(21)degraded to a synchronous dbl(21) — compiles, prints, but no longer spawnsactually spawns

The second one is especially dangerous: it is a semantic degradation, not a crash, so any check of the form "did it run without error?" cannot see it.

The fix adds lexer.RUN, lexer.AWY to isStatementBoundary(). The rule: every token that parseStatement's switch can dispatch on must also be listed in isStatementBoundary(); omitting one produces a silent bug that only appears when the statement is not the first in its block. Regression: tests/stmt-boundary.no, src/parser/stmt_boundary_block_test.go.

A bare { ... } at statement position was treated as a declaration (fixed 2026-09-30)​

parseStatement's LBRACE branch calls classifyBlockAtCurrent() (by which point currentToken is already { and no name can be consumed). The blockEnum / blockIface / blockTaggedEnum answers it can return are meaningless at statement position — real enum / interface / tagged-enum declarations all require a preceding name and are dispatched earlier via classifyBlock(). But the statement dispatch sent only blockUnknown to the statement-block path, so:

  • { x = 2 } classified as blockEnum and { print(2) } as blockIface;
  • both reached parseExpressionStatement, whose LBRACE case in expr.go accepts only blockStruct / blockMatch and otherwise does p.nextToken(); return nil;
  • the { was eaten, the block's inner statements became siblings of the enclosing body, and the block's own } closed the enclosing block.

The result is not a swallow but a one-level statement leak: f = () { print(1); { print(2) }; print(3) } printed 3,1,2, and f = () { x = 1; { x = 2 }; print(x) } printed nothing. Again rc=0, no diagnostic.

The fix routes blockEnum / blockIface / blockTaggedEnum to the statement block as well (blockStruct must stay on the expression path — { field: value } is a legitimate anonymous struct-literal expression statement). ⚠️ Do not "fix" this by changing classifyBlockAtCurrent's ASSIGN branch: that covers only one of the three misclassifications and moves the decision away from the one place that knows "I am at statement position".

⚠️ Knock-on effect: this fix made a statement-position { ... } survive as a real *parser.BlockStatement for the first time, and the whitelist of programUsesPrint (src/build/transpiler.go, the hand-written AST walker that decides whether to load fmt/io/str/byte) had no BlockStatement case — so a program whose only print sits inside a bare block stopped being detected. After any statement-layer change, re-run go test ./build/ -run TestProgramUsesPrint and add the new container type to walkStmt. Regression: src/parser/stmt_boundary_block_test.go, tests/stmt-boundary.no.

Imprecise free-skip heuristic for a global variable's first assignment​

The compiler uses a compile-time map globalFirstAssigned to track whether a global variable has already had its first assignment: the first assignment skips freeing the old value (the old value is zeroinitializer, not heap data), and only later reassignments free the previous heap value.

This map is initialized once for the whole compilation, is never reset per function, and does not distinguish conditional branch paths. If a global variable's first assignment happens inside a conditional branch, the compiler processes it in AST order: the first assignment statement is treated as the "first" one (skipping free), while a second assignment statement (even in a different branch) takes the reassignment path (attempting to free the old value). If at runtime the second branch executes first, the global is still zeroinitializer (data=NULL, len=0) and it attempts to free an uninitialized old value.

Current mitigations (effective):

  • Shallow containers (%str-long): emitNullCheckFree emits a runtime icmp eq i8* dataPtr, null check, skipping call @free when NULL
  • Deep containers (%vec/%arr): emitDeepContainerFree additionally has a len == 0 short-circuit check; zeroinitializer has len 0, so the whole free loop is skipped directly

These two layers of runtime protection mean that, even though the compile-time judgment is imprecise, no actual crash occurs. But logically this relies on runtime NULL checks as a safety net rather than precise compile-time judgment.

Possible improvement: turn globalFirstAssigned from a compile-time map into a runtime tracking mechanism (a bitmap like movedVarBitset), but this would add runtime overhead, and the current mitigations are already sufficient.

Design Trade-offs​

  1. The cost of a deep clone. b = a allocates and copies the data; vec.push(x) likewise deep-clones a heap-owning element. This is deliberate: one copy buys back an entire class of alias analysis — value-to-value keeps only "own" and "transfer", never "share", so "who owns, who frees, whether a write is observable" no longer needs a separate set of rules per aliasing shape.
  2. Deep copy at the spawn boundary (= a snapshot). run f(x) deep-copies heap-owning arguments at spawn time, so the task sees the value as of spawn, independent of anything the caller writes afterwards. When the source is already dead this becomes a zero-copy move. ⚠️ Do not replace this copy with "share one buffer + reference count" just to save it: that would let the task observe the caller's in-place writes between spawn and awy (sharing), violating point 4. The correct way to save the copy is copy-on-write.
  3. Reference cycles leak. The only reference-counted object in the language is the coroutine task handle (%task). Reference counting cannot reclaim cycles — if a set of tasks hold each other's handles, none of them is ever freed. Wait explicitly with awy, or avoid letting tasks hold each other. This is the inherent cost of deterministic memory management (the same as Rust's Rc).
  4. Coroutines do not share mutable heap data. Coroutines are scheduled cooperatively on a single thread, so sharing mutable heap data across coroutines does not exist at the language level; allowing it would only produce data races and dangling pointers. The spawn boundary therefore always passes an independent copy (or transfers ownership).