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 | inline ghost text in the editor. Not called since 2.0's editor; kept so older extensions still link |
am_ext_complete | Tab candidates. Tab completion went in 2.0, so nothing calls it; kept so older extensions still link |
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, so 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 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 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.
t:([]sym:`AAPL`MSFT`AAPL; px:187.3 411.2 187.4; sz:100 250 50)
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.