Specifications / Compiler

Memory Management

Memory reclamation is an implementation detail. Osprey source cannot select allocation sites, release values, or observe when a value is reclaimed. Native builds default to tracing GC (gc); arc selects Perceus reference counting. WebAssembly, Apple and Android archives, and Python-hosted libraries default to ARC. Unmanaged allocation requires explicit --memory=none. The old --memory=default spelling is rejected.

Collection Is Unobservable [MEM-OPAQUE]

Osprey has no finalizers or destructors. Source code cannot inspect addresses, collector state, reference counts, collection order, or collection timing.

External resources such as files, sockets, processes, and foreign handles must therefore be closed through their explicit APIs; losing the Osprey value does not release the external resource.

The Managed Value Heap Is Acyclic [MEM-ACYCLIC]

Managed Osprey values are immutable after construction. mut rebinds a name or cell to a new value; it does not mutate an existing list, map, record, union, or closure environment to point at a newer managed value. Consequently, the managed heap cannot contain reference cycles and ARC does not need a cycle collector. Raw pointers obtained through FFI are outside the managed heap.

Ownership Lowering [MEM-OWNERSHIP]

Code generation tracks managed values in an ownership ledger:

  • A fresh managed result enters its current region with one owned reference.
  • Binding transfers a fresh owner or retains a borrowed value.
  • Storing a managed value retains it unless a fresh owner can be moved into the destination.
  • Region exits and proved last uses release their remaining owners.
  • A returned owner transfers its reference; a returned borrow is retained.

The generated calls are identical for every backend. General retain/release calls are no-ops under the unmanaged none and tracing-GC runtimes and active under ARC. For a proved-unique value, codegen emits the paired osp_release_unique hook with LLVM allocator/free attributes, allowing -O2 to remove a non-escaping allocation and release. The unmanaged none runtime also frees that unique value when the pair survives optimization; tracing GC leaves it for the collector.

Fiber Boundary Ownership [MEM-FIBER-ISOLATION]

spawn allocates a capture cell for that spawn and transfers one reference to the runtime. The fiber releases the cell after its thunk returns. A managed result is retained as a runtime root after the thunk returns. Every await retains a separate reference for that receiving side, so repeated awaits are safe. Once all spawned computations are quiescent, normal program teardown releases runtime roots after language-owned values have dropped; teardown does not invalidate cached results while another fiber can still await them. A managed value boxed into a channel is likewise retained for the receiving side.

The runtime may therefore co-own managed allocations across fiber threads. Before creating the first pthread-backed fiber it calls osp_mem_notify_multithreaded: ARC switches from its single-threaded fast path to synchronized retain/release operations, while the conservative GC disables collection after a second allocator thread appears. Deterministic fiber mode runs the same ownership protocol without creating a thread.

Backend Selection [MEM-BACKENDS]

Why GC is the native default

GC makes ordinary osp_retain and osp_release calls no-ops, reclaiming unreachable values during collection instead. ARC updates reference counts at ownership changes and releases an allocation when its last owner is dropped. Osprey defaults to GC to avoid that per-ownership-change bookkeeping as the starting policy for native programs; allocation tracking and collection still cost time and memory. This is a policy choice, not a claim that GC is faster than ARC on every workload.

The tradeoff is delayed reclamation, conservative retention and collection pauses. Choose --memory=arc when prompt reclamation or avoiding tracing pauses matters, then measure the application. Both backends are automatic and require no ownership annotations. Files, sockets and other external resources still need explicit cleanup under either backend.

The current GC is supported on Apple and glibc-Linux, not Windows, and stops collecting after a second allocator thread appears. Use ARC on Windows and for native programs that need reclamation across allocating fiber threads. WebAssembly, mobile and Python-library targets default to ARC and reject explicit tracing GC. Their managed runtime selection never falls back to unmanaged allocation. Exported host-library globals remain alive for later calls; --entry-only programs release globals on return, and native, WASM and mobile differential suites require zero live ARC allocations.

All code-generated heap allocation sites use the same IR symbols, so native backend selection only changes the runtime archive passed to the linker:

  • Explicit --memory=none uses compiler/runtime/memory_runtime.c.
  • gc uses compiler/runtime/memory_gc.c.
  • arc uses compiler/runtime/memory_arc.c.

The C runtime memory tests exercise each backend directly. The differential conformance targets exercise compiled Osprey programs and compare their output; the ARC target additionally checks its live-allocation counter at process exit.

Container Element Ownership [MEM-BACKENDS-ELEMENTS]

Persistent container slots use an erased int64 ABI, so the runtime cannot infer whether a slot is a managed pointer from its bits. Lists carry an elem_managed flag. Maps derive key ownership from the key type and carry a separate managed-value flag. Codegen supplies these flags from static types.

  • Insertion gives the container an owned key/value reference.
  • Path copying and builders retain every managed key, value, or child they continue to share.
  • Lookup, iteration, loop elements, and list-pattern heads borrow from the container; they do not create an owned reference by themselves.

Backend Hook ABI [MEM-BACKENDS-CUSTOM]

The runtime archives implement the same internal C hooks: allocation, tagged allocation, retain, release, proved-unique release, layout stamping, finalizer registration, multithread notification, and collection. Backends that do not use a hook implement it as a no-op. This ABI keeps emitted IR independent of the selected native backend.

Conservative Tracing GC [GC-TRACE-CONSERVATIVE]

The native GC records managed allocation base addresses and conservatively scans the native stack, flushed registers, and data/BSS ranges. A word is a root only when it equals a registered allocation base. A false positive can retain an otherwise dead object but cannot make the collector free a reachable one. The collector is non-moving.

Apple and glibc-Linux targets provide stack and executable data/BSS bounds. Windows reads the stack's high bound from the TEB, covered by the caller-stack root regression in the Windows workflow. Windows executable data/BSS ranges are still not scanned, so the GC backend is not part of the supported Windows contract until global-root discovery and its required Windows regression are implemented. Windows native callers should select --memory=arc explicitly.

Collection is restricted to the initial allocator thread. The first allocation from another thread permanently disables collection, while allocation-table access remains synchronized.

Perceus ARC [GC-ARC-PERCEUS]

The ownership and dup/drop discipline follows Perceus (Reinking, Xie, de Moura, Leijen), implemented from the extended technical report MSR-TR-2020-42 rather than the PLDI 2021 paper, because the report carries the full λ₁ calculus and insertion algorithm.

The ARC backend stores a 16-byte header before each managed body: a layout word, a signed reference count, and the body size. Retain/release first probe the live allocation registry, so literals, foreign pointers, and other unmanaged pointers are safe no-ops.

The layout word identifies raw blocks, pointer masks, list headers, pointer arrays, and finalized list headers: a header whose body owns a resource beyond the heap (a GPU buffer's copy on the device, GPU-BUFFER-RESIDENT) passes through the one finalizer the owning runtime unit registers (osp_mem_set_finalizer) before its data is released; the tracing collector runs the same finalizer at the sweep that reclaims the header, and the unmanaged none backend, which reclaims no general allocation, never runs it. Releases walk managed child slots non-recursively. Persistent list and map nodes retain shared structure and release the portion no longer referenced by any live version. ARC operations start lock-free and become mutex-protected before a pthread-backed fiber is spawned.