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:
| Semantic | Trigger | Behavior |
|---|---|---|
| Value copy | Primitive types (i64/f64/bool, etc.) and can_slot_rebind not satisfied | Direct value copy, no heap data |
| Stack-slot rebind | local b = a, a is a stack type (i64/u64/i128/u128/txt) and can_slot_rebind satisfied | g.varAlias[b] = a, b and a share the same stack slot, 0-copy (better than value copy); otherwise degrades to value copy |
| Deep clone | b = a between locals, a is heap-owning (vec/arr/str/cloneable struct/?str, ?[]T) and still referenced after the assignment | malloc 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 assignment | Zero-copy transfer of ownership; source skips free. Observably equivalent to a deep clone, minus one copy |
| move | Output 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) viastmtContainsVarRefAny/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/Bodyand theIterRangeiteration collection) /ReturnStatement/MultiAssignStatement/UnwrapAssignStatement(?=unwrap) /BlockStatement(bare block); expression-levelIdentifier/AssignExpression/Infix/Prefix/Call/Dot/Index/IfExpression(arm bodies) /Slice/Conditional/Grouped/AwaitExpression/CastExpression/RangeExpression/RunExpression(coroutine spawn, defensive; whole-function disable still handled bycurHasUnsafeConstruct). Any new variable-referencing construct must be added to both scanners. matchis desugared intoIfExpression; references inside its arm bodies are covered by the analysis above, so match does not trigger a disable (verified against baseline ontests/match.no/tests/option.no).
Disable scenarios (always degrade to value copy)
| Disable condition | Reason |
|---|---|
| Target is output param / global / heap-type variable | Output params are passed by pointer, globals are cross-function visible, heap types need deep free — rebind breaks ownership |
| Source is a param / global | Params 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 pathvarAddr(name)still points at the old source slot and pollutes the old source. - The rebind performs transitive resolution along the
g.varAliaschain 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 (dareused →db/dccopy), reassign-after (ra=10), param-move (pm_fnsource 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, verifyingcurHasUnsafeConstructdisables 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 Type | Memory Layout | Fields | Allocation |
|---|---|---|---|
[]T (slice) | 24 bytes | len, cap, data | malloc (heap) |
[N]T (fixed array) | 16 bytes | len, data | alloca (stack) or malloc |
str (long string) | 24 bytes | len, cap, data | malloc (heap) |
| struct | sum of fields | each field | alloca (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
| Field | Type | Purpose |
|---|---|---|
heapVarIndex | map[string]int | heap-variable name -> varIdx (local heap variables only) |
outBindState | []int | the heap-variable slot currently bound to each output parameter (-1 = unbound, -2 = indeterminate) |
movedVarBitset | []uint64 | compile-time moved bitmap (used when there is no runtime bitmap) |
movedBitmapBase | string | runtime bitmap variable-name prefix (e.g. %__mb, empty = not allocated) |
bitmapCount | int | number 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:
- If that output parameter was previously bound to a different variable (
outBindState[outIdx] >= 0), clear the bit of the old variable first - Then set the bit corresponding to the current variable to 1
- 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:
| Scenario | Bitmap allocation | Free behavior |
|---|---|---|
| No move | none | free all |
| move not in a branch (deterministic move) | none | compile-time movedVarBitset skips the free directly |
| move in a branch (conditional move) | allocate | runtime 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
- Free the target variable's old value (if it already has heap data)
malloca new data buffer,memcpysource data to new data- Recursively clone each heap-owning element:
%str-longelement: malloc + memcpy string data- User struct element: memcpy struct + recursively clone heap-owning fields
- Write new data pointer, len, cap into the target variable
- Track target as a heap variable (freed at function exit)
Cloneable Types
| Type | Deep cloneable | Notes |
|---|---|---|
%vec / %arr (primitive elements) | Yes | memcpy data suffices |
%vec / %arr (elements are %str-long) | Yes | per-element malloc+memcpy of string data |
%vec / %arr (elements are cloneable structs) | Yes | per-element recursive clone of struct fields |
%vec / %arr (elements are %vec / %arr) | Yes | recursively clone the inner container's elements via the elemElemType mechanism |
%str-long | Yes | malloc + memcpy string data |
| User struct (no nested container fields) | Yes | memcpy struct + recursive clone of heap fields |
| User struct (with nested container fields) | Yes | memcpy struct + recursively clone fields holding nested containers (via elemElemType) |
?str | Yes | inline-payload option: clone the option and its string data |
?[]T | Yes | inline-payload option: clone the option and the inner slice's data (per-element clone; recurses when the element owns heap, e.g. ?[]str) |
?str/?[]Tare inline-payload but heap-owning. Their 24-byte payload lives directly in the option slot, but thestrdata / 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?[]Tgoes 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:
- If a is the source of an output param → move
- If a is the source of vec.push → move
- Otherwise, if a is a heap-owning type but no longer referenced after the assignment → move (zero-copy, observably equivalent to a deep clone)
- Otherwise, if a is a heap-owning type and deep-cloneable → deep clone
- 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: sourceanot read afterward → move allowed (heap types; after move the source skips free, target unaffected).can_slot_rebind(slotRebindSafe): sourceahas no subsequent reference (read or write) → rebind allowed. After rebind,bandashare the same slot, so a later write toawould corruptb'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)
| Scenario | Reason |
|---|---|
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 param | output param is a caller-passed pointer; rebinding would point it at the local source slot, caller can't read it |
| target is a global var | rebinding makes the global name resolve to a local source slot, breaking global semantics |
| target is a heap-type var | stack-slot rebind only applies to stack types |
| source is a param | params are references to the caller's data; rebinding corrupts the caller's stack frame |
| source is a global var | global 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:
- NULL check: if C returns NULL, construct a nil
%str-long(data=0), makings == niltrue - Non-NULL:
strlen+malloc+memcpy+ null-terminate, copying into an independent heap buffer - PHI merge: merge both paths and construct the
%str-longreturn 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:
emitHeapFree— frees top-level local heap variables (not in globalVars)emitGlobalHeapFree— iteratesmoduleVarTypes, frees all heap-owning types inglobalVars
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:
| Target | Behavior | Ownership |
|---|---|---|
Local var v = arr[1..3] | zero-copy view | shares original data |
Output param out = arr[1..3] | clone (malloc+memcpy) | independent |
Explicit []T type v []i64 = arr[1..3] | clone | independent |
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/:
| Test | Verifies |
|---|---|
deep-clone.no | b = 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.no | struct field heap data free |
slice-view-escape.no | slice view assigned to output param clone |
reassign-leak.no | reassignment old value free |
vec-push-leak.no | vec.push moved marking |
ffi-str-return.no | FFI extern str return value safe copy |
global-heap-free.no | module-level heap variables freed at main exit |
double-move-same-source.no | a=x; b=x from one source: first clone + last move |
move-clone-liveness.no | Liveness pre-analysis decides clone/move (incl. conditional branches, triple assignment) |
rule1-binding.no | Rule 1: b = a write independence for str/[]i64/struct/?str/?[]i64, read-only aliasing, dead-source move |
nested-container-clone.no | deep 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 type | Spawn boundary | Wrapper 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 copy | recursive 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:
- 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
awyfrom picking up a stale type from a previous task. - The checker no longer returns
i64for all*AwaitExpression:awy <call>returns the callee's return type;awy <handle-var>returns "unknown" and skips type checking (the handle is an opaquei64; statically the type cannot be recovered). The old behavior rejected the perfectly validv str = awy t. - The transpiler's string check treats
*AwaitExpressionas potentially a string, otherwise the validv str = awy twould 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:
| Source | Before the fix | After the fix |
|---|---|---|
awy t | degraded 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 spawns | actually 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 asblockEnumand{ print(2) }asblockIface;- both reached
parseExpressionStatement, whoseLBRACEcase inexpr.goaccepts onlyblockStruct/blockMatchand otherwise doesp.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):emitNullCheckFreeemits a runtimeicmp eq i8* dataPtr, nullcheck, skippingcall @freewhen NULL - Deep containers (
%vec/%arr):emitDeepContainerFreeadditionally has alen == 0short-circuit check;zeroinitializerhas 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
- The cost of a deep clone.
b = aallocates 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. - 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 andawy(sharing), violating point 4. The correct way to save the copy is copy-on-write. - 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 withawy, or avoid letting tasks hold each other. This is the inherent cost of deterministic memory management (the same as Rust'sRc). - 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).