artifact = "c-library")Open and answer this issue on GitHub.
targets, ready · opened 2026-09-30 · updated 2026-10-10 · 9 comments
Owner’s order: second of the five big pieces (after std::db). Record: ADR-125, all of it. Nothing built.
A pub extern "C" fn with a body is an entry point, and artifact = "c-library" in [build] makes the package a library. What a C caller gets is deliberately narrow: numbers by value; text and bytes in as pointer and length, out into a buffer the caller owns (size, capacity, written; a NULL buffer asks for the size); a struct as an opaque handle with _new and _free; an enum as numbered constants; an optional as NULL or the package’s NONE. Every entry point returns an int status and puts values in out-parameters; a throws variant is a positive code, the library’s own failures are the seven negative ones, <pkg>_last_error carries site and secondary list. A caller may hand over its allocator before init. A panic is caught at the boundary and poisons the library until shutdown/init. A pausing function is exported blocking and as _async. Every handle carries a lock; a re-entrant call on the same thread is a status, not a deadlock. The header is generated from the ledger and carries its hash.
The direction ADR-124 and ADR-147 do not face: those let a program call C, and both are built.
Order of work (ADR-125 §5): the parser and the four refusals; the artifact and the safe shape at entry points; the wrapper per entry point; the runtime surface (set_allocator, init, shutdown, last_error, free, getters); blocking and async forms and the handle lock; the header generator and naming; a library called from a C program in examples/, and the test that links it.
Sub-issues carry the pieces that build on it (struct by value, symbol prefix, cancellation/streams, WebAssembly, bindings, the exported-roots analysis). The bare-metal target reuses this library’s allocator and baked settings and comes after it.
a644cb4, 0.0.664 5110b39)The record is ADR-284, into which ADR-125 was merged. ADR-324 D6 changed the spelling to extern, without "C".
Step 1: the parser and the four refusals.
pub extern fn … { body } parses, and the tree records is_extern.NK1237: an entry point that is not pub;NK1238: an entry point in a build that makes no library;NK1239: a generic entry point, or a trait’s method;NK1240: a parameter or result that does not cross to C. The help names the shape that does: ref String in, String out, ref Array[T] of a number, a pub struct as a handle, a callback written sync.Steps 2 and 3, for D5’s first row, and the header (step 6, in part):
[build] artifact = "c-library" makes lib<package>.so, lib<package>.a and <package>.h in target/nikaia/c-library/.symbol-prefix defaults to the package’s name, and must be a C identifier, naming the character that is not.pub extern fn of i32/i64/u8/f64/bool/scalar, in and out, that neither pauses nor throws, is exported as <prefix>_<name> (cexport.rs):
int status and writes its value through an out-parameter;uint32_t that is no Unicode scalar is E_ARGUMENT;E_PANICKED, and poisons the library for every later call (D8).nikaia run refuses a library, and a library that exports nothing is refused.tests/c_library.rs. A C program, compiled with cc and linked against the shared library, calls every shape, including the poisoning. It is skipped where there is no C compiler.Next, in ADR-284 §5’s order:
E_TOO_SMALL);throws as positive codes, and last_error;init, shutdown, set_allocator) and the blocking and _async forms for a function that pauses;examples/.Found on the way, not fixed: a function that calls panic(…) cannot be sync, because std.contracts has no entry for panic. So an entry point that panics on purpose is refused as pausing. The test triggers its panic with an index out of range instead.
Generated by Claude Code
Progress since 0.0.664:
Text, Bytes and runs (ptr, len), and hand text back into the caller’s buffer (out, cap, written; E_TOO_SMALL when it does not fit). The unsafe lives in the new crate crates/unsafe/c-boundary (ADR-218, Miri-checked).<PREFIX>_E_<VARIANT>, and the message is available through <prefix>_last_error(out, cap, written) (thread-local). Covered by tests/c_library.rs (THROWING_CALLER).Still open, in ADR-284 §5 order:
_new, _free, getters)init, shutdown and set_allocator_async forms for functions that pauseE_REENTRANT)examples/std and throws of several typesGenerated by Claude Code
Progress, 0.0.668–0.0.676 (all on develop, each covered by crates/nikaia/tests/c_library.rs with a C program):
enum without payload is a C enum (<PREFIX>_<TYPE>_<VARIANT>), checked on the way in.pub struct is a handle: _new from the extern anonymous constructor, extern methods, a getter per pub field, and _free. The handle lock is in c-boundary (Handle, shared/exclusive/free). A re-entrant call is E_REENTRANT (D11).<prefix>_init, <prefix>_shutdown, and E_NOT_RUNNING. Poison is cleared by shutdown followed by init (D8, D10).void *ctx. A call back into a held handle is E_REENTRANT.null is NULL, for ref String?, ref T? and T? of a handle.examples/c-library (wordtally), with use.c. It is built in place under --locked by the test.pub extern struct gets #[repr(C)] and crosses by value. NK1145 refuses a field that is not a C value, and NK1237 refuses one that is not pub. The wrapper uses a mirror struct whose enum and scalar fields are checked (D14–D17).shutdown drains and answers E_CLEANUP instead of ending the process (rt::Started::drain)._async form, with the ticket, <prefix>_cancel at the next pause point, and <prefix>_op_free (D9, D19).Still open, split by what it needs:
Needs a decision (ADR-284 does not say):
<PACKAGE>_NONE for T? of a number (D5). D7 says the boundary owns seven negative codes, and NONE is not one of them; the positive codes are the variants. Options: -8 as an eighth boundary code, or 1 with the variants starting at 2. My recommendation is -8, written into D7.enum with payload (D5: “_kind and a getter per field”). This needs a name for a positional field (TooLarge(i64)), and a rule for the status a getter returns when the handle holds another variant. My recommendation: <prefix>_<Type>_<Variant>_<n> for positional fields, and E_ARGUMENT when the variant is wrong.std’s errors (io::Error, …) thrown by an entry point. D7 numbers the library’s own variants. My recommendation: number std’s variants after the library’s own, in ledger order.Buildable, not built yet: String? handed back; Array[T, N] fields at the boundary; methods of an extern struct (D16); Vec of them; set_allocator (D6/D10), which needs an allocator hook that is sound for memory allocated before it was set; a library executor in place of one thread per _async call; the WebAssembly target (D22–D25); nikaia bind (D26–D29).
Generated by Claude Code
Progress, 0.0.683 to 0.0.694:
Shared values are safe on the caller’s threads (#113). Public signatures and fields keep the atomic floor at user_parallelism = no, and c-boundary’s handles require Send + Sync.nikaia bind python (#112). It writes a ctypes module from the same reading as the header, with:
extern structs as Structure;IntEnum;_async form as done= or an awaitable with cancel();pub extern struct (D16). self is const T* or T*, there is no lock, and a changed struct is written back only where the method returned.extern structs (D16). ref Array[T] or Vec[T] comes in as const T* with a count, and Vec[T] goes out into the caller’s buffer, counted in structs.Array[T, N] field of a number or bool becomes T name[N] (D15)._async calls run on library threads that are kept (rt::library), no longer one thread started and ended per call.Still open:
<PACKAGE>_NONE, getters of an enum with payload, codes for std’s errors).scalar, enum or struct;set_allocator, which needs an allocator hook that is sound for memory allocated before it was set;nikaia bind js and node.Generated by Claude Code
Progress, 0.0.695 to 0.0.698:
extern struct’s Array[T, N] field may hold any C value: a scalar, an enum without payload, or another extern struct. Each element is checked as a field would be.<package>_set_allocator(alloc, free, ctx) (D6, D10).
alloc(size, align, ctx) hands out a block and free(at, size, align, ctx) takes it back.#[global_allocator] is c_boundary::Heap. It chooses once: at that call, or at its first block, whichever comes first. So every block goes back to the allocator that handed it out, including memory allocated before set_allocator could have been called.init or after any other call, or with a NULL function, it is E_ARGUMENT.nikaia bind node (D28). It writes target/nikaia/c-library/node/<package>.c, an N-API module in C, and a binding.gyp. No napi crate enters the emitted crate.
Error whose code is the header’s name and whose status is the number.string and bytes a Buffer.i64 is a number while it is a safe integer, and a bigint beyond.close() and a GC finalizer.extern struct is a plain object, and its methods are functions of <Struct>.enum is a frozen object.cc -Wall -Wextra -Werror against Node’s headers and runs it under node --expose-gc._async form returns a Promise. It is settled on the main thread through a thread-safe function, and its cancel() is the ticket.Still open:
<PACKAGE>_NONE, getters of an enum with payload, and codes for std’s errors.nikaia bind js (D22–D25, #111).Generated by Claude Code
1def0b9)Question (from the comment on 0.0.668–0.0.676): which value does the status <PACKAGE>_NONE get for a T? of a number? ADR-284 D5 named the status but gave it no value.
Options weighed:
-8, an eighth boundary code. This was the building session’s recommendation. A caller’s if (s < 0) would treat an ordinary not found as the boundary failing.1, with the variants from 2. This puts absent among the errors and renumbers them.bool* present out-parameter, so the status keeps one meaning. This is the usual Rust FFI convention: cbindgen exports no Option<i64>, so crates add a flag or a { bool, T } struct. SQLite’s positive non-error codes (SQLITE_ROW, SQLITE_DONE) are the cautionary example.Decided (owner): the flag. ADR-284 D30 (D5’s table and D16 changed); Part III 15.1.
T? of a number or of an extern struct, as an entry point’s result, is exported with the value’s out-parameter followed by bool* present. The status is OK either way. Where present is false, the value’s out-parameter is left untouched. The header writes both parameters.
tests/c_library.rs: fn find(key: ref String) -> i64? returns OK with present = true and the value, and OK with present = false and n unchanged.T? of a number.nikaia bind python makes it None. nikaia bind node makes it null.Decisions 2 (getters of an enum with payload) and 3 (codes for std’s errors) follow, one at a time.
Generated by Claude Code
enum with payload (0.0.723, 69b79af)Question: ADR-284 D5 gives an enum with payload a handle, with _kind and “a getter per field”. It does not say how a positional field is named, or what a getter answers on a handle that holds another variant.
Decided (owner):
<prefix>_<Type>_<Variant>_<field>.0 (lib_Shape_Circle_0), as Rust’s .0.E_ARGUMENT and leaves its out-parameter untouched. last_error names the variant the handle holds.pub extern enum is a tagged union by value (D32). It stands to an enum as pub extern struct stands to a struct.
#[repr(C)] gives and what cbindgen writes: a tag and a union with one struct per variant that has fields.NK1145 otherwise).tag that names no variant is E_ARGUMENT._kind, plus one getter per field per variant, named as above.
tests/c_library.rs:
Shape::Rect { w, h } read through lib_Shape_Rect_w;Circle(f64) read through lib_Shape_Circle_0;lib_Shape_Rect_w on a Circle is E_ARGUMENT, and last_error names Circle.pub extern enum.
extern before enum.NK1145 for a field that is no C value.#[repr(C)] on the enum._Tag enum and the typedef struct { tag; union { … }; }. A variant without fields gets no member, and a member is the variant’s name in lower case.Rect, passes it in, gets a Circle back, and reads tag and circle._0. A tag of 99 is E_ARGUMENT.Vec of them in and out, methods with self as const T* or T*, and use as a field of an extern struct.ctypes.Structure with the union.{ kind: "Rect", w, h }, converted both ways.Decision 3 (codes for std’s errors) follows.
Generated by Claude Code
std’s errors (0.0.724, c12e561)Question: which status an entry point returns for an error it throws from std. Today such a function is not exported (not_yet in cexport.rs). That covers every library that reads a file, since 38 ledger entries throw io::IoError.
Options weighed:
std’s variants after the library’s own (the building session’s recommendation). Rejected: every std code moves when the library adds a variant, and every library code moves when std adds one. NotFound would also have a different number in each library.EILSEQ is 84 on Linux, 92 on macOS and 42 under Windows’ C runtime. A caller comparing a status with its own EILSEQ would misread it.std errors at the boundary. Rejected: every library that reads a file would have to translate its errors before it could be exported.Decided: ADR-284 D33, with D7 changed. Part III 15.1 holds the table.
1 to 99 999 are std’s.
std error that is an errno has the number of the target the library is built for: NotFound is ENOENT, PermissionDenied is EACCES, NotText is EILSEQ. WASI’s numbers apply under WASI.std’s other errors have fixed numbers from 1000, a block of a hundred per type: Outside 1000, Other 1001, task::Crashed 1100, supervisor::Escalated 1200, cleanup::Failure 1300, Overtaken 1400. A new variant is appended to its block, and a number is never moved.100 000, no longer at 1.100 000. This changes examples/c-library, its use.c, the Python and Node bindings, and tests/c_library.rs.std’s errors.
io::IoError, or several types, is exported. The header writes <PACKAGE>_E_IO_NOT_FOUND and so on, with the target’s errno value (from libc at build time) or the fixed number.ENOENT, and the C test compares the status with <errno.h>’s ENOENT.last_error carries the message of Other(String).ENOENT raises FileNotFoundError and EACCES raises PermissionError. Both are subclasses of the package’s Error as well, if that can be done cleanly; otherwise the package’s Error, with errno set.code is 'ENOENT', and errno is the number.errno values.All three decisions on this issue are now taken.
Generated by Claude Code
Decision 1, D30: an absent number is a flag (0.0.725, 13555d8)
T? of a number or of a pub extern struct is exported as the value’s out-parameter followed by bool *present.OK either way. null leaves the value’s out-parameter untouched._async form forwards both.None; Node returns null, in the Promise form too.find, nearest and Counter.last in C, Python and Node; waited_async for the async forms.Decision 2, D31 and D32
3e280f3). A pub enum with payload is a handle:
<prefix>_<Type>_kind writes <prefix>_<Type>_Kind (CALC_SHAPE_RECT);<prefix>_<Type>_<Variant>_<field> reads a field, a positional one numbered from 0;E_ARGUMENT, leaves its out-parameter untouched, and last_error says the Shape holds Circle, not Rect;IntEnum and the getters as properties; Node gets a frozen object and getters.7cec127). pub extern enum:
Item::Enum gets is_extern, in ast.nika and re-lowered into package.rs;pub with NK1237, and a variant field that is no C value with NK1145;#[repr(C)];_Tag and the tag plus an anonymous union;self, and as a field;ctypes.Structure with the union, with each tag a class attribute; Node gets { kind: "Frame", w, h }.FIGURE_CALLER in tests/c_library.rs (a tag of 99 returns E_ARGUMENT, in a field too), the Python and Node lines, and an_extern_enum_holds_c_values_only.Decision 3, D33: error codes (0.0.728, dda8b62)
100 000.io::IoError maps to the target’s ENOENT, EACCES and EILSEQ, plus 1000 and 1001. The other std errors are 1100 to 1400. The header writes CALC_E_IO_NOT_FOUND and so on.FileNotFoundError or PermissionError, each also the package’s Error, with errno set. Node sets code: 'ENOENT' and errno.ENOENT from <errno.h>, for a single type and for several. A unit test pins the Linux and WASI numbers.examples/c-library throws nothing, so it needed no renumbering.Still open on this issue: the WebAssembly target and nikaia bind js (D22–D25, #111). What blocks it is on #276.
Generated by Claude Code