Skip to content

Dynamic C libraries

A candela program calls into a shared library by declaring the functions it wants. The compiler opens the library, resolves each symbol, and builds the calling interface, so a call costs no more than the marshalling of its arguments.

Declaring a library

dylib "z" {
    string zlibVersion();
}

fn main() {
    print(z::zlibVersion());
}

A dylib block names the library, then lists one signature per line. Each signature is a return type, a name, and a parenthesised list of parameter types, ending in a semicolon. Parameter names are not part of the grammar; give the types only.

Omit the return type for a function that returns nothing:

dylib "mylib" {
    reset(int);
    int count();
}

The functions live in a namespace named after the library, so you call them as z::zlibVersion(). The block's name is a namespace, not a value, so z.zlibVersion() is a compile error (block_dot_call). A variable named z is a value: z.zlibVersion() calls its own method, and the library stays reachable through z::. Wrapping each function in an ordinary candela function is the usual way to give callers a tidier surface.

Finding the library

The name you write is turned into a filename with the platform's convention:

Platform dylib "foo" resolves to
Linux libfoo.so
macOS libfoo.dylib
Windows foo.dll

Two forms bypass part of that. A name that already has an extension is used exactly as written, on every platform, so "libfoo.so.6" and an absolute path both pass through. A name containing a path separator gets the platform extension appended but no lib prefix, so "../native/mylib" becomes ../native/mylib.so on Linux.

A plain name is looked for beside the importing file first, then in the working directory, then through the operating system's own search path. A path form goes straight to the loader, resolved relative to the importing file. This makes a bare name behave the same way everywhere, since the loaders differ on whether they consider the application directory at all.

A Rust host that embeds candela can name directories to look in ahead of all that, which is what an application whose sources and libraries sit apart (src/ beside lib/) uses. Both forms honor them: a plain name is looked for there first, and a relative path is resolved against them first. See where a dylib import looks.

The root of every package the project depends on is searched too, after the directories the host named and before the importing file's own. A package that ships a shared library at its root therefore binds with nothing to configure, and a dylib import inside that package finds its own library rather than one from the program that pulled it in. A library the package keeps under dist/ is named by path from src/: dylib "../dist/mylib".

For a path form with no extension, an architecture-suffixed build is preferred when one sits beside it, so "../native/mylib" picks up mylib-x86_64.so or mylib-aarch64.so before falling back to mylib.so. That is how you ship one directory holding builds for several architectures.

Types that cross

candela C
int int64_t
float double
string char *, null-terminated
T[] pointer to the packed elements
struct the C struct of the same field types, by value
omitted return void

Anything else has no C representation and is a compile error at the signature (no_c_representation): bool, enums, maps, union types, function types and any, and a struct holding any of those.

Strings you pass in are copied into a null-terminated buffer that lives for the duration of the call, so the C side must not keep the pointer. A string containing an interior null byte raises null_byte_in_string rather than being silently truncated.

A returned char * is copied into a candela string and never freed, so the C side keeps ownership; return a static or otherwise owned buffer. A null pointer comes back as null.

Arrays are passed as a pointer to the packed elements with no length alongside them, so pass the length as a separate int parameter. An empty list is a valid pointer C must not read through. An array cannot be returned, because C does not convey how long it is; that signature raises c_array_return_type_not_supported.

A list of ints or floats is C's to fill: what the function writes into the buffer is copied back into the list once it returns. That is how a C function hands back several numbers or a run of bytes. Make the list the size you need first, and the function writes up to that many elements:

dylib "mylib" {
    int read_block(int[], int);
}

fn main() {
    let buf = [0, 0, 0, 0, 0, 0, 0, 0];
    let n = mylib::read_block(buf, buf.len());
    print(buf[..n]);
}

Strings, nested lists and structs inside a list are copies C cannot write back.

Calls use the platform's default C calling convention. There is no way to select another.

When the library is needed

The library is opened and every symbol resolved while the program is compiled, so a missing library or a missing symbol is a compile error naming what it could not find, not a surprise at the first call.

Checking needs no library. candela check, Engine::check and the language server type-check each call against the signature the block declares and never open the library, so a program whose library a build step produces can be checked before that step runs. A signature type with no C representation is an error there too.

Building a .cdlb artifact records the library name, the symbol and the signature, never the library's bytes. Loading the artifact re-opens the library and re-resolves the symbol, so the library has to be present wherever the artifact runs. A standard-library module's own library is the exception: it is recorded relative to the libs directory rather than to the module, so the runtime finds it through the toolchain. See artifacts.

Dynamic libraries are not available when candela is compiled to WebAssembly.

A worked example

The standard library's time module is a dynamic library binding. The C side is one file with its exports marked visible:

#ifdef _WIN32
#define EXPORT __declspec(dllexport)
#else
#define EXPORT __attribute__((visibility("default")))
#endif

#include <stdint.h>
#include <time.h>

EXPORT int64_t now(void) { return (int64_t)time(NULL); }

EXPORT const char *format(int64_t timestamp, const char *fmt) {
  static char buffer[128];
  time_t t = (time_t)timestamp;
  struct tm *info = localtime(&t);
  strftime(buffer, 128, fmt, info);
  return buffer;
}

candela's int is int64_t and its float is double. The returned string uses a static buffer, since candela copies it out and the C side keeps ownership.

Build it as a shared library. The shipped ones are built like this:

# Linux
clang -O2 -fvisibility=hidden -shared -fPIC -o time.so time.c
# macOS
clang -O2 -fvisibility=hidden -dynamiclib -o time.dylib time.c
# Windows
clang -O2 -fuse-ld=lld -fvisibility=hidden -shared -o time.dll time.c

The candela side declares the two symbols and wraps them:

dylib "../std_src/time/time" {
    int now();
    string format(int, string);
}

fn now() { return time::now(); }
fn format(date, pattern) { return time::format(date, pattern); }

The path form is what makes time.so resolve without a lib prefix, and the namespace comes from the last part of the path. Anyone importing the module calls time::now() and never sees the binding.

Posting events from a library

A library whose work finishes on a thread of its own, such as a download or a child process, hands the result to the program as an event. The program registers a function for a source with event::on, passes the source to the library, and the library posts to it. The function runs on the program's thread, after main has returned if need be.

A library that wants to post exports candela_dylib_init. The runtime calls it once each time it opens the library, before the program can call in, with a table the library keeps for as long as the process lives:

#include <stdint.h>

typedef struct candela_runtime {
    uint32_t abi;                                   /* 1 */
    int64_t (*new_source)(void);
    void (*post)(int64_t source, const char *payload);
    void (*hold)(int64_t source);
    void (*release)(int64_t source);
    const char *(*base_dir)(void);
} candela_runtime;

static const candela_runtime *runtime;

EXPORT void candela_dylib_init(const candela_runtime *table) {
    if (table->abi == 1) {
        runtime = table;
    }
}

Check abi before using the table: a later runtime that changes the table says so there. Every function in it may be called from any thread.

A library that exports candela_dylib_init stays loaded until the process exits, even after every program that opened it is gone, because its threads may still be running its code.

Field What it does
post Queues payload, UTF-8 text, for the handler of source. The text is copied
hold Says work under source is still running. The program stays running after main until every hold has ended
release Ends one hold, behind every event already posted under source. Once the last hold ends and its events are handled, the handler is let go
new_source A source of the library's own, for events nothing in the program asked for
base_dir The directory relative paths resolve against, or an empty string when the host set none and they resolve against the working directory

A start function holds the source it was given, starts its thread, and returns; the thread posts as it goes and releases the source after its last event:

EXPORT void start(int64_t source) {
    runtime->hold(source);
    spawn_worker(source); /* posts with runtime->post, then runtime->release */
}

A library loaded by something other than a candela runtime is never handed a table, so a library that may be loaded elsewhere checks for one before posting. The first-party packages are written this way; see packages.

Host functions instead

A host block looks the same but binds to a Rust closure in an embedding program rather than a C symbol, and accepts types that C cannot carry. See embedding.