Memory

Scoped arenas, owner types, resources, and what check reports.

moon has no garbage collector. Memory is reclaimed by two mechanisms you can see in the source:

What happens by default

Ordinary code writes neither of them. Every value is allocated from the allocator the running task has selected, which is the process heap until you select something else, so no signature carries an allocator and no call site passes one:

fn main() {
  let greeting = "Hello " + "moon"
  let words = greeting.split(" ")
  println(words.len())
}

Releasing is the same by default. A value that belongs to something — an element of a container, the bytes of a Buffer — is freed when that owner is, and a program that ends releases everything it held. What is left over is a value nobody owns in a block that selected nothing: it lives until the process exits, because there is no collector to notice that it died.

That is the right answer for a program that does a bounded amount of work, and the wrong one for a loop or a server. The rest of this page is how to say otherwise.

Scoped arenas

context.allocator.use(.Arena) selects a fresh arena for the rest of the enclosing block. Every allocation made while it is selected — string building, array growth, parsed documents, whatever a function you call allocates — comes from that arena, and the whole arena is released when the block exits:

fn summarize(lines: [string]) -> int {
  context.allocator.use(.Arena)
  let words = lines.flat_map(|line: string| line.split(" "))
  words.len()
}

The release happens on every exit from the block — falling off the end, return, ?, break, continue — in the same order as the block's defers. No signature changes: the selection is inherited by everything the block calls, and across await.

Arenas nest. An inner block's arena is released first, and its result is handed to the arena selected around it.

Values that leave the block

A value that escapes the block is copied into the older memory before the arena goes away, with sharing and cycles preserved. That includes the block's result expression, a returned value, and a store into a binding or container that outlives the block:

fn shout(names: [string]) -> string {
  context.allocator.use(.Arena)
  let loud = names.map(|name: string| name.to_upper())
  loud.join(" ")               // copied out; the scratch strings are not
}

So an arena is a boundary for the work, not for the answer. The exception is an owner: see what check reports.

Long-running loops

A loop that allocates on every iteration and selects nothing keeps every one of those values until the process exits. The fix is one line at the top of the body, which gives each iteration its own arena:

for line in lines {
  context.allocator.use(.Arena)
  let entry = std.json.parse(line)
  publish(summary(entry))
}

Measured on a loop that parses one small object per iteration, peak memory goes from 333 MB at 262,144 iterations to a flat 8 MB with that line present. Same program, same output. moon lint reports the shape it can recognize, and moon run --telemetry=memory reports which arenas and which source lines the bytes of a run actually went to.

A server does not need the line: std.http_server opens an arena per request already.

An arena that outlives its block

allocator.Arena.new() is an arena nothing releases automatically — whoever holds it releases it with deinit(), and bytes() reports what it currently holds. Use it for memory whose lifetime is a phase rather than a block, such as a cache that is filled once and dropped whole:

fn main() {
  let heap = allocator.Arena.new()
  defer heap.deinit()
  context.allocator.use(heap)
  let entries = load_entries()
  serve(entries)
}

allocator.Arena.scoped() is the middle ground: an arena you can name and pass around that is still released when the block that created it exits.

Owner types

Three built-in types carry memory of their own:

TypeHolds
BufferOwned, growable bytes
Array<T>An owned sequence that also owns its elements
Box<T>One owned cell

Ownership is part of the type; there is nothing to annotate and no marker at a call site. Build one, release it with deinit(), and let defer do the releasing so that early exits are covered:

fn main() {
  let mut text = Buffer.new()
  defer text.deinit()
  text.append("Hello ").unwrap()
  text.append("moon").unwrap()
  println(text.view())
}

Handing an owner on — binding it to a name, appending it to an Array, storing it in a field, returning it in ok(...) — moves it: the new location is responsible for the release and the old one is no longer usable. A container releases its elements when it is released, so you never free an element separately.

view(), get(i) and &items[i] borrow: they are a look at what the owner holds, they cannot grow or release it, and they stop being valid when the owner is released or relocates. clone() is the explicit copy — a second owner, with its own release, holding a copy of the contents.

Guarding a failure edge

Between building an owner and handing it over, the value is yours, and a step in between that fails would strand it. errdefer releases it on the failure edges only, and does nothing on the successful path where ownership moves on:

fn collect(texts: [string]) -> Result<Array<Buffer>, Error> {
  let mut words = Array.new<Buffer>()
  errdefer words.deinit()
  for text in texts {
    let mut word = Buffer.new()
    errdefer word.deinit()
    word.append(text)?
    words.append(word)?
  }
  ok(words)
}

Check asks for this guard by name when a failure would otherwise leave a value with no owner.

Files, processes, and C resources

An open file, a Watcher, a child process and its pipes, and a handle obtained from a C library are owners in exactly the same sense: the binding that acquires one owes the release, passing it by value passes the obligation on, and a borrowed view of it (child.stdout) does not own it. The idiom is the same:

fn save(path: string, text: string) {
  let file = std.fs.try_open(path, "w").unwrap()
  defer file.close()
  file.write(text)
}

Leaving the block without releasing is reported, and so is releasing through a copy that does not own the resource.

What check reports

Ownership is checked before the program runs, so these arrive from moon check and in the editor rather than as a crash:

Each message names both values involved and suggests the edits that fix it.

Where next