aetherc --emit=lib (or ae build --emit=lib) compiles a .ae file into
a dynamic library (.so on Linux, .dylib on macOS) with ABI-stable
entry points that any FFI-capable language can call: C, Java (Panama),
Python (ctypes or SWIG), Go (cgo), Ruby, Rust (extern "C"), and so on.
This document covers the v1 contract. For the broader design rationale, see aether-embedded-in-host-applications.md.
// config.ae
import std.map
build_config(env: string, port: int) {
m = map_new()
map_put(m, "env", env)
map_put(m, "port", port)
return m
}
Build:
ae build --emit=lib config.ae
# produces libconfig.so (or libconfig.dylib on macOS)Consume from C:
#include "aether_config.h"
#include <dlfcn.h>
typedef AetherValue* (*build_fn)(const char*, int32_t);
int main(void) {
void* h = dlopen("./libconfig.so", RTLD_NOW);
build_fn build = dlsym(h, "aether_build_config");
AetherValue* cfg = build("prod", 8080);
const char* env = aether_config_get_string(cfg, "env");
int32_t port = aether_config_get_int(cfg, "port", 0);
printf("%s on %d\n", env, port); // prod on 8080
aether_config_free(cfg);
dlclose(h);
}| Flag | Effect |
|---|---|
--emit=exe (default) |
Current behaviour, produces an executable. |
--emit=lib |
No C main() in the output (a program's main() becomes aether_main / aether_main_exit); every top-level Aether function gets an aether_<name>() C-ABI alias; built with -fPIC -shared. |
--emit=both |
Produces both an executable AND a shared library from one source. ae build --emit=both foo.ae -o foo writes foo (the exe) and foo.dylib / foo.so (the lib) side by side. With no -o, defaults are <base> (exe) and lib<base>.<ext> (lib). Internally dispatches cmd_build twice (once per emit mode); the lib pass appends the platform lib extension to the user's -o so the second pass doesn't overwrite the first. |
--emit=csrc |
Emits the portable generated C source (foo.c), a catalog header (foo.h, the aether_<name>() prototypes), and a machine-readable catalog (foo.catalog.json), and stops. No gcc, no native artifact. Same catalog codegen as --emit=lib; the difference is you get source plus a describable ABI surface, not a host .so. |
--emit=obj |
Compiles straight to a single relocatable object file (foo.o), no link step. Same catalog codegen as --emit=lib, so the object exports both the bare Aether names and the aether_<name>() C-ABI aliases. For dropping .ae into an existing C link line. |
--emit=csrc hands you generated C that your build then has to know how to
compile. --emit=obj does that step for you and stops at the object:
ae build --emit=obj lib.ae -o lib.o
cc main.c lib.o $(ae cflags --libs) -o appThis is what makes an incremental make correct without checking generated C
into the tree. A checked-in *_ae.c is a stale-artifact trap: edit the .ae,
restore the .c to keep a diff clean, and the next plain make links the old
code with nothing to warn you. With an object rule the .ae is the only
source, and the timestamp the build compares is the one that matters:
%.o: %.ae
ae build --emit=obj $< -o $@
app: main.o lib.o
$(CC) $^ $(shell ae cflags --libs) -o $@The C compiler used for the object is $AE_CC, else $CC, else gcc when
present, else cc, the same resolution order as the other build paths, so a
cross build picks up the same toolchain. Because the object carries the
aether_<name>() aliases, a C caller can #include a header generated by
--emit=csrc (or declare the prototypes itself) and call in directly. Note
that a -> string export returns a string the caller owns, as a const void*
handle: see Strings an export returns.
A single native lib can't be universal across OSes, ELF vs Mach-O vs
PE/COFF are different loader formats. But the pre-native artifact can be:
the generated C is portable, so --emit=csrc moves the "which platform"
decision from distribution time (ship 6 natives) to the consumer's build:
ae build --emit=csrc mylib.ae -o mylib
# writes mylib.c (portable C) + mylib.h (aether_<name> prototypes)
# + mylib.catalog.json (the describable ABI surface)
# compile it wherever, against the runtime:
cc -fPIC -shared mylib.c $(ae cflags) -o libmylib.so # this host
# or feed mylib.c to WASM, or static-link it into a compiled host, or
# publish {mylib.c, mylib.h, mylib.catalog.json} as a content-addressable
# source package.The .h is a normal C header (include guard + extern "C"); a consumer
#includes it and calls aether_add(...) / aether_greet(...) directly. This
is the enabling primitive for compile-on-install bindings (Python C-extension /
Ruby-gem style) and a "source registry" story where every binding compiles the
same hash-pinned source in its own toolchain. Deliberate follow-ups:
single-file amalgamation and expressing the required runtime .c set for a
fully standalone external compile.
The same aether_lib_meta() symbol catalog the .c carries in .rodata (see
the reflection section below) is also written as a JSON document, so a binding
generator in any language can consume it without dlopening a native artifact.
It is a faithful serialization of the C struct, driven by the identical codegen
tables, so the two can never drift; it is deterministic (source order, stable
version strings) and human-diffable, which makes the source artifact
content-addressable. Fields:
| Field | Meaning |
|---|---|
schema_version |
"1.0" functions only, "1.1" adds closures, "1.2" adds constants, "1.3" adds structs and source signatures (the highest feature level present) |
aether_version |
Compiler version stamp |
primary_source |
The .ae the artifact was built from (mirrors the path passed to the compiler: pass a relative path for a reproducible, machine-independent value) |
capabilities |
The --with grants this artifact was built with (e.g. ["fs","net"]), empty when capability-empty. Because the emitted C only contains code paths for granted capabilities, this is the syscall surface a consumer can inspect before compiling the source |
functions[] |
aether_name, c_symbol (the dlsym/link name), signature, source_file, source_line; at schema 1.3 also source_signature, the signature as Aether source ("(m: *Model, p: Vec3) -> void") |
closures[] |
Closure surface reachable from exports: name, role (builder / param / literal), enclosing_export, signature, captures[] (name + type), source_file, source_line |
constants[] |
Exported scalar/string consts: name, type, value |
structs[] |
Schema 1.3: the structs the exports use, name, kind ("", "extern", "extern_packed"), fields[] (name, type as Aether source, flags), source_file, source_line |
host_abi |
Schema 1.11: the host ABI level of libaether.h the library was built at (abi-policy.md) |
runtime_abi |
Schema 1.11: the runtime ABI it was compiled for (AETHER_RUNTIME_ABI_ID, such as "0.856.0/abi1") |
requires[] |
Schema 1.11: what its importer must do (outcome_check, string_release, shared_runtime, runtime_abi, actors); an importer that does not know one refuses the library |
{
"schema_version": "1.0",
"aether_version": "0.0.0-dev",
"primary_source": "mylib.ae",
"capabilities": [],
"functions": [
{ "aether_name": "add", "c_symbol": "aether_add", "signature": "(int, int) -> int", "source_file": "mylib.ae", "source_line": 4 }
],
"closures": [],
"constants": []
}Every exported Aether function becomes aether_<name> in the library:
| Aether declaration | C symbol |
|---|---|
sum(a: int, b: int) { ... } |
aether_sum |
greet(name: string) { ... } |
aether_greet |
build_config(env, port) |
aether_build_config |
The internal sum, greet, etc. symbols are also present in the
library with external linkage. Callers should use the aether_<name>
entry points for ABI stability, the un-prefixed names are an
implementation detail and may be hidden in a future version. See
Symbol visibility matrix below for the
full picture across emit modes.
A function written as several clauses (fact(0) -> ..., fact(n: int) -> ...) is one export: one aether_fact, one catalog record and one header
prototype, with the set's signature: a parameter for each position, of the
type the clauses give it (a literal pattern such as fact(0) has its
literal's type), and the set's return type (#2665). A @c_callback("sym")
function is listed in the catalog under sym, and its aether_<name>
stub calls sym (#2666); a clause set annotated @c_callback takes the
symbol its first annotated clause binds.
A library built from a .ae that defines main() keeps it. Under
--emit=lib (and --emit=staticlib, --emit=obj, --emit=csrc, which share
its codegen) the program's entry point is exported as a pair:
int aether_main(int argc, char** argv); /* run main(), return its result */
void aether_main_exit(void); /* stop what main() started */aether_main runs the same prologue an executable's main runs (argument
setup, the opt-in Capsicum sandbox, and, when the program has actors or
imports a library that does, the scheduler started with the main thread
marked as not a scheduler thread), then main()'s body, and returns. Its
result is main()'s int (0 when main() returns nothing). Actors the body
spawned keep running.
aether_main_exit runs the executable's epilogue: it waits for the actors to
go quiet, stops and joins the scheduler threads, and prints the
message-pool report where the executable would.
An executable is that pair run back to back, from the same emitter:
int main(int argc, char** argv) { /* what --emit=exe amounts to */
int rc = aether_main(argc, argv);
aether_main_exit();
return rc;
}with one ordering difference: main()'s defers (an explicit defer, and the
automatic frees of its local strings and sequences) belong to
aether_main's stack frame, so in a library they run when aether_main
returns, before the actors are drained, where an executable runs them after.
Don't defer the release of something main() hands to its actors.
When a host calls each. aether_main when it wants the program to start,
aether_main_exit when it is done with it. On Android, where the app is the
library:
/* AetherActivity's JNI side */
JNIEXPORT void JNICALL Java_..._onCreate(JNIEnv* env, jobject self) {
aether_main(0, NULL); /* builds the UI, returns into Android's loop */
}
JNIEXPORT void JNICALL Java_..._onDestroy(JNIEnv* env, jobject self) {
aether_main_exit(); /* drain the program's actors */
}A desktop host does the same around its own loop, or calls both back to back to behave like the executable.
Linking it into an executable needs no C main(). The generated C (what
aetherc --emit=lib writes, and --emit=csrc), and the --emit=obj and
--emit=staticlib output, also define that back-to-back pair as a weak
main():
__attribute__((weak)) int main(int argc, char** argv) {
int rc = aether_main(argc, argv);
aether_main_exit();
return rc;
}So any build tool that links the object into a program (aeb's c.program, a
Makefile, cc app.o $(ae cflags --libs)) gets the program's entry point, and
its exit code, without a hand-written C file. A host that has its own main()
keeps it: a strong definition beats a weak one. A shared library that ae
links itself (--emit=lib, native or --target=) leaves it out (it exports
no entry point), as does wasm (emscripten runs a module's main on load,
which would turn a library into a program) and a compiler without weak
definitions. -DAETHER_NO_LIB_MAIN leaves it out anywhere else.
On Windows (MinGW) the entry is an archive member instead. A COFF weak
definition is a weak external, and GNU ld (2.46 at least) still searched the
archives for main: it pulled libmingw32.a's crtexewin.o, a strong
main() that calls WinMain, and the link failed with undefined reference to 'WinMain'. So the object carries no main() there, and the same three
lines are the one member of libaether_main.a, installed beside
libaether.a. ae cflags --libs names it first (-L<lib> -laether_main -laether ...), and an archive member is linked only when the link still
needs the symbol it defines, so:
cc app.o $(ae cflags --libs)links the program's own entry, as elsewhere;- a host with its own
main()keeps it (the member is never pulled), with nothing to change: the same command line works on every platform; - a GUI host that enters through
WinMainnames the C runtime's entry first,cc app.o host.c -lmingw32 $(ae cflags --libs), so thatmain()is resolved (to the one that callsWinMain) beforelibaether_main.ais searched. Without that it starts in the program'smain().
A link line written by hand rather than from ae cflags --libs adds
-laether_main before -laether itself. The shared runtime (aether.dll)
and every DLL ae links export no main.
Calling them more than once.
aether_mainwhile the program is already running (noaether_main_exitsince) is rejected: it printsaether_main: already running; call aether_main_exit() firston stderr, runs nothing, and returns-1.aether_main_exitwith nothing running is a no-op, so calling it twice is safe, as is calling it whenaether_mainnever ran.- After
aether_main_exit,aether_mainmay run the program again on a fresh scheduler: an Android activity destroyed and recreated in the same process. The previous run's scheduler tables are freed; the actor objects it spawned are not (an executable leaves those to process exit too). - Call both from one thread, the host's main or UI thread. They are not synchronized against each other.
- Stop the host's own threads that send to the program's actors before
aether_main_exit: its drain waits for the actors to go idle. A send from such a thread while the program is not running is dropped, counted byscheduler_stopped_sends()and reported once (#2912).
Finding them. They are plain exported symbols: dlsym(h, "aether_main"),
a weak reference, or the --emit=csrc header, which declares both when the
program has a main(). They are not catalog functions (aether_lib_meta
lists the library's callable functions, and an Aether program importing the
library must not bind a main). A library without main() has neither. A
wasm --emit=lib exports the catalog by default; add --export=main --export=main_exit (with the rest of the list you want) to export the pair.
--emit=both keeps the executable's C main and adds no pair.
Reserved names. Every top-level function exports as aether_<name>, so a
function called main_exit would export as aether_main_exit. In a program
that defines main(), --emit=lib rejects it with a compile error naming
both. Rename it, or end its name with _ to keep it private. Without a
main(), or in an executable, main_exit is an ordinary name.
Parameter and return types on exported functions are mapped to a fixed public ABI:
| Aether type | Public C type | Notes |
|---|---|---|
int |
int32_t |
Fixed width for cross-language clarity |
long |
int64_t |
The Aether source keyword for 64-bit integers is long; the lexer token TOKEN_INT64 backs both long and int64_t internally |
float |
double |
IEEE 754 binary64, as everywhere in Aether |
f32 |
float |
IEEE 754 binary32 |
bool |
int32_t |
0 = false, 1 = true |
string (parameter) |
const char* |
Borrowed for the call |
string (result) |
const void* |
A string the caller owns; see below |
ptr / list / map |
AetherValue* |
Opaque handle; walk with aether_config_* accessors |
*Struct |
AetherValue* |
Opaque to a C host; an Aether importer gets the typed pointer back (the catalog's source signature) and reaches the fields |
Struct (by value) |
the struct, by name | Its layout is in the catalog's struct records, which an Aether importer re-declares; an --emit=csrc header leaves these prototypes out and says why (#2297) |
fn(A, B) -> R |
void* |
A C function pointer |
fn (a closure) |
_AeClosure |
Its function and captured environment, by value. Both ways: a library calls the closure a program passes, and a program calls one the library returns, captures included. Only an Aether importer declares _AeClosure and its calling convention, so an --emit=csrc header leaves these prototypes out, as for a struct by value (#2297) |
A -> string export hands its caller one reference to a counted string,
as any Aether function does (R3 in
the ownership model). The caller reads it
with aether_string_data() and aether_string_length() and gives it back
to the library's runtime, which made it, through the library's release
function, aether_<lib>_string_release:
const void* s = aether_greet("world");
printf("%.*s\n", (int)aether_string_length(s), aether_string_data(s));
aether_greet_string_release(s);<lib> is the library's name: its package (--package), else its entry
file's stem (greet.ae is greet), so every target built from one source
exports the same function; a --namespace library is named for its
library file (libgreet.so is greet). Every character outside
[A-Za-z0-9] is written as _. A host never derives it: the catalog names it
(string_release, schema 1.9, and "string_release" in the JSON), and
ae lib-info prints it as String release:. An --emit=csrc header
declares it. The bindings ae build --namespace generates read and release
the string for you, and a binary import copies it into the program's
runtime and releases the library's reference.
Functions whose parameters or returns use types outside this table
(tuples, optionals, actor refs, enums) compile but don't get an
aether_<name> alias. aetherc prints a warning naming the skipped
function. You can still use those types internally; they just can't
cross the FFI boundary yet.
Two private-helper conventions also opt out of the aether_<name>
surface:
- Trailing-underscore: a function whose name ends in
_(e.g.record_start_,helper_,parse_line_) is treated as file-local. It's emitted with Cstaticstorage and gets noaether_*alias. This lets two.aefiles in the same project declare their ownrecord_start_without colliding at link time and without leaking either into the public ABI. The convention is enforced by codegen, so adding the_is enough, no annotation. - Tuple-returning helpers:
helper(n: int) -> { return n, n+1 }returns a_tuple_int_intstruct that doesn't fit the public ABI table. The function itself is fine; the alias is skipped. Wrap with a single-value-returning function if it needs to be exposed across the library boundary.
Composite Aether values, maps, lists, generic ptrs, come back to the
host as AetherValue* handles. The host walks them with the functions
declared in runtime/aether_config.h:
// Map accessors
const char* aether_config_get_string(AetherValue* root, const char* key);
int32_t aether_config_get_int (AetherValue* root, const char* key, int32_t default_value);
int64_t aether_config_get_int64 (AetherValue* root, const char* key, int64_t default_value);
float aether_config_get_float (AetherValue* root, const char* key, float default_value);
int32_t aether_config_get_bool (AetherValue* root, const char* key, int32_t default_value);
AetherValue* aether_config_get_map (AetherValue* root, const char* key);
AetherValue* aether_config_get_list (AetherValue* root, const char* key);
int32_t aether_config_has (AetherValue* root, const char* key);
// List accessors
int32_t aether_config_list_size (AetherValue* list);
AetherValue* aether_config_list_get (AetherValue* list, int32_t index);
const char* aether_config_list_get_string (AetherValue* list, int32_t index);
int32_t aether_config_list_get_int (AetherValue* list, int32_t index, int32_t default_value);
int64_t aether_config_list_get_int64 (AetherValue* list, int32_t index, int64_t default_value);
float aether_config_list_get_float (AetherValue* list, int32_t index, float default_value);
int32_t aether_config_list_get_bool (AetherValue* list, int32_t index, int32_t default_value);
// Lifetime
void aether_config_free(AetherValue* root);| Return value | Ownership |
|---|---|
Root AetherValue* from an aether_<name>() call |
Owned, call aether_config_free(root) |
Nested map/list handle from _get_map / _get_list / _list_get |
Borrowed, valid until the root is freed; do NOT free individually |
const char* from _get_string / _list_get_string |
Borrowed, points into the tree; copy if you need it past aether_config_free |
- Typed getters return the
default_valuethe caller supplied. _get_string/_get_map/_get_list/_list_get_stringreturnNULL._list_size(NULL)returns0;_has(NULL, k)returns0.
Aether maps and lists are untyped internally, values are stored as
opaque void* with no runtime tag. The accessors reinterpret the stored
value as the requested type; they do not verify it. That makes the
script-to-host interface a straight FFI contract: document what the
script writes at each key, and the host must read it back with the
matching accessor. If the script stored an int at "port" and the
host asks for a string, the host gets garbage, treat this exactly the
way you would treat any other cross-language value exchange.
--emit=lib rejects .ae files that import:
std.net,std.httpand its children,std.tcp,std.udpnetworkingstd.fs,std.file,std.dirfilesystemstd.os,std.dlprocess, environment, shell, and dynamic loadingcontrib.host.*foreign-language runtimes
The rationale: a library embedded in a host process shouldn't have ambient network, filesystem, or process-spawning access. Those are capabilities the host grants; the script should only compute and return data. The host mediates I/O through whatever its own runtime provides.
std.map, std.list, std.string, std.json, std.math, and the
other capability-free standard modules are allowed.
It also rejects an extern declared in the program's own code (the entry
file, an @extern("…") binding, or a local module it imports). An extern
names a C function directly, so extern system(cmd: string) -> int would
reach what import std.os is gated to stop. Externs declared by other std.*
modules are not the program's and are unaffected. Foreign-language host modules
sit behind the contrib.host.* gate above. Opt in with --with=extern.
Projects that are the host, code that compiles .ae and
handwritten C into one binary, rather than embedding Aether as an
untrusted user script, opt into specific capability categories:
ae build --emit=lib --with=fs file.ae # std.fs, std.file, std.dir
ae build --emit=lib --with=net file.ae # std.net/http/tcp/udp families
ae build --emit=lib --with=os file.ae # std.os, std.dl
ae build --emit=lib --with=fs,os file.ae # multiple, comma-separated
ae build --emit=lib --with=extern file.ae # own C externs, contrib.host.*
ae build --emit=lib --with=first-party file.ae # alias for fs,net,os,extern
ae build --emit=lib --with=all file.ae # alias for fs,net,os,externThe gate stays default-deny: a build without --with=fs still rejects
import std.fs. Unknown capability names are a hard error (typos
shouldn't silently leave a gate closed). The categories mirror the
banned-import groupings above, three buckets, chosen coarsely enough
that opting in is an auditable event in a project's build invocation.
--with=first-party and --with=all both expand to fs,net,os,extern.
The two names are equivalent; pick whichever expresses intent better
in your project. first-party reads as "this Aether code is
trusted-as-first-party, give it everything"; all reads as a literal
shorthand for the full set. Using either one is appropriate for tools
like build systems and SDK generators where the .ae files ship with
the toolchain itself; it is not appropriate for plugin / user-
script scenarios, see "When NOT to use this" below.
When to use this. Systems code where the .ae files are
first-party and version-controlled the same way as the .c files.
The Aether-side file_open_raw is no different from the C side
calling fopen there's no privilege boundary to police.
When NOT to use this. Anywhere the library could run untrusted Aether (user scripts, a plugin loader, a DSL evaluator). Leave the default in place; the host mediates I/O on the script's behalf.
What gets static storage class versus external linkage in the
generated .c file, by function origin × emit mode. This determines
which symbols collide when multiple .c files compiled from
different .ae sources are linked into one binary.
| Function origin | --emit=exe |
--emit=lib |
|---|---|---|
Local (defined in this .ae) |
external | aether_<name> alias exported; its own name hidden (#2937) |
Local with trailing _ (helper_) |
static |
static (no aether_<name> alias) |
| Local with tuple return type | external | external (no aether_<name> alias) |
| Imported Aether wrapper | static |
static (no aether_<name> alias) |
extern declaration (any module) |
declaration only, refers to external symbol from libaether.a or another TU |
|
Stdlib C externs (std/*/aether_*.c) |
linked from libaether.a; no per-TU duplication |
Two consequences worth highlighting:
Imported Aether wrappers are private to each TU. When module A
and module B both import std.string, both generated .c files
contain static AETHER_MAYBE_UNUSED const char* string_copy(...) a
private copy each. The attribute is there because a TU that calls part
of a module leaves the rest of the private copies uncalled, and a
consumer building the generated .c with -Wall -Werror would
otherwise fail on -Wunused-function in code it did not write.
Linking the two .o files together produces no duplicate-symbol
errors, even on linkers that don't support
-Wl,--allow-multiple-definition (notably macOS ld64). This is
deliberate; see compiler/aether_module.c
where cloned imported functions get clone->is_imported = 1, and
compiler/codegen/codegen_func.c
where the static AETHER_MAYBE_UNUSED prefix is emitted for functions
guarded by func->is_imported || trailing_underscore_private.
A library calls its own functions, not a host's. A function with an
aether_<name> alias is emitted with hidden visibility under its own name
(AETHER_LIB_LOCAL): it still links across the library's translation
units, but it is not exported, so on ELF, where a call through an exported
name binds to the first definition in the process, a host or another
library defining the same name cannot take over the library's calls
(#2937). A function without an alias (a builder, a tuple returner) and a
@c_callback keep their exported names.
Locally-defined functions retain external linkage. If two .ae
files both define a function with the same name (e.g. both define
helper), they DO collide at link time. --emit=lib adds an
aether_<name> alias on top, but the un-aliased local symbol is
still external. This is the symmetric opposite of the imported case
and is intentional, local functions are the unit of inter-TU
linking; the user program structures around their names.
Implication for downstream tools. Multi-TU binaries that import
the same SDK module across many .ae files do not need
-Wl,--allow-multiple-definition. The static-marking on imported
wrappers handles deduplication at the source level. Tools building
this shape can drop the link flag and gain macOS compatibility for
free, ld64 (Apple's linker) silently rejects
--allow-multiple-definition, and the static-marking removes the
need for it.
The repo ships a minimal SWIG interface at runtime/aether_config.i.
It wraps aether_config.h and marks aether_config_free as the
destructor so target-language GC integrates cleanly.
# Python
swig -python -o aether_config_wrap.c runtime/aether_config.i
gcc -fPIC -shared $(python3-config --includes) aether_config_wrap.c \
libconfig.so $(python3-config --ldflags) -o _aether_config.so
# Java
swig -java -package com.example.aether runtime/aether_config.i
# Ruby, Go, C#, ... same pattern with -ruby, -go, -csharp.The generated bindings wrap AetherValue* as a proxy class per target
language, Python gets AetherValue, Java gets a class AetherValue,
etc. Users see idiomatic API calls; the opaque pointer is hidden.
A -> string export returns a handle the caller gives back
(Strings an export returns), so wrap it
with a helper that copies the bytes out, calls the release, and returns
the copy as %newobject. See tests/integration/emit_lib_swig/ for a
worked Python round-trip, that helper included.
Hosts loading untrusted Aether scripts bound the script's
resource use via two TLS-backed knobs in include/libaether.h:
#include <libaether.h>
aether_set_memory_cap(64 * 1024 * 1024); // 64 MiB ceiling
aether_set_call_deadline(50); // 50 ms wall-clock
call_into_aether(); // returns inside both bounds
if (aether_last_outcome() == AETHER_DEADLINE) {
/* The deadline fired mid-call: the call ended at a safe point
* and returned its zero value. Any partial state is the
* host's to interpret. */
}Memory cap. Process-wide. The cap-aware allocators (std/string,
std/collections, runtime/memory/{arena,pool}) check + account
against a single _Atomic uint64_t counter. The counter tracks
current usage, not high-water-mark, long-running guests that
allocate-and-free in a loop don't trip the cap on cumulative churn.
Allocations past the cap return NULL through the existing
error-string convention; stdlib functions surface "out of memory:
cap exceeded" up the call chain rather than aborting. Pass 0 to
disable.
Allocation failure ends the call (#2956). Inside an export, an
allocation that fails, past the cap or with the allocator out of memory,
still fails cleanly where it happens (the std operation returns its
error, its own memory freed), and the call ends at its next safe point,
or when it returns, with AETHER_MEMORY_CAP or AETHER_OUT_OF_MEMORY
and the zero value: its defers run, and a try in the script does not
catch it. The call does not unwind from inside the allocator, which would
skip the C code between it and the safe point. An allocation the code
cannot go on without ends the call at once with AETHER_OUT_OF_MEMORY: a
message's payload copy, make([]T, n), and every allocation the generated
code makes for itself (a captured variable's cell, ref, lazy, a
closure's environment, a closure boxed into a ptr, heap.new, #3377).
Outside an export a send stops the process as before, and the others are
a panic naming what ran out (they used to dereference NULL). A malloc
the program declares and calls itself still returns NULL, as C's does,
and ends the call when it next reaches a safe point or returns.
Neither the outcome nor the unwind allocates: the reasons are static
strings and the journal is drained in place. One failure still stops the
process, inside an export or not: a message the scheduler has no memory to
queue behind an actor's earlier messages, since it has no place that keeps
their order.
Wall-clock deadline. Per-thread monotonic (CLOCK_MONOTONIC).
The codegen emits a safe point, if (aether_caps_armed && aether_exec_poll()) { __aether_abort_call(); break; }, at every for / while loop head
under --emit=lib not under --emit=exe (zero overhead on
non-sandboxed builds). aether_caps_armed is a global that goes
nonzero once any thread arms a deadline, trips one or cancels a call, and
stays so; until then a loop head costs one load, with no call and no
thread-local read. It is an atomic read with a relaxed load (a plain load
on x86 and ARM), so a loop that started before another thread's cancel
sees it. (The call reads thread-locals, and on macOS each of
those is itself a call: in a ported interpreter that was a fifth of the
run time.) Pass 0 to disable; pass a fresh ms value to clear the sticky
flag and re-arm.
Interruptions (#2956). Inside an export, the first safe point past the
deadline ends the call: the interruption unwinds like a panic, so every
frame on the way runs its defers and releases its owned locals, and the
export returns its zero value with the outcome AETHER_DEADLINE. A try
in the script runs its own cleanup and passes the interruption on instead
of running its handler: a script cannot swallow its deadline. Another
thread can end a call the same way: aether_exec_token() on the calling
thread names the running call (or, outside one, its next), and
aether_exec_cancel(token) from any thread ends it at its next safe point
with AETHER_CANCELLED. A call nested inside the cancelled one ends first;
the cancelled one at its own next safe point, once the host code between
them has returned. A cancel of a call that already ended does nothing.
Outside any export (a library's actor, say) a loop head breaks at a passed
deadline, as it did before. An actor stepped on the call's thread inside
it (a send's inline step, a cooperative ask's poll) is outside it too: the
call's deadline and cancel do not reach its loops, a try in it catches
its own panics, and the call is cut at its own next safe point once the
step returns (#3392). The drops its step runs after its handler (of the
message a panicking handler ran on, and of the messages waiting for an
actor the step ended) are outside the call as well (#3416). Code the runtime runs inside the call in a frame of
its own (the drop of an ending actor's messages, when the call ends an
actor there and then) is cut by the interruption, and the interruption
goes on to the call: it ends at the call's next safe point, or when it
returns, and a later call's try is not touched by it (#3404). Such a frame
tells an interruption from a panic by what its own code raised: one already
on its way when the code began (a defer the interruption's unwind runs
ends an actor, whose drops run there) goes on as it was, and an ordinary
panic in a drop is the drop's, reported as any is, so the call still ends
with the interruption's outcome and a try on the way passes it on (#3415).
The safe points are loop heads, and json.parse, which checks every 4096
values it reads and then fails with "stopped: the call's deadline passed or
it was cancelled" (the export then ends with the outcome). Nothing else
stops a call, so these are bounded only by their own length:
- straight-line code, however long;
- recursion without a loop: a safe point at every function's entry would cut it, but it made fib(30) through its stub 1.18 ms against 0.69 ms, so there is none;
- C-backed std operations other than the JSON parse (string split, join and
replace,
json.stringify, a regex match, compression), each bounded by its input; externC functions, and host services the script calls: they run to their return, and the next safe point after it ends the call.
The trip is also observable via aether_deadline_tripped(). Retrying a
tripped deadline is the host's policy: re-arm + call again with a more
generous budget, or surface Deadline to the user.
The cap and the deadline above belong to the process and to one call. A host that runs many scripts on one thread, each yielding to it and resumed later (a game's scripts, an editor's plugins), needs a budget that belongs to the script: an execution account.
AetherExecLimits lim = { 256 * 1024, 5000, AETHER_DEADLINE_ACTIVE, NULL };
AetherExecAccount* script = aether_exec_account_new(&lim);
AetherExecAccount* was;
aether_exec_use(script, &was); // this thread's calls charge `script`
int r = aether_on_frame(dt);
if (aether_last_outcome() == AETHER_MEMORY_CAP) { /* past its 256 KiB */ }
aether_exec_use(was, NULL); // back to the host's own
if (aether_exec_account_tripped(script)) drop(script);
aether_exec_account_free(script);- Memory. While an account is in use on a thread, every cap-aware
allocation made there (strings, collections, JSON, the std buffers) is
charged to it and to its parent chain. One that would take any of them
past its
memory_bytesis refused as the process cap refuses one: the call ends at its next safe point withAETHER_MEMORY_CAP, and the account'saether_exec_account_trippedhasAETHER_ACCOUNT_TRIPPED_MEMORY. The process cap stays the outer ceiling. - A block stays the account's that allocated it until it is freed, wherever that happens: in a call under another account, or in the host outside any call. So a string a script hands its host keeps counting against the script until the host releases it, and two scripts on one thread never charge each other.
- Deadlines. A call under an account has the earliest deadline on the
account's chain, or the call deadline (
aether_set_call_deadline) if that is earlier.AETHER_DEADLINE_ELAPSED(the default) counts wall time fromaether_exec_account_new, so the time a script spends suspended counts;AETHER_DEADLINE_ACTIVEcounts only the time spent in calls under the account, once however calls nest: a call a host service makes inside another, under the same account or a descendant of it, adds no time of its own to it (#3409). Past it the call ends withAETHER_DEADLINE, and the account keepsAETHER_ACCOUNT_TRIPPED_DEADLINE. - A parent only narrows. An account made with a
parentcharges what it holds to the parent too, and its deadline is never later than the parent's: a host gives each plugin an account and each of the plugin's scripts a child of it. Aparentthat is no live account of these accounts (one the host freed, or one another runtime copy made without sharing them) makesaether_exec_account_newreturn NULL (#3412): the child is refused, never made wider than the parent meant. - Nesting. An execution puts back the account that was in use when it began, whatever the code it ran set: a host service that runs another script under its own account, inside a call, leaves the outer call's account as it was.
- Threads. An account is in use on one thread at a time:
aether_exec_usereturns -1 for one in use on another thread, and it may move to another thread once let go there. Allocations on other threads (an actor's handler) are not charged to it, nor those of an actor stepped on this thread while it is in use (a send's inline step, a cooperative ask's poll), the drops its step runs after its handler included (#3416): a step runs in its own context (actor-concurrency.md, "The delivery contract"). The refusal on another thread is a threaded build's: a cooperative build (AETHER_NO_THREADING) runs one thread per program by contract, so there is no other thread to refuse. - Ownership. The account is the host's from
aether_exec_account_newtoaether_exec_account_free; the runtime keeps nothing oflimitspast the call, writespreviousand keeps nothing of it. Freeing an account detaches its blocks, which then count only against the process cap: its ancestors are credited with what they were charged for them (#3408). Its children go under its own parent, which their blocks were charged to all along, and keep narrowing under it. Nothing in the runtime names a freed account (#3407): in use on the calling thread, it is in use no more; in use on another thread, nothing more is charged to it there, and that thread lets go of it at its next allocation, call oraether_exec_use; an execution that began under it puts no account back when it returns. A freed account is the host's no more: passing it again is not supported. - An Aether host has the same in std.host:
host.account_new(bytes, ms, host.DEADLINE_ELAPSED, parent),host.account_use(a),host.account_in_use(),host.account_used(a),host.account_tripped(a)andhost.account_free(a).
Runtime copies. A library built without --shared-runtime has a
runtime of its own, and the accounts are still one per process: every copy
works through the accounts of the first loaded image that has them, found
once, at its first account call or execution (the rule the sandbox's
levels follow). There is one lock, one table of blocks and one account in
use per thread, so an account is charged whichever copy allocates, and is
refused on another thread whichever copy is asked.
- The copy that owns the accounts must stay loaded while another runs: unloading it first is not supported. Any other copy may be unloaded: it leaves the accounts as it goes (#3411), and is never written into after.
- Each copy marks its image with a record the loader maps with it (a PE
section, an ELF note, a Mach-O section), and the copies read the records
in load order, the program first. So a program's own copy is found though
its executable exports nothing (on Windows, and on Linux without
-rdynamic, as a C host linked withae cflags --libsis), whenever the libraries are loaded (#3445). Records are read on Windows, macOS, Linux and FreeBSD, NetBSD and OpenBSD; elsewhere (Cygwin, illumos, DragonFly) copies look each other up by name in the global scope, and where two do not find each other, a copy refuses (-1) to put in use an account the other made, rather than charge one it does not guard. Copies in a namespace of their own (glibc'sdlmopen) find none outside it. - The accounts carry a version and the sizes of what the copies pass each
other. A copy that finds accounts of another layout (another Aether
version) does not share them: it says so once on stderr, naming
--shared-runtime; itsaether_exec_account_newreturns NULL and itsaether_exec_use-1 for an account; its allocations are charged to none; and it reads nothing of the other's but the version and sizes.
What it costs. While no account exists nothing is charged, and a
cap-aware allocation still tests one word before it calls malloc (the
word also holds the process cap and the test hook); an execution's entry
adds one load, and an actor step one at each end (the delivery contract's
item 5, in actor-concurrency.md).
While an account exists anywhere in the process (#3410):
- A thread with no account in use takes no lock. Its cap-aware allocation or free reads its account in use and one word of a filter of the addresses the accounts recorded (32 KiB); only an address whose bit is set (another account's block, or one sharing its bit) takes the accounts' lock to look the table up. Actor cores and HTTP workers pay nothing for a script's account. A clear bit is enough: a block is recorded under the lock with its bit set first, before the allocator returns it, and whatever thread frees it got the pointer after that, so it sees the bit; a free that finds the bit clear is of a block no account was charged for. The bits are cleared only when the table holds no block. A false positive, a free whose bit another block set, costs a table probe under the lock: with n blocks recorded about n / 262,144 of such frees (8% with 20,000).
- A thread with an account in use takes the lock for each allocation and free (a table probe and a walk of the account's chain), so threads that each have one contend on it. A waiter pauses, then yields.
- Freeing an account is one pass over the table, under the lock.
Measured on Windows x86_64 (MinGW, the msvcrt heap), a 64-byte
aether_caps_malloc and aether_caps_free, best of 5 passes of 2 million
on each thread:
| Setting | 1 thread | 3 threads |
|---|---|---|
| no account exists | 27 ns | 81-145 ns |
| none exists, one more thread allocating (heap contention only) | 74-99 ns | 123-198 ns |
| an account in use on that other thread, holding 20,000 blocks | 61-82 ns (199-252 before #3410) | 102-156 ns (697-716 before) |
| each thread its own account | 46 ns (41 before) | 374-407 ns (532-570 before) |
Freeing an account of 40,000 live blocks takes 0.5 ms (1.1 s before).
Every aether_<name> export (and every package builder export) runs inside
an execution frame (#2955, #2956). How each call ended is its outcome:
#include <libaether.h>
int r = aether_parse(text);
switch (aether_last_outcome()) {
case AETHER_OK: use(r); break;
case AETHER_PANICKED: log("script failed: %s", aether_last_outcome_text()); break;
case AETHER_DEADLINE: /* cut by its deadline: r is not a result */ break;
}- A panic ends the call, not the process. A panic the export does not
catch used to print "panic outside any try/catch or actor" and abort the
host. It now ends the call with
AETHER_PANICKED, the export returns the zero value of its result (NULL for a string, which the release accepts), andaether_last_outcome_text()gives the panic's message (copied into a fixed buffer, cut at 1023 bytes: nothing is allocated to report it). The library takes the next call at once. Nothing done before the failure is rolled back: host effects, module state, messages sent. - A zero value with an outcome other than
AETHER_OKis not a result. Release only what anAETHER_OKcall returned.AETHER_DEADLINEandAETHER_CANCELLEDmean a safe point ended the call ("Per-call resource caps" above): it returned its zero value, its defers run. A call that ran to its return with a stop noted after its last safe point (an allocation it survived, a JSON parse that stopped, the deadline's flag) ends with that outcome and returns its zero value as well (#3388): the stub releases what the function returned, a string, or the strings and closures of a struct or closure by value. Aptror*Structresult owns nothing (R4), so what it points to is not released, as on a panic. An Aether importer gives such a string back to the library before it raises the failure, whichever version built the library. - Nested calls. An export that calls a host service, which calls an
export again on the same thread, runs two executions. The inner one sees
what the service set for it (caller info,
aether_set_call_deadline), has its own outcome, and whatever it leaves behind is undone when it returns, on every exit: the outer one's caller info, deadline (and trip flag) and sandbox levels are as they were (lib-caller-info.md, containment-sandbox.md). Executions nest up to 32 deep on one thread. - An Aether program importing the library in binary form gets a failed
call raised again as a panic in its own runtime, with the same reason, so
a
tryaround the call catches it;host.last_outcome()andhost.last_outcome_text()in std.host read the outcome without one. The stub reads it from the library's own runtime through the accessor the catalog names (outcome, schema 1.10), since a library with a runtime of its own records it there. - What a call costs. Catching a panic needs the stub to save its
context, where a panic can return to, on every call; nothing else is paid
unless something asks for it (a nested call, a deadline ever armed, a
sandbox level on some thread, caller info changed during the call). On
x86 and x86-64 the stub saves it with
__builtin_setjmp(0.57 ns; the C library's setjmp is 2.85 ns on Windows), elsewhere with setjmp. Measured on Windows MinGW, a C host calling in a loop (medians of 9 interleaved rounds against main): an export that calls a function costs 5.7 ns a call against 0.4 ns, about 2 ns of it the registers Win64 makes the stub keep (rbx, rbp, rsi, rdi, r12 to r15, xmm6 to xmm15) for a panic's return; fib(30) through its stub is 0.699 ms against 0.701 ms, the difference lost in the recursion. An export that can neither panic, allocate, loop nor call anything (scalar parameters and locals,+ - *, comparisons, if/else, return) gets no frame and records its OK outcome only: 0.57 ns against 0.19 ns. - A library made for another runtime runs nothing (#2957). A
--shared-runtimelibrary on a runtime of another ABI than the one it was compiled for (a shared runtime replaced in place), or--emit=csrccode linked against another Aether's runtime, finds out at its first call: every export returns its zero value withAETHER_INCOMPATIBLE(6), none of its code runs, and the reason, naming both runtimes, is printed once and inaether_last_outcome_text(). A host asks first withaether_<lib>_check(const char** why): 0, or -1 with the reason. The same C compiled against another Aether's headers does not compile. A host treats an outcome it does not know as a failure. abi-policy.md has what is supported across versions. - The host API is in every library. A library built with its own
runtime carries
aether_set_call_deadline,aether_deadline_tripped,aether_set_memory_cap,aether_set_caller,aether_clear_caller,aether_last_outcome,aether_last_outcome_text,aether_exec_token,aether_exec_canceland the execution accounts'aether_exec_account_new,aether_exec_account_free,aether_exec_use,aether_exec_account_usedandaether_exec_account_trippedeven when its code calls none of them, so a host that loads it can call them in its runtime (a library DLL had noaether_set_call_deadlinebefore). On the shared runtime they are the shared runtime's.
- Callbacks held live, a host can't pass a closure into Aether and have it retained. If you want this (the ARexx / rules-engine model), track the "Shape B" design note in aether-embedded-in-host-applications.md.
- Per-function capability grants,
--with=flags are coarse (fs / net / os). Fine-grained gates like "allow file_open but not dir_delete" don't match any concrete threat model for the default-deny shape, and every additional flag is API surface. - Typed returns beyond
void*, functions returningmap/listcome back asAetherValue*with no schema. Host knows the shape.
Aether's collections are intentionally untyped at the storage level, a
map's value slot is void*, and the host knows the schema. That
historically meant schema-mismatch bugs (script stores an int where a
map was expected) degraded into host-side segfaults. v1 closes that
gap with a magic-tagged kind discriminator.
Every HashMap and ArrayList carries a uint32_t _kind_magic as its
leading field, set at construction and cleared on free. The runtime
exposes:
typedef enum {
AETHER_KIND_UNKNOWN = 0,
AETHER_KIND_MAP = 1,
AETHER_KIND_LIST = 2
} AetherKind;
AetherKind aether_value_kind(AetherValue* v);
int32_t aether_value_is_map(AetherValue* v);
int32_t aether_value_is_list(AetherValue* v);These are safe to call on any pointer the host holds, including
scalars that were intptr-cast to AetherValue* (e.g. (void*)(intptr_t)42
from a schema-mismatched _get_map call). The probe applies two
layers of safety:
- Low-address guard, the zero page + first 64 KiB are unmapped
on every supported OS (Linux's
vm.mmap_min_addrdefault is 65536; macOS reserves more; Windows likewise). Any pointer below0x10000is filtered before any deref attempt, so common intptr-cast scalars never reach the magic check. - 32-bit magic check, for higher-address values that survive
the guard, the leading 4 bytes either match one of the two known
constants (
AETHER_KIND_LIST_MAGIC/AETHER_KIND_MAP_MAGIC) or the predicate reportsUNKNOWN. False-match probability for a non-container value is 2 / 2³² ≈ 5 × 10⁻¹⁰.
aether_config_free_deep(root) builds on the predicate to walk a
nested tree of containers and free each one in post-order. Scalars
encountered along the way are left untouched (the host owns those
separately). aether_config_free (shallow) auto-detects whether the
root is a map or a list via the same predicate, so it now works on
either container shape uniformly.
AetherValue* root = aether_build_config("prod", 8080);
if (aether_value_is_map(root)) {
/* Defensive, confirm what the script actually returned. */
}
AetherValue* db = aether_config_get_map(root, "db");
if (aether_value_kind(db) == AETHER_KIND_MAP) {
const char* host = aether_config_get_string(db, "host");
/* … */
}
aether_config_free_deep(root); /* nested map + list freed too */Defense-in-depth: list_free and map_free clear the magic before
releasing the struct's memory, so a use-after-free probe via
aether_value_kind reads the cleared magic and reports UNKNOWN
rather than a stale match on the freed-but-still-readable original
value.
Every --emit=lib artifact exports a single reflection entry point:
const AetherLibMeta* aether_lib_meta(void);declared in runtime/aether_lib_meta.h. The returned struct describes
every exported function, Aether name, C symbol, public signature,
source file, source line, in a layout-stable, allocation-free form
that any FFI consumer (Python ctypes, Java Panama, Ruby Fiddle,
Node-API, hand-rolled dlsym) can walk directly.
The entry point is emitted weak (__attribute__((weak)) under
GCC/Clang, #1590), so an orchestrator that links several --emit=lib
TUs into one artifact (aeb's regen shape: one C per module, one final
link) needs no -Wl,--allow-multiple-definition: the multi-TU
static-clone link model of 0.539 holds for lib TUs too, on ld64 as well
as GNU ld. In a lone .so nothing changes: one definition, same dlsym
contract. In a deliberate multi-TU link the first TU's catalog wins;
treat aether_lib_meta as meaningful only on single-module artifacts.
typedef struct {
const char* aether_name; /* "double_int", "std.fs.copy" */
const char* c_symbol; /* unmangled symbol to dlsym */
const char* signature; /* "(int) -> int" */
const char* source_file; /* the .ae that defined it */
int source_line; /* 1-based */
} AetherLibFunction;
typedef struct {
const char* schema_version; /* "1.0" funcs; "1.1" closures; "1.2" consts; "1.3" structs */
const char* aether_version; /* compiler version */
const char* primary_source; /* the .ae passed to aetherc */
int function_count;
const AetherLibFunction* functions;
int closure_count; /* v2: closure-surface records */
const AetherLibClosure* closures; /* NULL when closure_count==0 */
int constant_count; /* v3: exported-constant records */
const AetherLibConstant* constants; /* NULL when constant_count==0 */
int struct_count; /* v4: struct records */
const AetherLibStruct* structs; /* NULL when struct_count==0 */
const char* const* source_signatures; /* v4: parallel to functions */
} AetherLibMeta;No JSON, no parsing, no dynamic allocation, the struct is static const
in the artifact and the catalog literally lives in .rodata. Schema is
versioned; within 1.x only additive fields appear at the struct tail,
so a "1.0" reader walks a "1.1" or "1.2" artifact unchanged (it reads
function_count/functions and ignores the closure and constant slots;
those fields were always present, just NULL before). schema_version is
"1.0" for function-only artifacts, "1.1" once closure records are
present, "1.2" once exported-constant records
(constant_count/constants, of type AetherLibConstant) are present,
and "1.3" once struct records or source signatures are (below), and so on
up to "1.9" (runtime/aether_lib_meta.h lists them). Since "1.10" every
catalog has outcome, the library's outcome accessor
(above). Since "1.11"
(#2957) every catalog also has host_abi, the host ABI level it was built
at, runtime_abi, the runtime ABI it was compiled for, and requires, a
comma-separated list of what its importer must do; every library built now
is "1.11". An importer reads any minor of schema 1 and refuses another
major, and refuses a library that requires something it does not know,
naming it and the fix (abi-policy.md). A reader
touches a field only when the artifact's minor version is at least the one
that appended it: an older artifact's struct ends before the newer slots.
The function table includes every
Aether-defined function the linker exports; functions skipped from the
aether_<name> alias surface (unsupported types, tuple returns,
trailing-underscore privates) are also omitted from it.
The flattened function table above is the foreign-FFI surface; it drops
closures, real types, and the builder-DSL shape. The closures array is
the complement: the closure surface a downstream Aether consumer needs
to reconstruct a closure-with-context builder DSL at full fidelity,
rather than the lowest-common-denominator C ABI a foreign host links
against. Each record:
typedef struct { const char* name; const char* type; } AetherLibCapture;
typedef struct {
const char* name; /* param / closure name, or "" */
const char* role; /* "builder"|"param"|"trailing-block"|"literal" */
const char* enclosing_export; /* the export it is reachable from */
const char* signature; /* "|int, string| -> bool" */
int capture_count;
const AetherLibCapture* captures; /* NULL when capture_count == 0 */
const char* source_file;
int source_line;
} AetherLibClosure;Three sources populate it, all reachable from the set of top-level,
non-imported, non-_-suffixed functions (which is broader than the
ABI function table, a builder or a fn-parameter function is exactly
what the ABI gate excludes, yet its bare symbol is still linkable by an
Aether consumer):
builder, an export that takes an injected_ctx(thebuilderkeyword or the_ctx: ptrfirst-param convention). The "call me with a trailing block" signal;capture_countis 0.param, a closure-typed (fn) parameter of an export.nameis the parameter,signatureits|...| -> Rshape.trailing-block, a trailing-block closure literal in an export's body.literal, any other hoisted closure literal in an export's body. For both of these,captureslists the enclosing variables the closure closes over with their rendered Aether types.
Closure-literal return types are best-effort: rendered when resolved,
? when the body type-checks lazily and the type isn't known at emit
time. Captures and parameter types are exact.
The display signature flattens a typed pointer to ptr and spells a
struct struct Vec3, which is enough to call through a C ABI and not
enough to declare the function on the Aether side. Two additions carry what
an Aether importer needs:
typedef struct { const char* name; const char* type; const char* flags; } AetherLibField;
typedef struct {
const char* name; /* "Model" */
const char* kind; /* "", "extern" or "extern_packed" */
int field_count;
const AetherLibField* fields; /* type as Aether source: "*Vec3", "fn(ptr, float) -> int" */
const char* source_file;
int source_line;
} AetherLibStruct;structslists every struct the library defines and every struct an export's signature reaches (through fields, transitively), including one merged in from a module the library imports. A struct that cannot be re-declared (an opaque or header-definedexterntype, a union or bit-width field, a field of enum, sum or distinct type) is left out with a compile-time warning naming why.- A struct's C name. Struct types are module-scoped, and an executable
emits a module's struct as
<ns>_<Name>. A library keeps the plain name for a struct its module exports (all of them when the module has noexportslist), since a C host or header may spell it; only unexported module structs are mangled there. So two modules of one library that both export a struct of one name still clash, as every same-named pair did before struct types were module-scoped. source_signatures[i]isfunctions[i]'s signature as Aether source, with parameter names:"(m: *Model, p: Vec3) -> void". A parameter's@keepor@intois part of it ("(p: @keep ptr) -> void", #3013): the externaedeclares for an importer carries it, so the importer is checked as a source import is (a borrowedptrgiven to a@keepparameter, or a string given to one withoutstring.keep, is E0902), and a C host reads the contract: keep the argument alive while the library holds it (@keep) or while the result is held (@into). An entry is""when an export has no source spelling.
Both are emitted only when an artifact has struct records or a signature
the display form flattens (a typed pointer, a struct, a function pointer, a
@keep or @into parameter), so a library with none of them stays
byte-identical to schema 1.2.
The records above are not just for inspection, they are what lets an
Aether program consume a precompiled --emit=lib artifact as a
first-class import, with the call site reading as if the library were
compiled in the same cycle:
import std.map // builder default factory (map_new) lives here
import gizmo // resolves libgizmo.so, no gizmo.ae source needed
main() {
println(gizmo.greet("world")) // a function export
s = gizmo.section("intro") { } // a builder (trailing-block DSL) export
println(s)
}
When ae run / ae build / ae check sees an import foo that has no
foo source module but a libfoo.so / foo.so on the search path, it
reads that artifact's aether_lib_meta() catalog and synthesizes a small
Aether interface stub: an @extern("<c_symbol>") name(...) -> R for each
function export (typed by its source signature from schema 1.3, a
parameter's @keep and @into included), a
struct declaration for each struct record, and a trailing-block builder
wrapper for each builder record (forwarding the block's config map to the library
function's (..., _builder) entry point). The stub is dropped onto the
module search path, so the existing source-import machinery
(typecheck, namespace prefixing, builder registration) rehydrates the
library, no special call-site syntax. The artifact is linked in
(absolute path + -rpath), and the host's -rdynamic + static
libaether satisfy the .so's runtime symbols.
Scope of the current implementation:
- Function exports and builder DSL entry points are callable.
A builder consumer also
import std.map(the defaultmap_newconfig factory is astd.mapfacility used at the call site). - Structs cross (#2297): a struct passes by value, a
*Structreaches its fields (m.position.x), and a struct with a function-pointer field keeps its layout. The importer declares each struct from the catalog, so its layout is the library's. - Module state lives once. A module-level
varin the library is the library's single instance for every importer: a second binary library that imports the first, and a host that imports both, all reach the samevar. - Higher-order exports (a function taking a closure
fnparameter) are described in the metadata but not yet callable across the binary boundary, passing an Aether closure into an imported function is a follow-on (the closure ABI across the boundary). - Every platform. On Windows the catalog is read by mapping the DLL
without running its initialisation code, the DLL is linked directly, and
ae buildcopies each imported DLL next to the program it builds (PE has no rpath);ae runputs their directories on the program'sPATH.
A library built the default way carries its own copy of the runtime, linked
in statically: one scheduler, one set of panic frames, one config per image.
That keeps a library self-contained for a C or Python host, but a program
that imports Aether libraries in binary form then runs on several runtimes.
On Linux and FreeBSD ELF symbol interposition happens to bind a library's
runtime calls to the program's copy; macOS's two-level namespace and Windows'
PE keep them apart, so a panic raised inside a library never reaches the
program's catch there.
--shared-runtime links against the runtime as a shared library instead,
aether.dll (with libaether.dll.a), libaether.dylib or libaether.so,
which the toolchain builds and installs in lib/aether/shared/ (build/shared/
in a source tree), beside the static archive:
ae build --emit=lib --shared-runtime engine.ae -o libengine.so
ae build --emit=lib --shared-runtime script.ae -o libscript.so # imports engine
ae build app.ae -o app # imports both- The library's catalog records it (
runtime = "shared", schema 1.5;ae lib-infoprintsRuntime: shared), and a program importing such a library links the shared runtime too, without being asked. - Program and libraries then run on one runtime: a panic inside a library
unwinds to the program's
catchon every platform, and actors, config and resource caps are the program's. - The shared runtime is linked by path (never
-laether, which keeps finding the static archive): an rpath to its directory on Linux and macOS, and on Windowsaether.dllis copied next to the program (ae build) or put onPATH(ae run). The Windows DLL is self-contained: it needs only system DLLs and the UCRT. - A program that imports one library built on the shared runtime and another
built static gets a warning naming the static one: rebuild it with
--shared-runtime. - Each copy of the runtime frees only what it made. A string an export
returns crosses as a copy (above), but a
struct holding a string (returned by value, or reached through a typed
pointer) and a builder that uses its config have none. Where the copies
stay apart (macOS, Windows), importing a static library that has such an
export is an error naming each one, with the rebuild:
--shared-runtime. - A sandbox's grants are each thread's, in the runtime: on one runtime a
library's export called from inside another's
sandbox.enforceblock is held to the block's grants. Where the copies stay apart, the inner copy cannot read them and refuses what the block would check, with a message naming--shared-runtime(containment-sandbox.md). - Hooks a program supplies to the runtime by
@c_callback(the pure-TLS client and server) reach a shared runtime by registration: every@c_callbackregisters itself by name when its image loads, and the runtime looks a hook up before falling back to its own definition.
A library's exports may spawn actors that the program never defines:
ae build --emit=lib --shared-runtime crowd.ae -o libcrowd.so # spawns actors
ae build app.ae -o app # has none- The scheduler starts on the first spawn. A program's
main()initializes the scheduler only when the program has actors. Otherwise the first spawn initializes it and starts its threads. This holds for an Aether program and for a C host. A secondscheduler_init()while one is live is a no-op, so it cannot wipe the actor tables of actors already running.scheduler_shutdown()andscheduler_cleanup()end the lifecycle. - The catalog says so. The library's catalog has
actors = 1, andae lib-infoprintsActors: yes. This also covers a library with no actors of its own that imports one which has them. A program that imports such a library hasmain()run the scheduler as it does for its own actors. On the way out,main()drains the scheduler and joins it, so a message the library sent fire-and-forget is still delivered. - Draining needs one runtime. The drain reaches the library's actors
where the program and the library share one runtime:
--shared-runtimeeverywhere, or ELF interposition with the default static runtime (Linux, FreeBSD). On macOS and Windows a static library's actors run on that library's own scheduler.aewarns that messages still in flight when the program exits are not delivered, and names the library to rebuild with--shared-runtime. - A C host calls
scheduler_shutdown()before it exits to deliver what is still in flight.
A package of many modules (ae3d.core, ae3d.gl, ...) builds into one
library, which programs then import module by module (#2297):
ae build --emit=lib --package ae3d --lib src -o libae3d.so- What is built. The package's modules are its directory under
.,srcor a--lib/ dependency directory (src/ae3d/): that directory if it holds amodule.ae, and every directory below it that does (src/ae3d/core/module.aeisae3d.core).aewrites an entry that imports all of them and builds it with--lib-package=ae3d. The default output name islib<package>, with dots as_. - What is exported. Each module's public functions and constants are
exported: the names in its
exports (...)list, or all of them when it has no list, minus_-suffixed privates. A function gets a stable C symbol named after its module,aether_ae3d_core__model_new. The compiler's own merged name (core_model_new) depends on which other modules a build loads, so it is never the ABI. The package's structs are recorded as in schema 1.3, and modulevars live once, in the library. - The catalog.
function_modulesandconstant_modulesare parallel to the function and constant tables and give the module each export belongs to.aether_nameis the bare name (model_new).ae lib-infoprints amodule:line under each export. - Importing.
import ae3d.corewith noae3d/coresource on the path resolves to the package library:aetries each proper prefix of the import, longest first (ae3d→libae3d), and takes the library whose catalog listsae3d.core. The interface it builds for that module declares the module's functions and constants and the library's structs, socore.model_new(...)reads as it does against source. Importing several modules of one package links the library once. - Builders of a package module are exported too (#2349, schema 1.6). A
builder takes its parameters plus the trailing block's
void* _builderconfig, so the library exports a wrapper with that signature,aether_ae3d_ui__panel. Its catalog record carries the module (closure_modules), and a module's interface declares it as a trailing-blockbuilder, soui.panel("hud") { ... }works against the library. An importer of a builder alsoimport std.map, as for a single-module library. A builder with a pattern parameter is the one form left out, with a warning naming it.
The ae CLI ships a turnkey reader that dlopens a .so/.dylib,
calls aether_lib_meta, and prints a human-readable dump:
$ ae lib-info build/libscript.so
Aether Library: build/libscript.so
Schema: 1.0
Aether: 0.134.0-dev
Source: script.ae
Functions: 3
- aether_script_handle(ptr, ptr, ptr) -> void
@ script.ae:3
- double_int(int) -> int
c_symbol: aether_double_int
@ script.ae:7
- greet(string) -> string
c_symbol: aether_greet
@ script.ae:11The c_symbol: line is suppressed when the symbol equals aether_<name>
(the default for plain exports, printing it would just be noise).
@c_callback-marked functions whose Aether name is the C symbol show
no c_symbol: line either; they are their own export.
On Windows ae lib-info maps the DLL without running its initialisation
code and reads the same catalog; any host reaches it with LoadLibrary +
GetProcAddress("aether_lib_meta"). A DLL that ae links exports the
entry point strong: PE cannot export a weak definition, so the generated
code is compiled with -DAETHER_LIB_META_WEAK= there (#2297).
Any embedder can build a typed binding without re-parsing the source:
import ctypes
lib = ctypes.CDLL("./libscript.so")
class AetherLibFunction(ctypes.Structure):
_fields_ = [("aether_name", ctypes.c_char_p),
("c_symbol", ctypes.c_char_p),
("signature", ctypes.c_char_p),
("source_file", ctypes.c_char_p),
("source_line", ctypes.c_int)]
class AetherLibMeta(ctypes.Structure):
_fields_ = [("schema_version", ctypes.c_char_p),
("aether_version", ctypes.c_char_p),
("primary_source", ctypes.c_char_p),
("function_count", ctypes.c_int),
("functions", ctypes.POINTER(AetherLibFunction)),
("closure_count", ctypes.c_int),
("closures", ctypes.c_void_p)]
lib.aether_lib_meta.restype = ctypes.POINTER(AetherLibMeta)
meta = lib.aether_lib_meta()[0]
for i in range(meta.function_count):
fn = meta.functions[i]
print(fn.aether_name.decode(), fn.signature.decode())Same struct walks from Java Panama, Ruby Fiddle, Go cgo. The flat-C layout is the lowest common denominator across every FFI in the wild.
The integration suite under tests/integration/ covers:
| Test | What it proves |
|---|---|
emit_lib/ |
Primitive round-trip through dlopen |
emit_lib_composite/ |
Nested map + list round-trip via accessors |
emit_lib_lists/ |
List-of-ints, list-of-strings, empty list, out-of-range |
emit_lib_primitives/ |
long, bool, float across the boundary |
emit_lib_unsupported/ |
Unsupported param types warn + skip stub |
emit_lib_clause_sets/ |
Clause sets (guards, a literal first clause, @c_callback) and an export-wrapped function: each listed once in the catalog and the header with its set's signature, every catalog symbol exported, each callable from a C host (#2665, #2666) |
emit_lib_banned/ |
All five capability-heavy imports rejected |
emit_lib_dual_build/ |
Same source → exe AND lib via separate invocations |
emit_lib_swig/ |
SWIG Python round-trip (skips if swig missing) |
emit_lib_with_capability/ |
--with=fs,net,os opt-ins; --with=first-party and --with=all aliases |
lib_meta/ |
aether_lib_meta + ae lib-info round-trip, schema, source, function count, three signatures, c_symbol gating, source refs |
binary_import_package/ |
--package builds two modules into one library: stable aether_<module>__<name> symbols, schema 1.4 module records, export-list and _-suffix privacy, a host importing both modules by name from the binary (#2297) |
binary_import_structs/ |
Schema 1.3 struct records and source signatures: a struct by value, *Struct field access, a function-pointer field, closures both ways with their captures, one module state shared by a host and a second binary library; static runtime on Linux/FreeBSD (a library panic caught; elsewhere the import is refused, since *Model holds a string) and --shared-runtime (caught everywhere, schema 1.5), Windows included (#2297) |
binary_import_ownership/ |
Strings an export or a builder returns are given back to the library's runtime: 0 growth and no leaks(1) findings for a binary import (static and shared runtime) and a C host releasing through the catalog's string_release; a struct holding a string and a builder freeing its config run on one runtime and are refused across two (#2931, #2861) |
binary_import_keep/ |
A @keep ptr and an @into ptr export cross the catalog's source signature, and each importer gets the same E0902 verdict from ae check against the library as against its source (a string or a borrowed ptr to the @keep parameter, an @into result that escapes); the accepted one runs (#3013) |
binary_import_actors/ |
A library's actors in a program with none of its own: the first spawn starts the scheduler, schema 1.7 actors passes through a second library, and the program's exit drains a fire-and-forget message (shared runtime everywhere, static on Linux/FreeBSD; elsewhere a warning) (#2297) |
emit_lib_keeps_main/ |
A program's main() as aether_main / aether_main_exit: a C host runs it (args, an ask/reply, its return value), the actors outlive aether_main and aether_main_exit drains them, a second aether_main is rejected, a rerun works without leaking scheduler tables (macOS leaks), the executable prints the same lines, no pair without main(), the reserved main_exit, the --emit=csrc header, and aarch64-linux / Android cross builds |
emit_lib_kind_safe/ |
Kind-discriminator predicates + deep-free safety, adversarial low-address probe ((AetherValue*)42) survives, kind correctly classifies map/list/scalar slots, deep-free walks nested map+list+scalars, magic-clear-on-free defends UAF probes |
Run them with the standard make test-ae or individually:
tests/integration/emit_lib_composite/test_emit_lib_composite.sh