EngineeringCompilers and analysis

References that can't outlive their frame: escape analysis in Turflang

How the compiler stops a reference to a local from escaping its function without lifetime syntax, and the sixteen shapes a fuzzer found that it got wrong.

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:

make()'s framemut n: Np = &n;Stackreturn p;a returnE086dst.next = p;a field the caller ownsE086l.push(p);a collection mutator, or []=E086m.next = p; heap(m)heap()'s operand, or inside itE086return E1::P(p);an error payloadE086xs[0] = p;an element of a (&N)[] parameterE086heap(n)return heap(n);a fresh GC copy: Heapallowed
The six sinks. A reference with Stack provenance reaching any of them is E086. The usual fix is heap(), which copies the value to the GC heap and returns a reference that lives as long as anyone holds it.

To enforce that, the pass gives every reference a provenance at every point in the function:

ProvenanceMeansAt a sink
Stackpoints into this frame: &n, &n.f, &a[i] of a local arrayE086
MayStackStack on at least one path that reaches hereE086
Paramcame in from the caller, self includedallowed
Heapfrom heap(), or read through something that isallowed
Unknownthe pass cannot tellallowed

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:

path A (rows)path B (columns)StackMayStackParamHeapUnknownStackStackMayStackMayStackMayStackMayStackMayStackMayStackMayStackMayStackMayStackMayStackParamMayStackMayStackParamUnknownUnknownHeapMayStackMayStackUnknownHeapUnknownUnknownMayStackMayStackUnknownUnknownUnknown
Merging two paths' provenance for one place. Equal stays equal. Stack on either side makes MayStack, which is flagged at a sink like Stack. Any other disagreement becomes Unknown, which is never flagged.

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.

Before: the back edge met as emptymut p: &N = &n;while i < ki = i + 1back edgereturn p;IN(while) = meet({p: Stack}, {})result {}, so p is Unknowncompiles: the caller reads a dead frameAfter: an unreached edge is skippedmut p: &N = &n;while i < ki = i + 1back edgereturn p;IN(while) = {p: Stack}back edge not reached yet: skippedE086 at return p;
The same control-flow graph, before and after turflang 9398f38. Treating an uncomputed predecessor as empty erased every provenance at every loop; skipping it until it has been computed keeps them.

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.

13 sources&n, id(&n), &a[0], heap(n), a parameter20 sinksreturns, fields, collections, heap()11 wrappersloops, if and else, compare, nesting2,662 programsplus 5 pinned shapes1,441: E086with the sink's own wording1,221: print 42-O0, -O2, ASan + GC stressEvery program calls clobber() between the escape and the read, so a dangling read prints garbage.
The soundness oracle. Each combination that type-checks becomes a small program with a verdict known in advance; five decided owner questions run alongside as pinned shapes.

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.