The Region Model
The invariant
A value allocated inside a region must not be reachable after that region ends. Four kinds of place outlive a region: the function’s result, storage declared outside the region (an outer local, an object a parameter points at, or a static), an object owned by an outer or sibling region, and any pointer whose target the compiler cannot identify, including a pointer that a called function stores into for the caller.
A region is a $scope() block, a Scope.retain and Scope.release pair, a
$scope(&slot) push, a Pool.open bracket, an $auto local, the
storage of a Scope local that Scope.destroy ends, or the function’s own
locals and parameters, whose storage ends when the function returns.
src/regions/regions.x analyzes these forms before the transform driver lowers
them, so a region is still the call that opens it and the defer beside it
that closes it.
Exemptions
The pass exempts these ways of leaving a region.
A canonical-value pool owns each canonical String and List, so ending a
Scope region does not free it. A value made inside a Pool.open bracket
belongs to that bracket’s pool and is tracked like scope-born storage. This
includes String.new, String.malloc, and List cells returned by a helper.
cons(a, rest) and %($a) retain their arguments in the cell’s pool: a value
from that same pool stays there, while a value from a shorter-lived region
reports at the store.
Scope.move and Context.export change the storage’s owner. After the
move, the region the pass was tracking no longer holds the allocation, so
the allocation survives the end of that region. The promote family -
String.promote, List.promote, and Atom.promote - hands the value to an
ancestor pool the same way and is exempt for the same reason. Moving into a
caller’s slot is the ordinary way to return mutable storage, and the pass
stops tracking the value there. Moving into a Scope local of the same
function ties the
value to that local’s storage, so a read after Scope.destroy reports.
A typed conversion copies. Assigning a Block, Buffer, or Array into a
String or List declaration produces a new canonical value with its own
owner, so the target does not inherit the region. The pass classifies the
assignment by the declared type through the compiler’s Var tag for that
type, as the converter does. A String * or List * points at storage and
gets no exemption.
Every region a function opens ends before the function’s own storage. The
address of a local can therefore be stored into another local, or into an
object of a region the function opens, without a warning. A reference
capture, using &name, moves the local into a cell of the active region, so
the closure holds that cell, which the active region owns.
A store into a place that an earlier defer in the same block writes
back, as $let does, is undone before the block ends and is not reported.
A store made before that defer, or after a defer that runs on only one
branch, is reported, because a return between them leaves the place
holding the value.
A destination that converts its value by copying does not keep it: a List
parameter given an Array, or a Var given a C string, which it boxes as a
fresh String. A local C array
passed on, returned, or stored counts as the address of its first element.
A number holds no address. A value of integer, floating-point, or enum
type, or a destination of such a type, carries nothing the pass tracks.
An address cast to intptr_t and then returned does not report, and a
pointer cast back from that number is not followed.
What the warnings do not cover
The check covers lexical regions and the storage it tracks. It does not cover:
- storage from plain
mallocor a C library; - pointer arithmetic and casts through raw C types;
- values reached through a field of a stack
struct, which the pass treats as one storage, so an address kept in a field of a struct that is copied out is not followed; - callbacks and function pointers, including entry points a Lisp binding calls;
- calls into functions defined in other units, except the runtime operations the pass knows; such a call is assumed to keep none of its arguments and to return no tracked storage;
- other aliases of storage that
Scope.freeorScope.reallocended; the pass ends only the local passed to the call, and it reports a free only of storage it can see is not from a Scope allocator: a literal, the function’s own storage, or a pooled value; Contextregions, which are not modeled;$autoanddefercleanups other than the runtime’s ownArray,Map,Block,Bytes,Buffer,Context, andScopecleanups;- a release inside a branch, which leaves the region open for the statements after that branch.
Each of these can produce a dangling pointer with no warning. A program without warnings is not proven memory safe.
Optional lifetime proof
x2c-graph certify checks selected entry points and the functions they can
call across the supplied source units. It applies the same region ownership
and parameter-effect rules as the compiler’s local pass, then asks whether
every reachable memory effect is covered. A proved result establishes the
scoped lifetime invariant for those paths under the native effect assumptions
listed in the report. If a root hands storage to its caller or another owner,
the report lists the remaining release obligation.
The audit distinguishes a proven lifetime violation from an incomplete proof. An unknown call, pointer cast or arithmetic, indirect storage access, aggregate copy, or unmodeled scope ending is an obstacle, not a compiler warning or error. A project may provide explicit native effect contracts to the audit; they are assumptions of its result and do not change compilation. The optional command and its contract format are described in the source graph reference. The proof concerns scoped ownership and lifetime. It does not establish general C memory safety or correctness of emitted code.
How the check works
Each function gets one summary of two facts: which of Scope and Pool storage its result may use (possibly both), and where each parameter is sunk (returned by identity, retained in the fresh result, placed into another parameter’s object or a static, or stored through an unknown pointer). The summaries of a unit’s functions are computed to a fixpoint because a caller’s facts depend on its callees’ facts. Each round walks every body with a stack of open regions and a map from each binding to its known facts: its parameter index, the region it was born in, the local a pointer was taken from, and whether a free has already ended it. The first round that changes no summary walked every body against final summaries, so its warnings are the ones reported.
An address taken with & belongs to the storage it names. The address of a
local, a parameter, or a compound literal belongs to the function’s own
storage. An address reached through a pointer, such as &box->value, belongs
to whatever that pointer holds, so an address inside a parameter’s object
counts as that parameter in the summary. A caller of a function that returns
&box->value therefore receives its own borrowed address as the result. An
argument to a reference parameter is the address of the object it names, so
bind(object) with T & is checked as bind(&object) with T *.
A meta function is walked the same way when it is defined, against the
summaries of the meta functions defined before it. A compile-time call
frees its locals when it returns, so a finding in a meta body is an error
and the definition is rejected; see
Meta Functions. A native function outside the runtime table
described below takes a summary its signature implies; see
Native modules.
Summaries stay inside the unit. For a call into another unit, the pass uses
only a fixed table of runtime operations, such as a Scope allocator,
cons, Array.push, Scope.move, and Array.list_free. Summaries are not
written to the .xi interface. A dependent unit collects its includes before
those units are transformed, and parallel workers do not share transformed
units, so an interface summary would make a unit’s warnings depend on input
order, the job count, and interfaces left by an earlier translation. As a
result, the pass reports no warning when a caller passes a region-born value
to a function in another unit that stores its argument into a static.
Where the design sits
The region check is based on the ML Kit and Cyclone. The ML Kit inferred
regions and a region-annotated program from an unannotated one. Cyclone made
regions part of the type system of a C-like language and checked pointer
lifetimes against them. From the ML Kit, x2c takes the idea that regions are
the unit of deallocation; from Cyclone, the idea that a C program can be
checked against them. The regions are the ones the author wrote with
$scope and the retain and release calls. The check adds nothing to the
program text and produces only warnings, except in compile-time code.
Rust and Swift put ownership and lifetime in the signature. Every call is checked locally against a declared contract, and a violation is an error. x2c has no lifetime annotations, so the check derives the same information, and derived facts are weaker than a declared contract. As a result, existing C-shaped code compiles unchanged, and ordinary code gets only warnings.
Go’s escape analysis and Infer use the most similar methods. Go computes per-function escape facts and uses them to choose between stack and heap allocation. Infer computes per-procedure summaries and propagates them across a program to report memory and lifetime defects. x2c’s pass applies Infer’s method to Go’s kind of fact: it computes summaries per function within one unit and uses them for warnings.