ADR-125: A Library for Other Languages — pub extern "C" fn Is an Entry Point, the Caller Owns the Memory, and Every Call Answers With a Status

Status: Superseded by ADR-284 — its D1-D10 are D4-D13 there (D10 together with ADR-128) Date: September 19, 2026 Target Version: Nikaia 0.0.8 Related: ADR-062 (a build that lets foreign code call in is a target; one artifact, safe at its boundary), ADR-124 (extern is reserved with its construct — the block without a body declares a foreign function; this record gives the word its second form, a function with one), ADR-119 D4 and D7 (an allocator the build names; settings baked at build time), ADR-112 (an expired cleanup deadline), ADR-115 (secondary), ADR-110 and ADR-111 (the doors, and what leaves a lock), ADR-010 D1 (an FFI value is Untrusted), ADR-005 D8 and ADR-100 (a deterministic ledger and --locked), Part I 4.2 and 6.4, Part II 12.2, Part III 13.3, 15.1, 17.2, Appendix A

1. Context

A language that lowers to Rust can be the language a library is written in for programs written in something else: a C program, a Python module, a Node add-on, a Java service over Panama. Every one of those hosts speaks the C calling convention, and the higher-level bindings (PyO3, napi-rs) are comfortable wrappers over the same entry points. So the C ABI is not one target among many; it is the floor every other host stands on, and it is what this record decides.

ADR-062 settled the hard half: such a build is a target, because the caller brings the threads; every entry point and everything reachable from one takes the safe shape (atomic counts, real locks), the rest of the library keeps the per-value answer, and no check changes because no check ever read the switch. What it left open is everything a C programmer sees: how an entry point is written, what crosses the boundary and in which memory, how a failure arrives, what a function that pauses does to a caller that has no executor, and how the library starts and stops inside a process it does not own.

Two rules of good C libraries shape the answers, because the caller’s expectations are the specification here. The caller owns the memory: it allocates the buffers results are written into, and if it wants, it supplies the allocator for everything else, so that a host with its own heap discipline — an embedded system, a garbage-collected runtime, a fuzzer — is never handed memory it did not ask for. And a call never surprises: it returns a status, never unwinds, never blocks a thread it was not told it would block, and never touches memory after the caller freed it.

2. Decisions

D1 — pub extern "C" fn with a body is an entry point, and the artifact is the build’s.

pub extern "C" fn greet(name: &str) -> String { f"Hello, {name}" }

pub struct Session { … }
impl Session {
    pub extern "C" fn(url: &str) -> Session throws { … }     // the constructor
    pub extern "C" fn send(&mut self, payload: &[u8]) throws { … }
}

extern "C" before fn is ADR-124’s word in its second form: the block without a body declares a function the language calls; a function with a body is one the language provides. The string names the physical calling convention, and there is one: "C" is what every host speaks, a WebAssembly module’s exports included — the target names WebAssembly, not the word (ADR-130).

The build says what is made of it: artifact = "c-library" in [build], beside target. The pair is what ADR-062 calls the target — the platform and whether foreign code may call in — and the second key is the answer to that record’s first open question. The artifact is a shared library and a static one for the platform, and a header, <package>.h, generated from the ledger.

Four refusals at the declaration, in this language’s words: extern without pub (what is reachable from outside must be reachable from inside); extern with a body in a build without a library artifact (nothing would be exported, and silence would let somebody believe otherwise); a generic function or a trait method (C has no way to ask for an instance); and a signature whose shape D2’s table does not cover, naming the shape to use.

D2 — What crosses the boundary, and what it becomes in the header.

in the declaration in C rule
i32, i64, u8, f64, bool, char int32_t, int64_t, uint8_t, double, bool, uint32_t by value, both ways
&str, &[u8], Bytes in const char*, size_t / const uint8_t*, size_t a pointer and a length, never a terminator scanned for; the caller keeps ownership; text is checked for UTF-8 at entry and refused with a status if it is not
String, Bytes, Vec[u8] out uint8_t* out, size_t cap, size_t* written D3: the caller’s buffer
Vec[i64] and other lists of numbers in const int64_t*, size_t an array the caller keeps
a pub extern "C" struct the struct, by value C’s layout, copied both ways; a Vec of them is an array in and the caller’s buffer out (ADR-127)
any other pub struct the library exports struct <package>_<Type>*, opaque a handle; created by its extern constructor, released by <package>_<Type>_free, its pub fields read through <package>_<Type>_<field>, changed through its extern methods and nowhere else
enum without payload a C enum, <PACKAGE>_<TYPE>_<VARIANT> numbered in declaration order, and the numbering is part of the ABI: a variant is appended, never reordered
enum with payload an opaque handle with <package>_<Type>_kind and a getter per variant field as a struct
T? of a handle or a text a null pointer absence is NULL
T? of a number a status code, <PACKAGE>_NONE C has no absent integer
fn(A) -> R sync as a parameter a C function pointer and a void* ctx, forwarded the callback is called on the thread that called in, or on a library thread for D6’s async form; a callback type that may pause is refused, because C cannot. A callback returning bool is the streaming shape, and its false is the caller’s stop (ADR-129)
Locked, Shared, Seen, a map, a list of anything but numbers and value structs — refused at the declaration; the shape to use is a handle around a struct that holds it, or — for many results — a function that takes a fn(item) -> bool sync

Every value that enters is Untrusted — ADR-010 D1’s barrier for an FFI value, unchanged — which is what a program wants of bytes a host it does not know handed it.

D3 — The caller owns the memory: results go into its buffers, and it may supply the allocator for everything else.

A result that is text or bytes is written into the caller’s buffer. The exported form of fn render(…) -> String is

int hello_render(const char* name, size_t name_len,
                 uint8_t* out, size_t cap, size_t* written);

The library writes at most cap bytes, sets written to the length of the result, and returns HELLO_E_TOO_SMALL where cap did not hold it — with written carrying the size that would — so that the caller comes back with a buffer that does. out == NULL is the size query. Nothing is allocated for the caller and nothing is freed by it.

Where the library must hold memory across calls — a handle, its contents, the runtime’s own state — it takes it from an allocator the caller may supply:

int hello_set_allocator(void* (*alloc)(size_t size, size_t align, void* ctx),
                        void  (*free)(void* ptr, size_t size, size_t align, void* ctx),
                        void* ctx);

Called before the first entry point (before hello_init, D7), it becomes the library’s global allocator, and everything the library allocates from then on comes from it — the emitted crate’s allocator is a forwarder to these two pointers. Not called, the platform’s heap is used, so a caller that does not care does nothing. <package>_<Type>_free stays, because the caller cannot know what a handle holds, and it returns the memory to the allocator it came from.

What a callback receives is a view that lives for the call. Text or bytes handed to a C callback are valid until the callback returns; a caller that wants them afterwards copies them into memory of its own. That is the rule the language has inside, with views, said at the boundary.

D4 — Every entry point returns a status, and a value travels in an out-parameter.

One shape for every exported function, whether or not it throws: int <package>_<name>(inputs…, outputs…). 0 is <PACKAGE>_OK. A function that throws numbers the variants of its error type from 1, per library and in declaration order, and the header names each as <PACKAGE>_E_<VARIANT>; the numbering is part of the ABI as an enum’s is. Negative codes are the boundary’s own and the same in every library:

code meaning
-1 E_ARGUMENT a null pointer where one was not allowed, text that is not UTF-8, a handle already freed
-2 E_TOO_SMALL the caller’s buffer did not hold the result; written says what would
-3 E_NOT_RUNNING the library has not been initialised, or has been shut down
-4 E_PANICKED a panic was caught at the boundary; the library is poisoned (D5)
-5 E_REENTRANT a call on a handle from inside a callback running on that same handle (D8)
-6 E_CLEANUP shutdown’s drain did not finish inside its deadline (D7)
-7 E_CANCELLED the async call was cancelled through its ticket before it finished (ADR-129)

<package>_last_error(uint8_t* out, size_t cap, size_t* written) renders the last failure on this thread: the message, the raise site of ADR-023 D6, and the secondary list of ADR-115, indented. It is a thread-local slot, so two threads failing at once do not read each other’s failure.

D5 — A panic never leaves the library; it is caught at the boundary and poisons it.

Unwinding into C is undefined behaviour, and aborting is ending a process that is not ours. So the emitted crate for a library artifact unwinds — the one artifact where Appendix A’s abort at user_parallelism = no does not hold, for the reason that page gives for aborting: there, nobody survives for whom anything else would be done; here, the host does. Every entry point catches at the boundary, returns E_PANICKED, and the library is poisoned: every later call returns E_PANICKED until <package>_shutdown and <package>_init have run again. A half-finished library state is not one the host may keep using without saying it knows.

The panic hook runs before the catch, as it does everywhere (Part I 7.2).

D6 — A function that may pause is exported twice: blocking, and with a callback.

The caller has no executor. So pub extern "C" fn fetch(…) whose body may pause becomes two symbols:

A sync function has the first form only. A C programmer who wants nothing to do with pausing uses the first form and never learns the second exists. On a WebAssembly target, where the host’s thread may not block, only the second form exists and the generated JavaScript makes it a Promise (ADR-130).

D7 — The runtime starts on the first call or on request, and stops on request.

<package>_init() starts the runtime — idempotent, safe from any thread — and every entry point calls it if nobody has, so a caller that does not care does nothing. <package>_shutdown() drains pending cleanups within cleanup-deadline; where the deadline expires it returns E_CLEANUP with the resources named in last_error, and never ends the process, because the process is not ours (ADR-112 is the program’s rule, and this is its library form). After shutdown every call is E_NOT_RUNNING until init. set_allocator after init is E_ARGUMENT.

There is no file to read at start: nikaia-runtime.toml’s settings are read at build time and baked into the artifact, as ADR-119 D7 does for a target without a filesystem, and user-pool sizes the library’s own executor for D6’s async form.

D8 — A handle is guarded, and a re-entrant call on it is a status, not a deadlock.

A C program may call two methods on one handle from two threads at once, and nothing in C stops it. Inside the language that is a data race a compiler refuses; at the boundary it is caught by a lock the handle carries: exclusive for a method the ledger marks mutates, shared otherwise, taken for the duration of the call. The cost is one lock per call, which is what a boundary is for.

A callback that calls back into the same handle from inside a method’s call would wait on that lock forever. The lock records the thread that holds it, and a call from that thread on the same handle returns E_REENTRANT instead of waiting. The header says that a callback may not call into the handle it was called from.

D9 — The header is generated from the ledger, carries its hash, and --locked catches an ABI change.

<package>.h is derived from nikaia.contracts — the signatures, the types, the error variants, the enum numbering — and carries the ledger’s hash and the package version in a comment. An exported signature that changes changes the ledger, and a --locked build refuses until the ledger is committed (ADR-100, ADR-101); so an ABI change is never an accident a downstream build discovers by linking.

D10 — Names.

<package>_<fn> for a function — the prefix is the package’s name, or the one symbol-prefix the build sets, and no declaration renames itself (ADR-128) — <package>_<Type>_<method> for a method, <package>_<Type>_new for the anonymous constructor, <package>_<Type>_free, <package>_<Type>_<field> for a pub field’s getter; constants <PACKAGE>_OK, <PACKAGE>_E_<VARIANT>, <PACKAGE>_<TYPE>_<VARIANT>. A name that collides after prefixing is refused at the declaration. Only these symbols are visible in the artifact; everything else is hidden.

3. Consequences

4. What this does not decide

5. What is built

Nothing. extern "C" with a body is a parse error, there is no library artifact, and the compiler has no header generator. The order of work:

  1. the parser: pub extern "C" fn with a body; the four refusals of D1;
  2. artifact = "c-library" in [build], and the safe shape seeded at entry points as ADR-062 D2 says;
  3. the emitter’s wrapper per entry point: status and out-parameters (D4), the buffer protocol (D3), the UTF-8 check, the catch at the boundary (D5);
  4. set_allocator, init, shutdown, last_error, free and the getters, with baked settings (D3, D7);
  5. the blocking and async forms (D6), and the handle lock with re-entrance detection (D8);
  6. the header generator with the ledger hash (D9), and the naming (D10);
  7. an example: a library with a handle, a text result and a throws, called from a C program in examples/, and the test that links it.

#88 carries it. Five records stand on this one: the value struct (ADR-127), the symbol prefix (ADR-128), cancel and stream (ADR-129), the WebAssembly library (ADR-130) and the generated bindings (ADR-131).

This site is open source. Improve this page.