Dfr
dfr is scope-exit cleanup sugar. edf is its error-only V3 counterpart.
The shipped forms are intentionally narrow:
- only the
dfr { ... }andedf { ... }block forms are supported - each block registers work on the current lexical scope
dfrruns when that scope exits by fallthrough orbreak, and before areturn, recoverablereport, orpanicleaves it- multiple deferred blocks run in reverse registration order
edfruns only on a recoverablereportexit, not on normal return or panic
This is control-flow sugar.
It is not a destructor system, user-defined drop hook, object system, or async cleanup system.
Model reminder:
-
memoalone does not expose the hosted.echo(...)substrate -
the printing examples below use
fol_model = "memo", declare the bundled standard library explicitly inbuild.fol, and call itsstd.iowrappers:build.add_dep({ alias = "std", source = "internal", target = "standard" }); -
source files then import that dependency with
use std: pkg = {"std"}; -
the explicit dependency upgrades the effective tier to hosted;
stdis not a thirdfol_model -
dfrandedfare not std-only; both remain valid incoreandmemo -
a
coreor unhostedmemoprogram using them can still run; bundledstdappears here only because these examples print from source
Basic form
use std: pkg = {"std"};
pro[] main(): int = {
dfr {
std::io::echo_str("closing");
};
std::io::echo_str("work");
return 7;
}
The deferred body runs before control leaves main.
Reverse order
use std: pkg = {"std"};
pro[] main(): non = {
dfr { std::io::echo_int(1); };
dfr { std::io::echo_int(2); };
}
This executes as:
2- then
1
Nested scopes
Deferred bodies belong to the scope where they are declared:
use std: pkg = {"std"};
pro[] main(flag: bol): non = {
dfr { std::io::echo_str("outer"); };
when(flag) {
case(true) {
dfr { std::io::echo_str("inner"); };
return;
}
* { }
}
}
If the inner scope exits first, its deferred bodies run before the outer scope’s deferred bodies.
Error-only cleanup
edf registers a body for recoverable error exits only:
use std: pkg = {"std"};
fun[] load(fail: bol): int / int = {
edf {
std::io::echo_int(2);
};
dfr {
std::io::echo_int(1);
};
when(fail) {
case(true) { report 7; }
* { return 0; }
}
};
On success, only dfr runs. On report, both registrations run in reverse
registration order. On panic, dfr runs but edf does not, because panic is
not a recoverable error-shell exit.
Deferred-body limits
The registered body is checked now but executed later. V3 therefore rejects effects that the current lowering cannot replay safely:
return,break,report, andpaniccannot appear inside adfroredfbody; deferred cleanup cannot initiate another exit while the surrounding exit is being replayed- a moved binding cannot be reinitialized there
- channel sender or receiver endpoints cannot be acquired there
| awaitand access to an existing eventual binding cannot occur insideedf; an error-only body cannot discharge eventual ownership on normal exits, and the restriction remains active inside adfrnested within thatedf- a
mux[T]handle cannot be locked, unlocked, used for guarded field access, or forwarded to anothermux[T]parameter there
These are compile-time boundaries, not cleanup operations that are silently
skipped. An outer report still triggers registered dfr and edf bodies;
only a new report from inside one of those bodies is rejected. An all-exit
dfr may await and handle a recoverable eventual, because it runs on every
modeled exit. Error-only edf cannot. Put the other affected control flow,
channel acquisition, owner reinitialization, or mutex work in ordinary code.
Ownership ordering
At lexical scope exit FOL applies this order:
- select the registered blocks for the exit kind (
dfr, plusedffor a recoverable error) - run those blocks in reverse registration order
- release any still-active borrows
- free still-owned heap values by ordinary scope drop
A moved value is no longer in the source binding’s owned set, so it is not freed twice. Deferred work always runs before the scope drops its remaining owned heap values.
A dfr or edf body that references a move-only binding from an enclosing
scope reserves that binding through the exit of the registration scope. The
binding cannot be transferred after the deferred block is registered, because
the deferred body still needs its value. Moving the value before registration
instead makes the reference inside the deferred body an ordinary use-after-move
error. A reservation registered in a nested block ends after that block’s
deferred work has run, so the owner may be transferred afterward.
Mutex guard effects are not deferred in V3. A dfr or edf body cannot
call .lock() or .unlock() on a mux[T] parameter, and it cannot access
mutex-guarded fields even when the surrounding scope currently holds the
guard. It also cannot forward the mutex handle to another mux[T] routine,
because that would hide delayed guard transitions behind the call. Perform
guarded work in ordinary control flow before registering deferred cleanup.
Channel endpoint acquisition is similarly rejected inside dfr and edf.
Acquire any sender handle before registration and perform receiver operations
in ordinary control flow.
The same delayed-effect boundary applies to owner reinitialization. A moved
binding cannot be reinitialized inside dfr or edf: typechecking that write
as immediate would make the binding look usable before the deferred assignment
actually runs. Reinitialize the binding in ordinary control flow before the
deferred block is registered.
Explicit captures
A delayed block declares how outer locals enter it with a capture list — every routine-local binding the body uses must appear in it. Each entry names an outer binding and states its operation:
var seen: int = 2;
var owned: ptr[int] = [ref]seed;
dfr[seen[bor], owned[mov]] {
std::io::echo_int(seen + [drf]owned);
};
[bor] observes the binding — the owner stays restricted while the block can
still run, and the loan is read-only: assigning through a [bor] capture is
rejected. A block that mutates its capture states the composite [mut, bor]
mutable loan instead:
var[mut] total: int = 5;
dfr[total[mut, bor]] { total = total + 37; };
mut composes only with bor. [mov] transfers the value into the block’s
environment: the outer binding is invalidated for the rest of the scope, and
the block still owns the value when scope-exit cleanup executes it. [cpy]
and [cln] duplicate the value under the same capability rules as spawn
captures. A bare capture name without an operation is rejected, as is a
channel endpoint capture — endpoints belong to spawned tasks, not delayed
blocks. Using an undeclared outer local
inside the block is an ownership error; module-level values and routine names
are not captures and need no declaration.
Current milestone boundary
The basic dfr form belongs to V1. Ownership-aware ordering and edf are the
shipped V3 memory contract. V3 processor resources do not become generalized
deferred-cleanup hooks: outstanding tasks join at process exit, channels close
through endpoint ownership/lifecycle, and an eventual can be consumed at most
once by | await. Recoverable eventuals must be awaited and handled on every
exit path; an all-exit dfr may do so, while an error-only edf may not.
Infallible eventuals may instead remain outstanding for the process-exit join.
Await inside edf—including inside a nested dfr—channel endpoint acquisition,
and mutex guard effects inside dfr/edf are rejected. Native/foreign resource
cleanup remains V4 interop work.