Skip to content
You are reading v0.3, which is not the current release. Go to v0.4.

Memory and resources

Khora manages ordinary memory automatically. Resource lifetimes that have external meaning—files, sockets, transactions, foreign handles—use explicit structured cleanup.

There is no source-level borrow or lifetime syntax for ordinary Khora values. The runtime uses reference counting and the compiler may remove retain/release operations or reuse uniquely owned storage when doing so cannot change program meaning.

The programmer-visible rules are:

  • optimization cannot make behavior depend on whether storage was reused;
  • writable state does not silently become cross-fiber shared state;
  • external resources require their own cleanup policy.

The primitive resource lifetime object is Region:

pub type Region;
impl Region {
pub fn open() -> Region;
pub fn root() -> Region;
pub fn defer(self, finalizer: () -> ()) -> ();
}

Open a region and register finalizers with defer:

fn work() -> Int {
let region = Region::open();
Region::defer(region, fn () => release_second());
Region::defer(region, fn () => release_first());
42
}

Finalizers execute in reverse registration order when the region is released, and a region is released when the last reference to it goes – which for the ordinary case is the end of the block its binding is in, on every way out of that block, including a cancellation. The exception is a reference that outlives the block: a closure that captured the region, or a Scope handed to a fiber, keeps it open until that reference goes too. That is what lets a fiber hold a scope belonging to the call that spawned it, and it means a captured region’s finalizers run later than the block, not at it. So the example above runs its finalizers when work returns, and putting the let inside a smaller block ends the region there instead:

{
let region = Region::open();
Region::defer(region, fn () => release());
use_it();
};
// `release()` has already run here.

That is the difference between a lease that ends with the call and one that ends with the caller, and it is worth being deliberate about: a Region::open() at the top of a function holds everything until the function returns.

Region::root() refers to the outer program region. Its finalizers run as the program exits.

The standard structured-resource capability is:

pub effect Scope {
defer: (() -> ()) -> (),
}

scoped creates a fresh region, installs a Scope handler over the body, and removes that capability from the caller’s required row:

pub fn scoped<A, 'ef, 'er>(
body: () -> A with { 'ef | scope: Scope } raises 'er
) -> A
with 'ef
raises 'er

Example:

fn inside() -> Int
with { scope: Scope }
{
scope.defer(fn () => cleanup());
7
}
fn outside() -> Int {
scoped(inside)
}

A named function is the normal argument to scoped when that function requires the scope capability.

acquire registers a release operation and returns the acquired value:

pub fn acquire<A, 'ef>(value: A, release: (A) -> ()) -> A
with { 'ef | scope: Scope }

Typical form:

fn use_connection() -> ResultValue
with { scope: Scope }
{
let connection = acquire(open_connection(), close_connection);
query(connection)
}

release(value) runs when the enclosing resource scope ends.

A region is released on every structured path out of its owner:

pub type WorkError = | Failed;
fn work(fail: Bool) -> Int raises WorkError {
let region = Region::open();
Region::defer(region, fn () => cleanup());
if fail {
raise WorkError::Failed
}
return 1;
}

The finalizer runs on the normal path, the explicit return, and when the raise leaves the function.

Cancellation uses the same structured unwind path. A pending cancellation observed at a cancellation point releases intervening regions and runs their finalizers before the fiber terminates.

A catch handles failures in a raises row. Cancellation is not a failure variant and is not consumed by catch.

Cancellation is not an exception that can arrive between arbitrary instructions: a pending one is observed at a cancellation or failure propagation point, such as !. That is what keeps the code between two marked points readable as ordinary straight-line code, while still letting blocked or suspended work be woken so it can unwind and release what it holds.

The invariant a resource abstraction should be built on is the short one: if the scope ends, cleanup runs.

A resource-owning API should generally keep the lifetime inside one call:

fn with_resource<A, 'ef, 'er>(
body: (Resource) -> A with 'ef raises 'er
) -> A
with 'ef
raises 'er

rather than returning an unmanaged handle that callers must remember to close on every path.

A database transaction is this shape with a richer finalizer policy, and it belongs in the transaction abstraction rather than in every caller:

  • completing normally commits;
  • a typed failure rolls back;
  • cancellation rolls back before the connection returns to its pool.

Database transactions is that policy written out.

Foreign or thread-affine resources can impose rules beyond ordinary Khora values. See FFI for pointer and suspension constraints, Concurrency for fiber lifetime rules, and Sharing for cross-fiber values.

Khora 0.3.0built frombd964738afb6on 2026-10-17 20:41ZVersioningSecurityContributingReleasesSource