Runtime
clause_runtime is an LLVM-free C++23 library. It owns contexts, process heaps,
atoms, loaded modules and lifecycle bookkeeping, and runs Erlang processes
on scheduler workers (processes). All APIs are
project-internal; host calls must be serialized per runtime and not overlap
a program run, whose workers synchronize among themselves
(threads).
Linking
Link exactly one runtime built for the target through Clause::generated_program:
add_executable(harness harness.cpp)
target_link_libraries(harness PRIVATE Clause::generated_program)
It brings the archive, ABI/runtime headers and C++23, but not LLVM. link_consumer.cpp shows a full lifecycle.
Lifecycle
Runtime::start(options)→std::expected<std::unique_ptr<Runtime>, Status>. Defaults: current ABI version and native term width,max_atoms2^20 (at most 2^26; programs set it with--max-atoms, runtime options). The number of contexts is not limited.process_heapandprocess_stackare the options of contexts created bycreate_context();memory_limit_bytesis the optional runtime-wide limit, reported bymemory_bytes(). All are uncapped by default.create_context()orcreate_context(heap_options, stack_options)→ borrowedProcessContext*, stable until destroyed. Heap defaults: 233-word minimum heap (min_heap_words) and no memory cap (limit_bytes=UNLIMITED_HEAP_BYTES; a set budget is a word multiple at least the minimum heap). The stack is uncapped too unlessStackOptions::limit_wordsis set.destroy_context(ctx),shutdown(): shutdown returnsbusywhile contexts remain; after that it succeeds idempotently and later calls returnstopped. The destructor cleans up remaining contexts.- Context and runtime identities are non-recycled; exhaustion fails. A
context's identity carries its pid number (pids). A context
pointer is a borrow, not an identity.
lifetime()gives a weak token that reportsalive() == falsebefore heap teardown. - Lifecycle calls never throw and are silent. Status values: abi.md.
Teardown order: close scheduler records → destroy contexts → release code registrations → destroy scheduler service → atom table last. Resolved function handles keep code and atom spellings alive after runtime teardown but never a process.
Process memory
Each context owns one ProcessHeap: a single heap block created by its first
allocation and sized max(min_heap_words, request), plus a chain of heap
fragments owned by the same process. Words move only when the host calls
collect() at a safe point.
The heap follows the classic ERTS design; runtime-heap.md is its contract (layout, areas, sizing, admission, roots, collection).
allocate(words)returns zeroed word storage.reservegives a move-only reservation with explicit commit and automatic rollback. One reservation at a time per heap: build children first, reserve the parent last.- Bump allocation fills the heap block; a request that does not fit goes to the
newest fragment, else to a new fragment sized
max(min_heap_words, request)(capped by the remaining budget). Words are word-aligned only. Rollback resets the area top, drops a fragment (or the heap block) created by the reservation and restores accounting exactly. - Rejects zero, overflow and exhausted budget before
publishing. Errors:
out_of_memory(allocation) orlimit_exceeded(budget); generated code receives the exact status. - Binaries over 64 bytes live in shared buffers outside the heap. Each heap cell
that refers to one holds a
std::shared_ptrand joins the process's off-heap list when published; teardown walks the list and drops those references (off-heap binaries). add(value)/copy_tocopy a graph of another process of the same runtime, keeping its sharing and sharing off-heap buffers; a failed copy changes nothing (copying between heaps).used_wordscounts allocated words;capacity_wordscounts heap block and fragments;off_heap_wordscounts the buffers this process references, each once. Backing plus off-heap words share the optionallimit_bytesbudget; a collection keeps half of the budget left after survivors free, so exhausting it means the live data no longer fits (failure behavior).- Every used word parses as a header-led object, a cons cell or filler
(word layout); reserved words start zeroed.
Raw
allocate()words must stay zero or hold complete objects.verify()walks the heap block and every fragment and checks each term slot points at an object start of the same process (tests and debugging;corrupt_heapotherwise). collect(roots)copies everything reachable from the process roots and the host's root words into a new heap block, frees the old block and fragments, releases dead off-heap binaries and rewrites the roots (collection). Generated code collects at function entries and comprehension loop heads whenwants_collection()(collection in generated code). It runs only at a safe point (no generated code running outside aSafePointscope, no open reservation), elseunsafe_point; the root inventory lists what it rewrites; failure to allocate the new block isout_of_memorywith nothing changed.
Code server and builtins
Each runtime owns one CodeServer (code_server.hpp,
callable.hpp):
auto functions = std::make_unique<ModuleRegistry>();
auto added = functions->add("identity", 1,
[](ProcessContext &, std::span<const Term> args) -> CallResult<Term> { return args.front(); });
auto loaded = context.code_server().load({"native_demo", CodeImage::linked(), std::move(functions)});
auto fn = context.code_server().resolve({.module = "native_demo", .function = "identity", .arity = 1});
- A
ModuleRegistrymaps exact name/arity (≤ 255) to one all-Termcallable.loadfreezes and publishes it; duplicates or failures publish nothing. resolvedistinguishes missing module and missing export.ResolvedFunctionpins the module image;callchecks arity, arguments and results, and turns host exceptions into failures.- Native bodies must be synchronous, non-blocking and must not retain the context or argument span.
- A bounded catalog of known deferred BIFs (
self/0,length/1,spawn/3,spawn_link/3,send/2,make_ref/0,garbage_collect/0,apply/3(generated code callsapply/2,3through the dynamic call services instead, funs),tuple_size/1,+/2) reportsnot_implemented; other unregistered names returnunknown_builtinsilently. This host path does not reach the production builtins. - Production builtins live in the server's
BuiltinRegistry, registered at runtime startup; generated code, dynamic calls and funs reach them through the builtin bridge. CodeServer::export_framefinds theFrameDescriptorof an export by module, function atom and arity;function_frameadds the builtins for dynamic calls;external_funinterns the definitions of external funs built at run time.CodeServer::unloadis deferred; dynamic loading is not supported.
Scheduler bookkeeping
SchedulerService (scheduler.hpp)
records process lifecycle only; it runs no code.
register_process(context)once per context (same runtime); duplicates returnalready_registered.remove_process(id)retires a non-running record.begin_dispatch→ running;finish_dispatch→ runnable, waiting or exited (with reason).set_suspendedtoggles a flag on runnable/waiting records. Other transitions returninvalid_transition.request_shutdown()closes registration, dispatch and suspension control; inspection and returns stay available for draining.runandexecutereportnot_implemented.
Design sketches for workers and processes live in
runtime/design/ and runtime/include/{scheduler,process}.hpp.
Messages are implemented (processes): a send copies
the message into the receiver's heap and appends it to its signal inbox
(runtime/include/mailbox.hpp).
Threads
Scheduler workers (plan step 56, workers) run processes on several threads of one runtime. Services they share are synchronized; everything else stays confined to the thread running its process or guarded by the executor's mutex.
- Atoms (step 54):
AtomStorageguards both indexes with a shared mutex. Lookups (lookup,boolean,size) share it;internlooks up under the shared lock and only a new spelling takes the exclusive lock, checks again and publishes the entry, so racing interns of one spelling get one word. Words stay stable: an entry is never changed or removed before teardown. - Code (step 55):
CodeServerguards its modules and external fun definitions with a shared mutex. Lookups (find_module,resolve,atom_word,record_definition,fun_definition,export_frame,function_frame,owns) share it;loadand building a new external fun definition take it exclusively, so concurrent registrations of one name publish one module (the others getduplicate_module) and racingexternal_funcalls get one definition. The builtin registry is filled at runtime startup, before any worker runs, and only read afterwards. - Pins: modules are never removed while the runtime lives (unloading is
deferred) and the server is destroyed after every context, so definitions,
frames and atom slots it returned stay valid for every invocation and every
fun cell.
ResolvedFunctionandfind_modulehandles also keep their module after the runtime is gone. - Pid numbers (step 56):
ProcessNumbersissues numbers under an exclusive lock and admits pid words under a shared one. - Memory (step 56): the runtime-wide account (
RuntimeMemory) charges and releases with atomic operations; a charge never takes the account past an optional limit. - Port I/O (steps 57C–57F): the I/O service's reader, writer and watcher threads and the socket thread (ports) touch process state only through the executor's mutex.
- Linking: on Linux the runtime adds
-pthreadfor its threads; on Windows it links Winsock (ws2_32,mswsock) for sockets.
Standard output
RuntimeOptions::standard_output (output.hpp)
receives erlang:display/1 and later standard_io bytes. The default writes to
process stdout through C stdio (buffered); a host sink returns false to
report a failed write.
erlang:display/1 renders its argument in display style,
writes the text and a newline in one write and returns true. Rendering limits
and rejected writes become infrastructure statuses (resource_limit,
output_failure, ...) in the checked channel, never Erlang exceptions.
Program startup
CLAUSE_main_v1 (startup.hpp,
runtime/src/startup/) runs a whole program for the generated main: it
checks every descriptor's ABI, starts a default runtime, registers all modules
before any entry code, builds argv in the entry context, calls the entry and
maps the result to the exit status of executables.
Reports go to stderr after stdout is flushed; the context and runtime are torn
down in order on every path. CLAUSE_halt_v1 implements erlang:halt/0,1
(abort calls std::abort).
Deferred services
These report one [feature] notimpl line (features) and change no
state:
| Boundary | Error |
|---|---|
TermFactory::port (no ports yet, plan step 53), reference(ReferenceIdentity) and function(FunctionIdentity) | TermError::not_implemented |
SchedulerService::run / execute | SchedulerError::not_implemented |
CodeServer::unload | CodeError::not_implemented |
dispatch_builtin on a catalogued BIF | Status::not_implemented |
Clause