Build System
Overview
Aether uses a multi-tier build system with different optimization profiles for development, testing, and release builds.
Project Configuration (aether.toml)
Every Aether project has an aether.toml at its root. ae run and ae build read it automatically.
Minimal project
[package]
name = "myapp"
version = "0.1.0"
[[bin]]
name = "myapp"
path = "src/main.ae"
Full configuration reference
[package]
name = "myapp"
version = "1.0.0"
description = "What this program does"
[build]
# Extra C compiler flags appended to the gcc invocation on every build path,
# including `ae run`. They come after the optimisation level, so a cflag like
# `-O3` overrides the `-O0` that `ae run` and `ae build --quick` use by default.
cflags = "-O3 -march=native"
# Platform-specific linker flags (e.g. for third-party C libraries).
# macOS/Linux: link_flags = "-lraylib"
# Windows: link_flags = "-Ldeps/raylib/lib -lraylib -lopengl32 -lgdi32 -lwinmm"
link_flags = ""
[[bin]]
name = "myapp"
path = "src/main.ae"
# Extra C source files compiled alongside the Aether output.
# Useful for C FFI helpers, renderer backends, or any C code your program needs.
# Merged additively with any --extra flags passed on the command line.
extra_sources = ["src/ffi_helpers.c", "src/renderer.c"]
Link flags you do not have to write
link_flags is for libraries your program needs. A library that a module needs is declared by that module, with @link at the top of its module.ae:
@link("-laether_sqlite -lsqlite3")
Codegen unions those declarations across the resolved import closure into a // aether-link: comment on the generated C, and ae build puts them on the link command (#1549). So import contrib.sqlite links libsqlite3 without any aether.toml entry, and it works transitively, a module three imports deep contributes its own dependencies.
Two consequences worth knowing:
- Your
link_flagsstill win. Module flags are placed before them on the command line, so anything you specify takes precedence. - A
-D-dropped import drops its libraries too. Because the flags come from the AST, animportinside a losingwhen defined(...)region never contributes them, the library is absent from the binary, not merely unused. Restating it inlink_flagsdefeats this, since that is static and applies to every build. Prefer@linkfor a module's own dependencies.
-L search paths remain yours to supply; they are site-specific. The one exception is <lib_dir>/contrib, added automatically so the veneer archives make contrib builds resolve without configuration.
The // aether-link: comment also lists libraries for std modules backed by native code (std.http names -lssl -lcrypto -lnghttp2). Those are there for downstream C builds; ae build skips them, because it already passes the same libraries from its own pkg-config detection, and passes nothing when the library was not found, so std.http still links on a box without libnghttp2 and the h2 surface falls back to its "unavailable" stub. A module's @link is for dependencies the toolchain does not probe for.
extra_sources vs --extra
Both add C files to the build, they are additive when both are present.
extra_sources in aether.toml | --extra file.c CLI flag | |
|---|---|---|
| Scope | Always included for this binary | Per-invocation |
| Good for | C helpers your program always needs | Renderer backends, platform variants |
| Works with | ae build and ae run | ae build and ae run |
--quick for fast iteration
By default, ae build runs the C compiler with -O2 to match release-quality codegen. For tight edit/build/test loops where binary speed isn't critical, pass --quick to drop to -O0 -g:
ae build src/main.ae # release-shape, -O2 (default)
ae build src/main.ae --quick # iteration-shape, -O0 -g
--quick typically halves the gcc step on small programs, at the cost of unoptimised codegen. ae run already uses -O0 regardless, since cache hits dominate over a single optimised compile.
--profile for sampling profilers
--profile compiles with -O2 -g -fno-omit-frame-pointer:
ae build src/main.ae --profile # -O2 -g -fno-omit-frame-pointer
perf record -g ./main # attributable frames
perf report --stdio
Neither other mode serves this. The default -O2 build carries no DWARF and omits frame pointers, so a sampling profiler cannot unwind it, profiling std.http.server.lb under load, gdb resolved 239 of 240 sampled frames as ??. And --quick's -O0 inlines nothing and keeps every temporary live, so the hot spots it reports are not the ones the shipped binary has.
--profile keeps -O2 precisely so the profile describes the code you ship, and adds only what the profiler needs to attribute it. It works under --target as well, for profiling a cross-built binary on the target machine.
--coverage takes precedence if both are passed: gcov's line attribution needs -O0, which is a correctness requirement rather than a preference.
--size for shipped artifacts
--size compiles with -Os -g0 (-Oz under --target) and strips at link time, -Wl,--strip-all -Wl,--gc-sections with GNU ld and LLD, -Wl,-x -Wl,-dead_strip on Apple targets, whose linker rejects the GNU spellings as unknown options:
ae build --target=wasm32-wasi --emit=lib mylib.ae -o lib.wasm # 956,573 bytes
ae build --target=wasm32-wasi --emit=lib --size mylib.ae -o lib.wasm # 24,942 bytes
Every other mode points at debugging, --quick is -O0 -g, --profile is -O2 -g -fno-omit-frame-pointer, --coverage is -O0 -g --coverage, and the default -O2 sits between them. --size is the one that points at shipping.
It matters most under --target. zig cc emits DWARF by default, even at -O2, and the cross backend passed no -g0, so a cross-compiled --emit=lib artifact was overwhelmingly debug information. Measured on a two-function wasi library, 97.4% of the module was .debug*/name sections; code and data were the remaining 2.6%. The equivalent native .so has zero .debug* sections, so this was a cross-path problem rather than something every target shipped. Native builds still benefit, just far less: about 14% on the same library, from -Oz and the symbol table.
Stripping is behaviour-preserving. The 24 KB module above still instantiates, still exports every symbol, and still runs identically, including the fail-stop panic path WASI has.
-Os rather than -Oz on the native path: gcc only gained -Oz in GCC 12, and Ubuntu 22.04, the CI baseline, ships GCC 11, where it is a hard error. -Os is supported everywhere and gives nearly the same result. Cross builds do use -Oz, because zig bundles its own clang and the version is not the host's to vary.
Two things --size deliberately does not do:
- It is not the default. A 38× difference is discoverable; anyone shipping to a browser will find the flag. Stripping every build by default would make the first "why can't I get a stack trace from my wasm module" report genuinely hard to diagnose.
- It does not strip
--emit=objor--emit=csrc. Neither links, and an object file's symbols are exactly what whoever links it next needs. Those modes still get-Oz -g0for the compile, but no link-time stripping.
--profile takes precedence if both are passed: asking for a small artifact and a profileable one is contradictory, and the debug-oriented reading is the safer one.
Resolving the build target
ae build accepts either a path to a .ae file or a [[bin]] name from aether.toml. The two are equivalent:
ae build src/main.ae # explicit path
ae build myapp # [[bin]] name = "myapp"
When the positional argument doesn't exist as a file, ae checks aether.toml's [[bin]] entries for a matching name = "..." and uses that bin's path field. Cargo and similar build systems work the same way.
If you run ae build from a subdirectory and there's no aether.toml in the current directory, ae walks up the directory tree looking for one. When it finds an ancestor aether.toml, it switches to that directory before resolving paths, so cd src && ae build main.ae works as if you had run ae build src/main.ae from the project root, and extra_sources declared in the toml are still applied. Walk-up only happens when there's no toml in the current directory; a project with a local aether.toml always wins.
Integer semantics (-fwrapv)
Aether's int is 32 bits and wraps: 2147483647 + 1 is -2147483648, and the compiler's constant folder says so out loud (W1003). The code generator emits it as a C int, and C leaves signed overflow undefined, so the flag that makes the two agree has to be on every command line that compiles generated C:
| flag | what it buys | where |
|---|---|---|
-fwrapv | signed overflow wraps in two's complement, as the language reference specifies | every compile of generated C: ae run, ae build, --profile, --size, --coverage, ae build --target=<triple>, the wasm/emcc backend, and ae cflags |
Without it the guarantee held only up to -O1. At -O2 GCC 15.2 folded
g_seed = (g_seed * 1103515245 + 12345) & 0x7FFFFFFF;
down to a three-value cycle, the mask proves g_seed >= 0, "a signed multiply does not overflow" then proves g_seed <= 2^31 / 1103515245, and value-range propagation finishes the job. ae run compiles at -O0 and was unaffected, so the disagreement appeared only in shipped binaries.
ae cflags emits -fwrapv too, ahead of the include flags, so a build system that compiles aetherc output itself gets the same language:
aetherc app.ae app.c
gcc app.c $(ae cflags) -o app # -fwrapv arrives with the -I flags
The hand-written runtime and compiler are built by the Makefile rather than through ae, and are deliberately left out: signed overflow in that C is a bug to fix, not an operation with a defined result.
Binary Hardening
ae build asks the C toolchain for the mitigations whose cost is a rounding error next to what they catch, rather than inheriting whatever the platform defaults to:
| flag | what it buys | where |
|---|---|---|
-fstack-protector-strong | a canary on functions with buffers or address-taken locals | everywhere |
-D_FORTIFY_SOURCE=2 | bounds-checked memcpy / sprintf / printf wrappers | everywhere, on optimised builds only, since it needs -O1 or better and warns without it |
-Wl,-z,relro -Wl,-z,now | full RELRO: the GOT is read-only before main | ELF |
-Wl,-z,noexecstack | a non-executable stack even when a dependency forgets | ELF |
-fPIE -pie | a position-independent executable, so ASLR applies to the program image | ELF executables (macOS and Windows produce relocatable images already) |
-Wl,--dynamicbase -Wl,--nxcompat | ASLR and NX | PE |
--emit=lib and --emit=obj skip -pie (it contradicts -shared, and an object file is linked by someone else). A project that wants a different posture overrides it through aether.toml's [build] cflags, which append after these.
ae checksec reads the artifact, not the intent
The flags above say what was asked for. What a binary ended up with is a separate question, and the only honest answer comes from the file itself:
$ ae checksec build/myprog
build/myprog (ELF)
PIE yes
NX yes
RELRO yes
canary yes
FORTIFY yes
stripped no
It reads ELF program and dynamic headers, Mach-O load commands, and PE DllCharacteristics directly, so it needs no checksec(1), readelf or otool on the machine. A property the format has no concept of reports n/a rather than a failure: RELRO is an ELF idea, and Mach-O keeps its dyld info read-only by construction.
A property reporting n/a satisfies --require, so one gate line works on every format: ae checksec --require pie,nx,relro-full,canary,fortify passes on a hardened Mach-O, where RELRO does not exist, and holds an ELF to full RELRO. relro without -full accepts partial RELRO; relro-full requires BIND_NOW.
n/a also covers what a file genuinely cannot answer. Canary and FORTIFY are read from names, and a PE need not carry any: the COFF symbol table is optional, and mingw-w64 fortifies in its own headers, so the bound check is inlined and the only name it can leave behind is the handler on the failing branch. Where there are no names to read, checksec says n/a instead of reporting an absence it did not observe. The protection is still there, and the test suite proves it the way the file cannot: it builds a program that copies 32 bytes into a 16-byte object and requires the process to die rather than complete. That check runs on every platform, and the same overflow completes normally when the flag is taken away.
--require turns the report into a gate, which is the part that keeps a mitigation from disappearing in a flag change nobody notices:
$ ae checksec --require pie,nx,relro-full,canary,fortify build/myprog
$ echo $?
0
A missing property prints a FAIL row and exits 1. relro-full demands BIND_NOW; plain relro accepts partial.
Whether a FORTIFY call appears at all is the libc's decision, not the flag's: glibc's headers redirect the printf family, Apple's do not for the same source. The flag is passed on both; only the artifact knows.
Hardening the toolchain itself
make HARDEN=1 builds the compiler and runtime with -fstack-protector-all -D_FORTIFY_SOURCE=2 -Wformat -Wformat-security, and CI pins a leg that does so.
Object files carry no record of the flags that built them, so changing HARDEN used to recompile nothing: make HARDEN=1 after an ordinary build relinked the same unhardened objects and reported success. The build now keeps a digest of everything that changes code generation, and any change to it forces the rebuild.
Build Cache
Both ae run and ae build cache compiled binaries in ~/.aether/cache/. The cache key is an FNV64 hash of:
- The source file's content
- The
aethercbinary's mtime (recompile invalidates everything) libaether.a's mtime (stdlib rebuild invalidates everything)- Every
--extraC file's content (editing an FFI shim invalidates the cache, not just touching it) - Every imported lib module's content: the
.ae/.c/.hfiles under each lib-search directory, walked recursively. This covers both an explicit--lib/$AETHER_LIB_DIRdirectory and the defaultlib/the compiler searches when neither is set (thesrc/main.ae+lib/<name>/module.aepackage layout). Because entries are content-hashed rather than keyed on mtime+size, a same-second, same-size edit (a one-character constant flip in an editor-save loop) still invalidates the cache, and a baretouchdoes not. This lib-dir walk is POSIX-only; on Windows the key covers the entry file and--extracontent but not lib-module edits, so a lib-module change there still needsae cache clearuntil the walk is wired for Windows. - The optimisation level (
-O0forae runandae build --quick,-O2for defaultae build)
ae run and ae build use separate cache slots so toggling between them doesn't churn one entry back and forth.
Cost breakdown of a build:
- Cache hit (
ae build): copy the cached binary to the requested output path. Sub-millisecond on local disk. - Cache hit (
ae run): fork + exec the cached binary directly. - Cache miss: full
aethercfront-end + gcc compile + link. Dominant cost is gcc;aethercis a small fraction. - First macOS run: an extra one-time pause while the OS performs its Gatekeeper check on the newly compiled binary. Subsequent runs of the same cached binary are hit-path.
ae cache # Show cache location and entry count
ae cache clear # Delete all cached builds
Wasm builds, --emit=lib, and --namespace SDK generation skip the cache (different artefact shapes; each will get its own cache layout when measurement justifies it).
Build Tiers
All builds go through the Makefile, which handles the full source list across subdirectories (compiler/parser/, compiler/analysis/, compiler/codegen/, runtime/scheduler/, runtime/memory/, etc.).
Development Build
make compiler CFLAGS="-O0 -g -Icompiler -Iruntime -Iruntime/actors -Iruntime/scheduler -Wall -Wextra -Wno-unused-parameter"
Purpose: Fast compilation with debug symbols for active development.
Testing Build
make compiler # Uses -O2 by default
Purpose: Moderate optimization for CI and testing.
Release Build
make compiler CFLAGS="-O3 -march=native -flto -Icompiler -Iruntime -Iruntime/actors -Iruntime/scheduler -Wall -Wextra -Wno-unused-parameter"
Purpose: Full optimization for production use and benchmarking.
Flags:
-O3: Aggressive inlining, loop unrolling, auto-vectorization-march=native: CPU-specific instruction selection-flto: Link-time optimization for cross-translation-unit inlining
Profile-Guided Optimization
# Stage 1: Instrument
gcc -O3 -march=native -fprofile-generate -o aetherc_pgo ...
# Stage 2: Profile (run representative workload)
./aetherc_pgo <typical_usage>
# Stage 3: Optimize with profile data
gcc -O3 -march=native -fprofile-use -o aetherc ...
PGO uses runtime profiling data to improve branch prediction, function inlining decisions, and code layout. It is used by major projects including Chrome, Firefox, CPython, and LLVM for their release builds.
Incremental Compilation
Dependency Tracking:
CFLAGS += -MMD -MP
-include $(DEPS)
build/%.o: %.c
$(CC) $(CFLAGS) -c $< -o $@
The -MMD flag generates .d dependency files listing all headers included by each source file. Make uses these to rebuild only modified files and their dependents.
Parallel Compilation
make -j8 # 8 parallel jobs
Limited by dependency ordering: some files must build before others.
Cross-Compilation (PLATFORM variable)
The PLATFORM Makefile variable selects the scheduler backend and sets platform-specific flags:
# Native (default), multi-core scheduler, pthreads
make stdlib PLATFORM=native
# WebAssembly, cooperative scheduler, no pthreads/fs/net
make stdlib PLATFORM=wasm # CC=emcc, -DAETHER_NO_THREADING/FILESYSTEM/NETWORKING
# Or use the ae CLI directly:
ae build --target wasm hello.ae # Produces hello.js + hello.wasm
node hello.js # Run with Node.js
# Embedded, cooperative scheduler, no pthreads/fs/net/getenv
make stdlib PLATFORM=embedded # -DAETHER_NO_THREADING/FILESYSTEM/NETWORKING/GETENV
# Override individual features on native
make stdlib EXTRA_CFLAGS="-DAETHER_NO_THREADING" # Auto-selects cooperative scheduler
make stdlib EXTRA_CFLAGS="-DAETHER_NO_FILESYSTEM -DAETHER_NO_NETWORKING"
The Makefile auto-detects AETHER_NO_THREADING in EXTRA_CFLAGS and switches to the cooperative scheduler automatically. It also omits -pthread from linker flags.
Cross-building the toolchain itself (FREEBSD=1 / WINDOWS=1)
PLATFORM above selects a scheduler/feature profile. Two separate knobs cross-build the ae/aetherc toolchain itself for another OS, both via zig cc:
# FreeBSD x86_64 (needs a base sysroot: zig bundles no FreeBSD libc)
make compiler ae stdlib FREEBSD=1 ZIG=/path/to/zig AETHER_SYSROOT=/path/to/base
# Windows x86_64, no sysroot needed: zig bundles the mingw-w64 headers + CRT
make compiler ae stdlib WINDOWS=1 ZIG=/path/to/zig
file build/ae.exe # PE32+ executable (console) x86-64, for MS Windows
Provision zig with the aether-crossbuild repo's scripts/get-zig.sh (the same source CI uses).
Both cross builds are capability-lean: OpenSSL, zlib, nghttp2 and YAML are forced off, because the host's pkg-config would resolve the host's Linux libraries and poison the target binary. Those std features ship as their "unavailable" stubs, exactly as in any build without the libraries. Vendored PCRE2 needs no host library, so std.regex survives.
One build/ tree holds one target's objects and archives, they are not interchangeable (ELF vs PE). The tree carries a build/.build-target stamp and a mismatching build stops immediately with an actionable error rather than failing deep in the link; make clean between targets is the fix (and clean itself is never blocked by the guard).
Testing a cross-built toolchain: see AE_TEST_RUNNER in the next section.
Testing with a runner (AE_TEST_RUNNER)
ae run and ae test normally exec the binary they just built. Setting AE_TEST_RUNNER makes them prefix it with a wrapper instead, the same idea as cargo's CARGO_TARGET_<triple>_RUNNER:
# Run the cross-built Windows toolchain's output under Wine
AE_TEST_RUNNER=wine make test-ae WINDOWS=1 ZIG=/path/to/zig
# Any wrapper works, qemu-user, a tracer, a timing harness
AE_TEST_RUNNER="qemu-aarch64 -L /sysroot" ae test
Unset or empty means "exec directly", so it is inert by default. The value may carry its own arguments. Because the hook sits at the edge, nothing in std.spec or in your test sources needs to know it is running under an emulator, the same describe/it blocks run either way, and the AE_SPEC_FORMAT/AE_SPEC_REPORT structured-report contract is inherited straight through the wrapper.
Layer an extra exclusion list onto a sweep with make test-ae AE_SWEEP_EXTRA_PRUNE=<file> (applies to both the .ae and .sh sweeps).
Wine and the Windows cross lane. CI's windows-cross job (.github/workflows/windows.yml) currently cross-builds only, it does not run the suite under Wine. That was attempted and deferred for a structural reason worth knowing before trying again: ae is a compile-and-run driver, so ae run/ae test inside a Wine prefix want a Windows C toolchain there to compile the C the compiler emits (the driver tries to fetch MinGW-w64). Driving the native ae with AE_CC="zig cc -target x86_64-windows-gnu" avoids that, but then needs a Windows libaether.a kept alongside the native one, and one build/ tree holds exactly one target's archives. tests/ae_sweep_prune_wine.txt records which areas such a lane must never claim to cover (fs/path semantics, sockets/h2, actor timing, the LD_PRELOAD sandbox).
Docker-Based Cross-Compilation
For cross-compilation without installing toolchains locally:
make docker-ci-wasm # Emscripten SDK → compile + run with Node.js
make docker-ci-embedded # arm-none-eabi-gcc → syntax-check
make ci-portability # All: native coop + WASM + embedded
RISC-V 64-bit (ci-riscv64)
Cross-compile + run-under-qemu portability check:
# Install host toolchain (Ubuntu 22.04+):
sudo apt-get install -y gcc-riscv64-linux-gnu \
libc6-dev-riscv64-cross qemu-user-static
# Cross-compile compiler/ae/stdlib for riscv64; verify the binaries
# are riscv64 ELF; smoke-run them under qemu-user-static.
make ci-riscv64
Useful for catching pointer-width, struct-padding, and atomic- instruction-availability bugs that an x86_64-only matrix can't surface. Optional libs (OpenSSL, zlib, nghttp2, GTK4) are disabled in the riscv64 build because the host runner's pkg-config returns x86_64 lib paths, the std.* feature-detection wrappers fall into their "unavailable" stubs cleanly.
Docker images: docker/Dockerfile.wasm (Emscripten), docker/Dockerfile.embedded (ARM Cortex-M4).
ae build with an alternate or cross C compiler ($CC / $AE_CC)
ae build, ae run, and ae build --emit=lib select their C-backend compiler the way the Makefile does: they honor $AE_CC first, then $CC, falling back to gcc (the WinLibs-bundled gcc on Windows) when neither is set. This affects only the C backend that turns Aether's generated C into the final binary; it never changes aetherc, the Aether-to-C front end.
The same-OS, cross-arch cell is then a one-liner. On an x86_64 Linux host with a cross-gcc installed:
CC=aarch64-linux-gnu-gcc ae build --emit=lib core/embed.ae -o libfoo.so
file libfoo.so # => ELF 64-bit LSB shared object, ARM aarch64
qemu-aarch64 ./probe # load/run the emitted lib under an emulator
That produces an arm64 .so on a cheap x86_64 runner with no arm64 hardware and no new codegen. A compiler that cannot be found fails fast with C compiler '<name>' (from $CC) not found rather than a later link error. Cross-OS targets (for example Linux to macOS) need a matching C toolchain plus that OS's headers, which a stock cross-gcc does not carry: for those, use the zig cc backend below.
ae build --target=<triple> (cross-OS via a zig cc backend)
ae build --target=<triple> cross-compiles a foreign-OS/arch binary using zig as a self-contained cross toolchain. zig bundles each target's libc, system headers, and linker, so the Aether runtime and standard library compile straight from source for the target: no cross-gcc, no target sysroot, no per-host file juggling. The platform backend (epoll vs kqueue, spawn_sandboxed_linux vs the BSD/stub path) is selected by the __linux__ / __APPLE__ macros zig predefines for the target, so one source set serves every target.
ae build --target=x86_64-linux hello.ae -o hello # ELF x86-64, glibc
ae build --target=x86_64-linux-musl hello.ae -o hello # ELF x86-64, static
ae build --target=aarch64-macos hello.ae -o hello # Mach-O arm64
glibc or musl on Linux. The -linux triples link against glibc dynamically, so the artifact carries the GLIBC symbol version of whatever built it and will not start on an older distro. The -linux-musl triples link musl statically instead: no dynamic libc dependency, no version floor, and the same binary runs on any Linux of that architecture, including Alpine and other musl distros. Prefer musl for anything you publish; see release-glibc-portability.md.
Supported triples: aarch64-macos, x86_64-macos, aarch64-linux, x86_64-linux, aarch64-linux-musl, x86_64-linux-musl (the arm64-/amd64- spellings are accepted too). Zig 0.16.0 or newer must be on PATH (brew install zig, or use the checksum-pinned scripts/get-zig.sh); the build fails fast with an install hint otherwise.
iOS: --target=aarch64-ios (Xcode backend)
iOS is the one cross target that does not go through zig: the Apple SDKs are Xcode-licensed and not redistributable, so aarch64-ios, aarch64-ios-simulator, x86_64-ios-simulator and the Mac Catalyst triples (aarch64-ios-macabi, x86_64-ios-macabi) shell to xcrun clang instead and require a macOS host with Xcode. --emit=staticlib produces a single .a holding the program plus the Aether runtime and stdlib, the shape an App Store build needs, since iOS forbids third-party dynamic libraries, and --emit=lib produces an @rpath-installed Mach-O dylib for local development. Either way an iOS app is built by Xcode, and what it wants from Aether is a library, not a standalone binary. Full details, including the sandbox restrictions that apply at runtime, are in cross-ios.md.
How it links. The full runtime and standard library are compiled from source for the target and archived, then the program links against that archive, so the linker pulls only the objects it references, exactly as a native -laether link against the complete libaether.a does. The first build recompiles the runtime from source (a few seconds); caching the per-target archive is a planned optimization.
Scope. Cross binaries are built without OpenSSL / zlib / nghttp2 (zig ships none of those), so standard-library features that need them (HTTPS/TLS, hashing, base64, compression, HTTP/2) report errors at runtime, exactly like a native build on a host that lacks those libraries; plain sockets and pure helpers keep working. ae build prints a note when a program uses such a module (std.http, std.net, std.cryptography, std.zlib, std.encoding), then builds it anyway. Cross-building those libraries is the documented follow-up; staging them via CROSSBUILD_SYSROOT (provisioned by aether-crossbuild) links them for real today. std.regex is the exception: it always works on cross builds, with no sysroot, its engine is vendored in-tree (std/regex/pcre2/, pinned upstream PCRE2 compiled by std/regex/aether_pcre2_vendored.c; #1389), so a regex-using program cross-builds self-contained for every target. A CROSSBUILD_SYSROOT that stages a real libpcre2-8 takes precedence over the vendored copy. Executables, --emit=csrc, --emit=obj and --emit=lib are supported, and the host must be POSIX (Linux/macOS). The generated artifact targets another platform, so it is not runnable on the build host; copy it to a matching machine (or an emulator).
--emit=csrc is allowed under --target because it never links: it writes the portable C, its catalog header and the JSON catalog, and stops (#1648).
--emit=lib under --target produces a real shared library for the target (#1648): an ELF .so, a PE .dll, or a Mach-O .dylib, chosen by the target rather than the host. zig links a shared object for a target as readily as an executable, and the runtime and stdlib are already compiled from source for that target on the executable path, so -shared -fPIC instead of an executable link is the whole increment.
| target | artifact | extra link flags |
|---|---|---|
*-linux, other ELF | .so | -shared -fPIC |
*-windows | .dll | -shared -fPIC -Wl,--export-all-symbols |
*-macos, *-ios | .dylib | -dynamiclib -install_name @rpath/<leaf> |
wasm32-* | .wasm | --no-entry --gc-sections + --export= per symbol |
Windows needs --export-all-symbols for the reason the native path documents (#993): GCC's auto-export heuristic switches off as soon as any symbol carries an explicit __declspec(dllexport), and the aether_<name> catalog exports then vanish from the DLL. A Windows --emit=lib is named .dll, not .exe, the executable suffix is applied only to executables.
--emit=both is still rejected under --target: it wants an executable and a library from one invocation, and the cross path links once. Run it twice with different --emit modes. That makes it the route to a cross-compiled linkable library without cross-link support, emit the C here, and let the consumer compile it for their target into the .so / .a / .wasm they need.
The emitted C is target-neutral, not target-parameterised: platform selection stays in #if __linux__ / __APPLE__ / __wasi__ and is resolved by the consumer's compiler, which defines those macros for whatever target it builds. --target therefore does not change the bytes emitted (asserted by tests/integration/emit_csrc_cross/); it is accepted so one command line works for both native and cross consumers. For the same reason csrc under --target does not require zig on PATH, nothing is compiled or linked.
--emit=obj is the other non-linking mode, and is allowed under --target for the same reason: it stops at zig cc -target <triple> -c. Unlike csrc it emits a target-format object, real machine code for the triple, so it does need zig:
$ ae build --target=aarch64-linux --emit=obj lib.ae -o lib.o && file lib.o
lib.o: ELF 64-bit LSB relocatable, ARM aarch64
$ ae build --target=x86_64-windows --emit=obj lib.ae -o lib.o && file lib.o
lib.o: Intel amd64 COFF object file
$ ae build --target=x86_64-macos --emit=obj lib.ae -o lib.o && file lib.o
lib.o: Mach-O 64-bit x86_64 object
AE_CC/CC are deliberately not consulted on the cross object path: they name a host compiler, and honouring them would silently produce a host object for a command that asked for a cross one. The consumer links the object with their own libaether.a and system libraries for the target, exactly as they do for a native --emit=obj.
WebAssembly: two backends, selected by target name
There are two wasm paths, and they are not interchangeable:
| Target | Backend | Produces | Supported modes |
|---|---|---|---|
--target=wasm | Emscripten (emcc) | .js + .wasm bundle | executables |
--target=wasm32-wasi | zig cc | a self-contained wasm module | executables, --emit=csrc, --emit=obj |
Emscripten supplies a JS host, a DOM/filesystem shim and its own pthread emulation; the zig path produces a plain object a WASI runtime (or someone else's wasm link) consumes. Neither supersedes the other, so they are chosen by different target names rather than one silently switching backend.
wasm32-wasi goes through the same cross machinery as every other triple, so --emit=obj yields a real module:
$ ae build --target=wasm32-wasi --emit=obj lib.ae -o lib.o && file lib.o
lib.o: WebAssembly (wasm) binary module version 0x1 (MVP)
The two WASI defines are injected automatically. WASI's setjmp.h refuses to compile without -D__wasm_exception_handling__=1 ("Setjmp/longjmp support requires Exception handling support"), and WASI has no POSIX signal API without -D_WASI_EMULATED_SIGNAL. ae build adds both when the target resolves to a WASI triple, so nothing needs passing by hand.
A full executable link works as of #1655, including actor programs:
$ ae build --target=wasm32-wasi hello.ae -o hello.wasm
$ wasmtime hello.wasm
hello from wasi
Four things had to change for that, and they are worth knowing because each was a place where WASI had been forgotten beside Emscripten:
- The scheduler. WASI has no usable threads, but zig's wasi-libc ships pthread stubs whose
pthread_createreturnsEAGAIN, so the threaded runtime linked, started, printed "Failed to create scheduler thread", and then span forever onscheduler_start()'s readiness barrier. A wasi exe now selects the cooperative scheduler, the same substitution the Emscripten backend has always made. AETHER_HAS_PROCESS(new).std/os/aether_os.cwas guarded entirely by!AETHER_HAS_FILESYSTEM, so the only way to compile outforkwas to compile out the filesystem with it. Emscripten accepts that trade; WASI must not, because a capability-based filesystem is the point of WASI. The two capabilities are now separate.- Computed-goto dispatch. Actor codegen emits a table of label addresses, which wasm rejects ("relocations for function or section offsets are only supported in metadata sections"). The guard excluded
__EMSCRIPTEN__but not__wasi__. setjmp/longjmpselection.aether_panic.cguarded its crash handler with!defined(__wasi__), butaether_panic.h's macro selection did not. WASI is hosted and does not define__EMSCRIPTEN__, so it fell into the POSIX arm and got_setjmp/_longjmp, which wasi-libc declares but never implements. A link error, so it surfaced only at the end of a cross build (undefined symbol: _longjmp). See the caveat below.
panic / try / catch are fail-stop on WASI
There is no working setjmp on wasm32-wasi in either spelling. _setjmp is declared but unimplemented; plain setjmp is a hard #error in wasi-libc directing you to -mllvm -wasm-enable-sjlj and an engine implementing the exception-handling proposal (measured on zig 0.16.0, that flag does not help, the #error fires first). Real support needs the WebAssembly exception-handling proposal in both toolchain and engine.
So on WASI the runtime does not unwind: AETHER_SIGSETJMP always takes the first-return arm and AETHER_SIGLONGJMP calls abort(). A panic() traps the instance instead of unwinding to the nearest catch, and a catch block therefore never runs:
$ node --experimental-wasi call.mjs module.wasm
aether: panic outside any try/catch or actor: negative
safe(-1) -> trapped: RuntimeError
That message says "outside any try/catch" even when there is one, because the frame never registers. This is a real semantic reduction, and the alternative is that WASI cannot link at all. Nothing silently mis-executes: abort() is a trap the host observes, not a fallthrough into a half-unwound stack. Code targeting WASI should treat panic as fatal and use (value, err) returns for anything it expects to recover from.
--target=wasm remains the route to a runnable browser bundle with JS glue; wasm32-wasi produces a self-contained module for a WASI runtime.
wasm32-freestanding is deliberately not offered: it ships no libc, so the generated C's #include <stdio.h> cannot resolve and --emit=obj fails outright. Its only working mode would be --emit=csrc, which emits the same target-neutral bytes as every other target anyway.
The same vendored engine backs two native cases: a build box with no system libpcre2-8 (make compiles the vendored copy instead of stubbing std.regex out, PCRE2=0 restores the stub, PCRE2=vendored forces the vendored engine even when a system library exists), and ae's no-libaether.a source fallback, which always compiles the vendored engine so an installed toolchain never ships a silently-stubbed regex.
Build Recommendations
| Use Case | Flags | Notes |
|---|---|---|
| Development | -O0 -g | Fast iteration, debug symbols |
| Testing/CI | -O2 | Balanced optimization |
| Release | -O3 -march=native -flto | Full optimization |
| Profiling | PGO pipeline | Based on representative workload |
| Hardened | HARDEN=1 | See "Hardening" section below |
| WASM | PLATFORM=wasm | Cooperative scheduler, Emscripten |
| Embedded | PLATFORM=embedded | Cooperative scheduler, no OS |
| Cross-OS/arch | ae build --target=<triple> | zig cc backend, POSIX host, executables + --emit=csrc/--emit=obj |
| iOS arm64 | ae build --target=aarch64-ios | Xcode/xcrun backend, macOS host, --emit=staticlib (App Store) / --emit=lib, see cross-ios.md, and swiftui-ios-app.md to call it from a SwiftUI app |
| Mac Catalyst | ae build --target=aarch64-ios-macabi | Xcode/xcrun backend on the macOS SDK, platform MACCATALYST, min 13.1 (x86_64) / 14.0 (arm64), see cross-ios.md |
| Cross-wasm | ae build --target=wasm32-wasi | zig cc backend; executables + --emit=csrc/--emit=obj |
Hardening (HARDEN=1)
Opt-in hardening flags add stack canaries, fortified libc-call wrappers, and format-string-injection diagnostics. Enabled with the HARDEN=1 environment variable; disabled by default in release builds because the runtime overhead is non-zero (~3-5% on micro-benchmarks) and macOS Clang has historically been finicky with _FORTIFY_SOURCE on a few setups.
# Local hardened build, recommended before submitting a PR that
# touches C in compiler/, runtime/, or std/.
HARDEN=1 make compiler ae stdlib
# Hardened CI: run the full suite end-to-end under hardening.
HARDEN=1 make ci
Flags enabled:
| Flag | Purpose |
|---|---|
-fstack-protector-all | Stack canaries on every function (not just gcc-strong heuristic candidates), catches the smashing class of bugs that escape the default heuristic. |
-D_FORTIFY_SOURCE=2 | Runtime checks on read/write/memcpy/strncpy/printf-family calls. Linux gcc also emits compile-time warnings when it can prove a buffer overflow, those should be fixed at the source, never blanket-suppressed. Requires at least -O1; the default -O2 satisfies that. |
-Wformat -Wformat-security | Diagnose printf-family format strings sourced from non-literals (the %s-format-injection class). Default in modern Linux distros; we standardise on it explicitly. |
The flags are added to CFLAGS only when HARDEN=1 is set; the default build path is byte-identical to the unhardened release. The Linux/Hardened (gcc) CI matrix entry pins this so a regression that introduces an unchecked memcpy-over-fixed-buffer trips a red check before merge.
LLM diagnostics (AETHER_ENABLE_LLM=1)
ae help <script> --llm <weights.gguf> is an opt-in offline local-LLM escalation path for the config-IS-code diagnostic flow. Default builds omit the LLM module entirely so the binary stays small and the link line stays dependency-light; embedders enable it explicitly at build time.
# Default build, `ae help --llm <path>` returns a clear "rebuild with
# AETHER_ENABLE_LLM=1" message; no llama.cpp dependency.
make ae
# LLM-enabled build, requires llama.cpp built and linkable. The shim
# in `tools/llm_shim.c` targets the stable `llama.h` C API.
make ae AETHER_ENABLE_LLM=1 \
LLM_LDFLAGS="-L/path/to/llama.cpp/build -lllama -lggml -lstdc++ -lm" \
LLM_CFLAGS="-I/path/to/llama.cpp"
Hard privacy invariants (enforced by code structure + a Linux CI strace -e network guard):
- No network calls. The shim opens exactly the weights file the user names. No download, no fetch, no telemetry, no "anonymised usage stats." Same offline as online.
- No bundled weights. We ship no
.gguf. You bring your own (3-7B range works on a laptop CPU; larger models also fine but slower). - No model marketplace integration. No
ae help --download-model. Path argument only; a missing path is a clean error, not a "would you like to fetch it?" prompt. - Stripped builds (default) omit the entire module. A binary built without
AETHER_ENABLE_LLM=1cannot run inference even if someone passes--llm <path>, they get the documented "rebuild with the flag" message instead.
The shim's surface is a single C function (int ae_llm_run(const char* weights_path, const char* prompt, FILE* out)); upstream API rotations in llama.cpp need touching only that one TU. See docs/cic-help.md for the full design.
References
- GCC Optimization Options: https://gcc.gnu.org/onlinedocs/gcc/Optimize-Options.html
- LLVM PGO Guide: https://llvm.org/docs/HowToBuildWithPGO.html
- llama.cpp (C API used by the LLM shim): https://github.com/ggerganov/llama.cpp