Until September 2026, this Turflang function compiled:
fn make(): &N {
mut n: N = N::default();
n.val = 42;
return &n;
}
n lives in make()'s stack frame, and the frame is gone the moment make() returns. The caller gets a
reference to memory the next function call will reuse. It might read 42, or 0, or whatever the next call
left there. Today the compiler stops it:
── error at line 4, col 5 [E086 · dangling reference escape]
I cannot let a reference into this function's own local storage be returned, because it would dangle
when this function returns.
Hint: Copy it onto the GC heap first with `heap(value)`, which gives a reference that outlives this
function, or return or store a reference you received as a parameter instead of a local's.
The check is a compiler pass called EscapePass. This article covers what it tracks and why it has no lifetime
syntax. It also covers the sixteen shapes a fuzzer found that the pass got wrong, and what the pass looks like
now that they are closed.
The rule
Turflang has two safe reference types: &T, which is never null, and &?T, which must be checked before use.
Neither says anything about where the target lives. A reference can point into the GC heap, into the caller's
memory, or into the current function's own frame. Only the last can dangle, and only if the reference outlives
the call.
The language's rule, F-MEM-5 in its frozen specification (§6.13), names the places a reference must never reach if it points into the current frame. These sinks are the ways a value can outlive the function that made it:
To enforce that, the pass gives every reference a provenance at every point in the function:
| Provenance | Means | At a sink |
|---|---|---|
Stack | points into this frame: &n, &n.f, &a[i] of a local array | E086 |
MayStack | Stack on at least one path that reaches here | E086 |
Param | came in from the caller, self included | allowed |
Heap | from heap(), or read through something that is | allowed |
Unknown | the pass cannot tell | allowed |
When the pass cannot classify a value, it lets it through. That was decided in the first version, and most of what follows depends on it.
Why there is no borrow checker
Rust answers this question with lifetimes: every reference carries a region in its type, and the compiler proves at every use that the region is still alive. That works, and it costs syntax and a learning curve. Turflang's design rules out both. There are no lifetime annotations, no borrow checker, and no interprocedural summaries beyond one small exception covered below.
What Turflang gives up for that is completeness. The pass looks at one function at a time, follows a fixed set
of expression shapes, and treats anything else as Unknown. It makes one promise in return: it never reports an
error on a correct program. Every E086 is a real escape. The cost is that some real escapes, in shapes the pass
does not model, get through, and the design
record lists them.
That promise was tested on the first day. Running the new pass over the compiler's test suite turned up two
errors on correct programs. One was a &pt1 to Point* cast pushed into a List<Point*> that never left its
function. The other was &l.data[1] on a List's own heap-allocated buffer. Both were fixed by making the pass
track less. A cast into raw pointers now leaves tracking entirely (unsafe code has opted out of the safety net),
and &expr[i] is Stack only when the type says expr is a local fixed-size array.
How the pass works
EscapePass (documented
here) is a
forward dataflow analysis over the function's control-flow graph, run beside the pass that checks null
dereferences. Its state at each point is a map from a place to a provenance. A place is a variable, or a path
inside a value variable: h.head, a[0].next. A place missing from the map reads as its default, which for a
reference parameter is Param and for anything else Unknown.
Each statement updates the map. p = &n sets p to Stack; p = heap(n) sets it to Heap; h.head = p
copies p's provenance to h.head and drops whatever was known under the old value. When a statement passes a
value to a sink, the pass checks the value's provenance, and for a struct or array value it checks every place
stored inside it too.
Where control flow joins, after an if or at a loop header, the maps from each incoming path are merged, place
by place:
The pass repeats this until no map changes, a fixed point, and only then reports errors.
Two details matter as the language grows. The statement visitor is a tree walker that recurses into any node
kind it does not handle explicitly, so a new kind of expression is still searched for nested escapes. And the
list of collection mutators (push, set, put, add, enqueue on List, Map, Set, Stack and
Queue) is written out by hand, with a comment saying so. A sixth collection added later is not checked until
someone adds it to that list.
Sixteen shapes the pass got wrong
The pass landed in late September with six tests. In October, probing this boundary to build the
turf-fuzz soundness oracle turned up sixteen shapes it got wrong. Fifteen were escapes
it accepted: each compiled and returned a reference into a dead frame, and when the program ran, the read
printed garbage at -O0 once the next call reused the frame, and often a plausible 0 at -O2. The sixteenth
was the opposite, a correct program it rejected. All sixteen are written up in turf-fuzz's findings
report, and all
were fixed in mid-October.
The loop that erased everything
The first was the worst. Any loop between taking &n and returning it disabled the check:
fn make(k: int): &N {
mut n: N = N::default();
mut p: &N = &n;
mut i: int = 0;
while i < k {
i = i + 1;
}
return p;
}
The merge at a loop header combines the path from before the loop with the loop's back edge. On the first
iteration of the fixed-point loop, the back edge has not been computed yet. The pass treated it as an empty map,
the merge of {p: Stack} with {} was empty, and p became Unknown. Unknown is never flagged, so the
emptiness went round the loop for good.
The fix was one rule: skip a predecessor that has not been reached yet. In lattice terms, an uncomputed block is the top element, which leaves the merge unchanged. The pass that checks null dereferences had the same mistake, and it was fixed the same day.
Alongside it, a give/otherwise expression turned out to always be Unknown, so return (c give &n otherwise &n2) compiled. Now a give merges its two branches like two paths.
References inside values
The next eight were shapes the original design record had listed as accepted gaps. The pass tracked variables, so a reference that escaped inside something else went unseen:
fn make(): H {
mut n: N = N::default();
mut h: H = H::default();
h.head = &n;
return h; // E086: returned inside a struct or array
}
The others were the same idea in other forms: heap(m) where m.next holds &n, a struct holding &n pushed
into a List, &a[i] of a local array, h.own.next = &n two hops through a parameter, and a by-value
parameter's field. Closing them meant reopening a decision. Per-field tracking had been listed as a non-goal,
and the owner reopened it on condition that the pass still never reports a correct program.
This is where places came from (8e16021). The map is
keyed by paths inside values, a store into a path clears what was known under it, and every sink looks inside
the value it receives. One fact crosses a function boundary: a function whose every return is one of its
reference parameters, unchanged, passes that argument's provenance through to its caller. So return id(&n) is
an error, and return id(heap(n)) is not.
Five questions for the language's owner
The last six shapes were not plain bugs. The rule did not say what should happen, so they went to the language's owner as five questions, and each answer became part of the rule.
The first was about a path that carries &n meeting one that does not. The pass had merged them to Unknown:
fn make(c: bool): &N {
mut n: N = N::default();
mut p: &N = heap(n);
if c {
p = &n;
}
return p; // E086
}
The decision was that if any path carries &n, the escape is real on that path, so the merged value is
MayStack and flagged (1d210f3). That is the top row
and left column of the merge table. It was the one decision that could have broken the never-a-false-positive
promise. It keeps it, because the program above dangles whenever c is true.
An error payload, return E1::P(&n);, is now a sink
(9afd843), because the payload is copied to the heap so
the error can be returned. A call's result counts as long-lived when none of its arguments can reach this frame,
since a function cannot return its own locals, so mut h: &N = mk(); h.next = &n; is an error
(d5c7825). The same applies to an element of a standard
collection.
The last answer is about defer, and it fixed the one correct program the pass rejected. A deferred statement
runs when its block exits, after a return has already computed its value. The pass had analysed it where it
was written, so a() below was an error. Now it runs it at every exit of its block, last in first out
(b7741c0):
fn a(): &N {
mut n: N = N::default();
mut p: &N = heap(n);
defer p = &n; // runs after the return value is taken
return p; // accepted: returns the heap copy
}
fn b(): &N {
mut n: N = N::default();
mut p: &N = &n;
defer p = heap(n); // too late to change what is returned
return p; // E086
}
Each of the sixteen is now a test in the compiler's
tests/39_nullable_references/,
a fail_escape_* file that expects E086 with the right wording, next to escape_*_ok files for the shapes that
must keep compiling.
How it stays fixed
The soundness oracle runs every night. It builds each program from three parts: a source of the reference, a sink it flows to, and the control flow wrapped around the sink.
A program whose reference comes from the frame must fail with E086, and with the wording for its sink: a
rejection for the wrong reason counts as a failure too. A program whose reference is safe must compile and print
exactly 42 at -O0, at -O2, and under the AddressSanitizer runtime with a collection on every allocation.
Between the escape and the read, each program calls a function that overwrites the stack, so a reference that
does dangle prints garbage rather than a lucky 42.
What it still lets through
The pass does not look into an array element written at a run-time index, so a[i] = &n; return a; is
Unknown. It does not follow a call result whose arguments might reach the frame. It stops at every cast into
raw pointers, which only unsafe code can write. And a new collection type is unchecked until its mutators are
added to the list.
Each of those would need either more of the program than one function, or a guess the pass is not allowed to make. Within the shapes it does cover, the soundness oracle reruns all 2,662 programs every night, so a regression shows up the morning after it lands.
