Goose

Dynamic externs: calling a shared library by name

Today an extern fn is a C function that the linker resolves, from the runtime, from a package's native part, or from an --include header. This document proposes a second kind: an extern fn that names the shared library (.so, .dll, .dylib) it is in. The program opens the library when it starts, the runtime looks the symbol up by name and stores its address in a pointer, and calls go through that pointer. A program can then use a library the machine already has, such as SDL2, sqlite3, or a vendor SDK that ships as a DLL, without any C source, import library or .build sidecar, and in JIT mode without a C toolchain. Written against the tree at 1e31b6d. Not implemented.

1. Decisions

2. The boundary today

An extern fn has one of three implementations (spec §7.10, implementation §6):

For a JIT run, the native part is built again as a shared library exporting one table, goose_symbols. The compiler loads that table (process.cpp, LoadSharedSymbols) and passes its symbols to TinyCC one by one (tcc_add_symbol, jit.h). Codegen emits a prototype for every used extern that the runtime does not declare, and calls the symbol directly (EmitExternCall, ExternProto, codegen_calls.h). NativePackagesUsed (native.h) maps the used externs to the packages whose native parts are linked.

To bind an installed C library this way, a package needs a native part whose only content is the link line: a sidecar with c_libs = ["SDL2"], and a C file, possibly empty, so that the compiler has something to build. The JIT run then needs a C toolchain to produce the goose_symbols library, even though the program has no C code of its own to compile. goose build needs the import library (SDL2.lib) or the unversioned libSDL2.so symlink, which on Linux comes from the -dev package. A library that ships as a DLL with no .lib cannot be used without writing a loader in C.

3. The declaration

extern "SDL2" {
    fn SDL_Init(flags: u32) -> i32;
    fn SDL_Quit();
    fn SDL_GetTicks64() -> u64;
    fn delay(ms: u32) = "SDL_Delay";
}

The grammar gains one top-level form:

externblock  := "extern" strlit "{" externmember* "}"
externmember := "fn" declname "(" params? ")" ("->" rettypes)? ("=" strlit)? ";"

Both at the top level (§7.10) and inside a block, a symbol is given as the string after =, where a body would otherwise go. A string after extern names a library when { follows it. When fn follows it, the string is the deprecated top-level syntax for a symbol, which is still accepted with a warning; block members cannot use that syntax. A member without a symbol string binds the symbol with the same name as its Goose leaf name. This keeps the Goose names in one column: fn delay( is what a reader or a grep looks for. A member takes a declname, so a namespaced fn gfx::init(...) is written as it would be outside the block. The qualifiers an extern cannot carry today (checkfree, simd, inline, recursive, a body) are errors inside the block, with the same messages; thread_fn and export are not valid members. A block can only appear at top level and cannot be nested. Trailing defaults work as in §7.10: every call passes every argument.

Two declarations may bind the same symbol with different Goose signatures (the sqrt/sqrtf overload pair of math.goose, or two views of one function); each gets its own pointer, so unlike the prototype form there is no C-level conflict to avoid.

Spelling the library. The string is a bare name or a file name:

string Windows macOS Linux
"SDL2" (no separator, no dot) SDL2.dll libSDL2.dylib libSDL2.so
"libfoo.so.3", "foo.dll" (a dot) as written as written as written
"bin/steam_api64" or "./x.so" (a separator) relative to the program's resources, see below

A bare spelling is passed to the platform's loader as a bare name, so it is found through the DLL search order on Windows, through DYLD_LIBRARY_PATH and the usual places on macOS, and through LD_LIBRARY_PATH, the rpath and ld.so.cache on Linux. Unless it is absolute, a spelling with a separator is joined to the program's resource directory (resource_dir(), docs/stdlib.md: the entry source's directory in a JIT run, the executable's directory in a built program). A program can therefore ship a DLL beside itself and name it the same way on every platform. The bare-name transformation applies to the last path component ("bin/steam_api64" is bin/steam_api64.dll and bin/libsteam_api64.so). A file name with a dot is taken literally and is therefore platform-specific; it belongs in the sidecar rather than the source, unless the program targets only one platform.

The sidecar. A dylibs list maps a name used in the source to the spelling this platform should open. Like every list in a sidecar, it can take the _windows, _linux and _macos suffixes (packages.h):

// myprog.build
dylibs_linux = ["SDL2=libSDL2-2.0.so.0"];     // the soname every distro installs
dylibs_windows = ["SDL2=bin/SDL2.dll"];       // shipped with the program

The left side is the string in the source, and the right side is a spelling as described in the table above. The program's sidecar maps names for every package the program uses, and a package's sidecar may map its own names as defaults. A name that no block in the program declares is an error, which guards against typos. Nothing in the sidecar is required: a program without one opens the derived spellings.

Both mechanisms are needed. A package that binds SDL2 wants to write "SDL2" once and have it work for every user, and the derived spellings match what a user who installed the library for development has. How the program is deployed (to machines with only the runtime package, or with the DLL beside the executable) is something only the program knows, and its sidecar is where the program already says what it links and where it builds. A list of candidate spellings tried in order was considered and is deferred (§12).

4. What happens at run time

The runtime gains the following in runtime_os.h, the half of the runtime that includes the platform's headers and is therefore part of the runtime object and the --standalone unit:

typedef struct gs_dllib  { const char *name, *win, *mac, *linux; } gs_dllib;
typedef struct gs_dlbind { int32_t lib; const char *sym; void **slot; } gs_dlbind;
GS_API void gs_dl_bind(const gs_dllib *libs, int64_t nlibs,
                       const gs_dlbind *binds, int64_t nbinds);

gs_dl_bind opens each library once (with dlopen(RTLD_NOW | RTLD_LOCAL), or with LoadLibraryW on the UTF-8 spelling, widened the same way gs_os_* widens paths), never closes it, and stores each symbol's address in its slot. A library entry carries the bare name and the sidecar's three spellings, with NULL where the default applies. The runtime derives the default for the platform it is compiled on, so the generated C stays as portable across platforms as the rest of it. A failure is a runtime error through gs_panic, with the text the loader gave:

cannot load SDL2 (libSDL2.so): libSDL2.so: cannot open shared object file: No such file or directory
SDL2 (libSDL2.so) has no symbol SDL_Foo

The program's gs_program_init calls gs_dl_bind right after gs_rt_init and before gs_init_globals (codegen_emit.h). A global's initializer may therefore call a dynamic extern, and so may fn main() of a --header library, since goose_init runs the same initializer. Binding happens on the main thread before any worker exists. The slots are written once and only read after that, so thread_fns read them without synchronization, as they read the globals' initialized state. A library that may only be called from the main thread is marked the same way as today: with main_thread_only = true in the sidecar of the package that declares it. The checker already applies that setting to every extern fn in the package (typecheck.h, the thread_fn walk).

The program never closes a library. Calling dlclose on a library while a worker may still be running one of its functions is the kind of problem the language exists to avoid, and the process ends soon enough.

Toolchain consequences. dlopen is in libc on glibc 2.34 and later, and in libdl before that. On Linux, -ldl is added to SystemLibs() (build.h); on new systems libdl is a harmless stub. On macOS dlopen is in libSystem, and on Windows LoadLibraryW is in kernel32; both are linked already (the runtime calls GetProcAddress today). A JIT run compiles the runtime with TinyCC, which links libc but not libdl. RunJit therefore adds tcc_add_library(s, "dl") on Linux and ignores its failure, since a system without the archive has the function in its libc. TinyCC does the same for its own bounds checker (tccelf.c). The runtime's source text changes, so GS_RUNTIME_VERSION changes and the runtime object is rebuilt, as for any runtime change.

5. The generated C

For the block of §3, with every member called:

/* ---- extern prototypes ---- */
static int32_t (*gs_dl0_SDL_Init)(uint32_t);
static void (*gs_dl1_SDL_Quit)(void);
static uint64_t (*gs_dl2_SDL_GetTicks64)(void);
static void (*gs_dl3_SDL_Delay)(uint32_t);
static const gs_dllib gs_dllibs[] = {
    { "SDL2", 0, 0, "libSDL2-2.0.so.0" },
};
static const gs_dlbind gs_dlbinds[] = {
    { 0, "SDL_Init", (void **)&gs_dl0_SDL_Init },
    { 0, "SDL_Quit", (void **)&gs_dl1_SDL_Quit },
    { 0, "SDL_GetTicks64", (void **)&gs_dl2_SDL_GetTicks64 },
    { 0, "SDL_Delay", (void **)&gs_dl3_SDL_Delay },
};

and in gs_program_init, gs_dl_bind(gs_dllibs, 1, gs_dlbinds, 4);. A call site is gs_dl0_SDL_Init(flags) where the prototype form writes SDL_Init(flags). The argument snapshots and the builder write-back around the call (EmitExternCall) are unchanged. The pointer's name is made from the spec's position in usedexterns and the symbol, which makes it deterministic and distinct per declaration. The gs_ prefix keeps it from clashing with anything an --include header declares, so a header may #include "SDL.h" for its own purposes without the prototype conflict the direct form would have. Storing dlsym's void * into a function pointer through void ** is what every POSIX program does and what the platform ABIs define.

The libraries are listed in the order the program's blocks declare them (file order, then source order), and the binds in usedexterns order, so the output is stable across runs. The table and the slots are omitted when no dynamic extern is used, and the /* ---- extern prototypes ---- */ section keeps its prototypes for the static externs beside them.

On the targets the compiler supports (x86-64, AArch64), the calling convention is the platform's single C convention. 32-bit Windows __stdcall is not a target. A variadic function cannot be bound: the declaration has no way to say that a function is variadic, and calling one through a fixed-arity pointer is undefined behavior (on Apple AArch64 it actually goes wrong). The compiler cannot detect this. §6 lists it as a rule for the programmer, and such a function can be called through a wrapper in a native part.

6. Calling a real library without an adapter

The shapes of §7.10 were chosen with an adapter in mind: a header written against the generated typedefs converts between what Goose passes and what the library wants. With a dynamic extern there is no adapter, so the declaration has to express the library's real prototype in Goose's shapes. Most of a library's API fits these shapes; the parts that do not are listed last.

These calls show exactly what crosses the boundary, but are awkward to write. A stdlib ffi module could provide the two helpers below.

7. The compiler's pieces

8. Alternatives considered

9. Testing

10. Documentation

11. The plan

One working commit each, tests green at every step:

  1. Runtime: gs_dllib, gs_dlbind, gs_dl_bind, the error texts, -ldl in SystemLibs(), tcc_add_library("dl") in RunJit. Nothing uses these yet; the runtime version changes.
  2. Parser and AST: the block, dylib on SFunction, the errors, the tree-sitter and Goose-in-Goose grammars, ExternNamesIn skipping blocks. Codegen rejects a dynamic spec with "not supported yet", so the tree builds and the error tests can be added in this step.
  3. Codegen and driver: the slots, tables and bind call, NativePackagesUsed skipping dynamic specs, test/extern_dylib.py with the derived spellings only.
  4. Sidecar: dylibs and its suffixes, program over package, the typo guard, the override tests.
  5. Documentation: §10, with the §6 conventions.
  6. stdlib/ffi.goose (cstr, bytes) as a separate decision once the feature has a user that needs them.

12. Deferred and open