C API & extensions
Amber has exactly two seams, and both live in one header. src/ext.h
sections 1–5 are the in-process registry: drop a .c file into
ext/, rebuild, and it plugs itself in without a line of src/ being patched.
Section 6 is the out-of-process API on the front of libamber.so.
The in-process seam — ext/
ext/ is empty in a stock checkout. An out-of-tree package installs itself by dropping
.c files there and re-running ./build.sh; they are compiled with the same
flags, linked into the same binary, and register themselves from a constructor.
| Hook | What it lets an extension do |
|---|---|
am_ext_verb("xyz", fn) | register a backtick verb — `xyz x — at runtime |
am_ext_bs | claim a \-command before the "unknown \cmd is a shell command" fallback |
am_ext_hint | offer inline ghost text in the editor — never inserted until accepted |
am_ext_complete | add Tab candidates ahead of the built-in lexical sources |
am_ext_startup | run once, lazily, when the REPL first reads a line |
am_ext_usage / am_ext_banner | append to --help and to the banner |
The Amber-level half goes in lib/; repl.k loads lib/ext.k
whole-file at startup if it exists, fully trapped, and an extension may define the optional
ext.pre / ext.post / ext.err / ext.raw /
ext.tag hooks.
Pulling a new Amber release must never conflict with a package you installed, and a user who
installs nothing must pay nothing — every hook is a null pointer and every call site is a
predictable branch. tests/ext_probe.c is a complete worked example;
tests/test_ext_seam.sh installs it, checks every hook, and uninstalls it again.
amber-ai is installed exactly this way:
git clone https://github.com/BonucciAndrea/amber-ai.git
cd amber-ai && ./install.sh /path/to/amberThe out-of-process seam — libamber.so
./build.sh # ./amber (unchanged; identical binary, zero new cost)
./build.sh --shared # ./amber + libamber.so
./build.sh --shared-only # libamber.so only
AMBER_SHARED=1 ./build.sh # same as --shared, for callers that cannot pass a flag#include "ext.h" /* section 6 -- nothing else from src/ */
amber_init("/path/to/amber"); /* boots the engine, loads the .k stdlib */
amber_value t = amber_eval_qsql("select vwap:wavg[sz;px] by sym from trades");
int type; long long n; int bits;
const void *px = amber_get_vector_ptr(amber_table_column(t, 2), &type, &n, &bits);
/* `px` IS the engine's column payload. Not a copy of it. */~60 entry points, all named amber_*. Boot and evaluate
(amber_init, amber_eval_str, amber_eval_qsql,
amber_call), reference counting (amber_retain / amber_release),
the zero-copy vector seam (amber_get_vector_ptr), tables and dictionaries, constructors
for pushing data back in, the Arrow C Data Interface, rendering, and amber_plugin_load
for dlopening a native plugin into a running engine.
What the shared build changes — and what it deliberately does not
The executable is untouched
./build.sh with no flags produces byte-for-byte what it produced before, from the same
objects, with the same flags. The shared library is a separate object set
(-fPIC -Dshared): position-independent code and the global-dynamic TLS model a
dlopen'd library needs both change code generation, and sharing objects between the two
would silently pessimise ./amber.
Only amber_* and am_ext_* are exported
Amber's internal C is written in a terse K-derived idiom — the engine's own globals are called
mr, su, us, err, run, add,
sub, pk, cpl. Those are perfect inside one static binary and
actively dangerous inside a library loaded next to NumPy, libarrow and libpython. An export map
(src/libamber.map) makes everything else genuinely absent from the
dynamic symbol table, so it cannot be bound to by accident and cannot interpose on a host's symbol of
the same name.
The library records a SONAME
A satellite that dlopens a second copy of libamber.so gets a
second engine: two heaps, two symbol tables, two global namespaces, and values from
one that are meaningless to the other — with no error, because nothing is technically wrong. The
SONAME is what lets the loader recognise an already-loaded copy and reuse it, so
python-amber, libamber_arrow.so and any plugin in one process share one
engine.
amber_arrow.library_path() tells you which one you got.
TLS drops to global-dynamic in the shared build only
./amber keeps the initial-exec model on the allocator's hot path. A
dlopen'd library cannot: it is resolved out of the static TLS block the loader sizes
before main(), and borrowing from glibc's small surplus reserve fails
nondeterministically — with cannot allocate memory in static TLS block —
depending on what else the host imported first.
Apache Arrow C Data Interface
The engine exports Arrow directly, in about ninety lines (src/ar.c), with no
libarrow linkage at all. Export is zero-copy.
p:arrow.export t / table -> (schemaAddr; arrayAddr) 64-bit C-ABI pointers
arrow.import p / (schemaAddr; arrayAddr) -> Amber tableThat is exactly the right amount of Arrow for a 10,000-line C runtime to own: it covers "hand me
this table" completely. What it does not cover is "hand me this 400-million-row table in batches,
and stop when I stop asking" — the ArrowArrayStream — and that lives out in
amber-arrow, where it can grow.
Verification
tests/test_capi.sh # release build, then ASan + UBSan
tests/run_tests.sh --asan # the whole suite, the C API includedtests/test_capi.c is the only consumer of libamber.so inside the engine
repository, and it is written the way a satellite would write it: it includes src/ext.h
and nothing else from src/, never dereferences an amber_value, and links the
shared library rather than the objects. If it ever needs a second -I, the API is
wrong.
Because a C API whose ownership rules are only documented is a C API whose ownership rules are wrong, and LeakSanitizer is what checks the prose.