Foreign Functions#
Status: designed, not built (2026-10-24). — CONCEPT ("Foreign Functions (Draft)") sketches foreign: a block of
C functions, Pointer, CString, a library per platform in project.trb. Of that, the shape of a foreign block is
checked today (checkForeignShape, compiler/src/semantics/checker/declaration.trb) and the capability "foreign
functions" is derived from source (compiler/src/package/archive.trb); nothing links, loads or calls anything, and
Pointer and CString are not declared in std/. This record fixes the types, the checker rules, how each back end
reaches C, how the sandbox, the registry and the destructors treat it, and cuts the work into slices (section 10). It is
step 1 of #84. The one thing it does not decide is RELEASE.md
section 2 ("whether a registry package may declare foreign functions, and whether it may carry C"): that is a
published contract, so section 5.3 sets out the options and stays open, owner decision (#84).
COMPUTE.md (std/gpu) and FIRST-PARTY.md row 7 (torb/sqlite) are what the design
serves; slices 1 to 4 of section 10 do not depend on that answer.
foreign is where TorbScript stops guaranteeing anything, so the design keeps the place small and visible. A C
function can write through any address, keep a pointer it was given and call back from any thread. Nothing the type
checker knows survives the call. So the rules below do not try to make C safe; they keep what C returns from becoming
a value (it stays behind a shared type that closes it), keep what C holds from crossing a task, and make the
capability show wherever a package is looked at.
1. What is decided#
- A
foreigndeclaration is a signature and nothing else, and every type in it is spelled. No inference, no defaults, no overloads (the checker already demands the result type). The C ABI of the platform is the contract. - Both back ends load the library at run time, through one runtime function, on the first call — the compiled binary as well as the VM. CONCEPT says "the compiler links directly"; this record changes that to one behaviour for both (section 4.2). A missing library or symbol is one panic that names the library, the files tried and the symbol.
- The VM reaches C through one more kernel operation,
ForeignCall, a row of the kernel's table inruntime/machine.cand a constant of the emitter. It is not a native: the manifest does not change, so no seed refresh (VM.md section 6, "One native, many operations"). Pointer<Value>andCStringare not values in the language's sense. A type that contains one is confined to the task that made it, by the mechanism that already confines ashared type(CONCURRENCY section 1), may not appear in apublicsignature, and is notEqual,HashorEncode.- Foreign memory is owned by a
shared typewithClose, andclose()is the only place that frees it (DESTRUCTORS sections 2 and 6). - Foreign functions are never granted to a script: not by a
Sandbox, not bymodules, not by a per-system grant (the-sandbox rule 8, SCRIPTS capability table, OS.md section 8). foreignin a published package is not decided here. Whether a registry package may declareforeignfunctions, and whether it may carry C, is the owner's decision (#84); section 5.3 has the options and a recommendation. Whatever is chosen, the capability stays visible in source, intorb addand in the lock, and is never grantable to a sandboxed script.foreignexists on the targets that have a C ABI. A program that reaches one for the JavaScript or PHP target is a compile error on that target, the way a native that does not exist there is (OS.md section 2,availableOn); whatforeignmight become there stays with #63 (JAVASCRIPT-AND-PHP.md).- No
unsafekeyword and no new reserved word.foreignis the marker, asnativeis forstd/.
2. What exists#
| Part | State |
|---|---|
foreign "lib" { fn ... } | parsed; checkForeignShape demands no body, a declared result and functions only |
| the capability "foreign functions" | derived by capabilityOf for a foreign block (archive.trb), tabulated in docs/tooling/package-capabilities.md, RELEASE section 7.8 |
Pointer, CString, foreign type, foreign { library ... } in project.trb | nothing |
lowering of a call to a foreign function | nothing; no back end emits one |
| loading a library | LoadLibraryW and GetProcAddress only, as helpers for a few system DLLs (runtime/platform.c, runtime/os/windows.c, runtime/os/iocp.c); no POSIX dlopen |
| the VM's way to C | the kernel natives of Machine and a table of thunks generated from the manifest's .Runtime rows (VM.md section 6); a thunk for each known native, so a signature that appears only in a user's program has none |
The last row is the reason for section 6.2: the thunk table is generated when torb is built, but a foreign
signature is written by whoever writes the script.
3. Types and the ABI#
3.1 What may cross#
| TorbScript | C | IR type (VM.md section 4) | Note |
|---|---|---|---|
Int8 to Int64, UInt8 to UInt64 | int8_t to uint64_t | Integer (1 word, sign- or zero-extended) | Int and UInt are not foreign types: the width has to be spelled |
Float32, Float64 | float, double | Floating (1 word; a Float32 is widened in the register and narrowed at the call) | |
Bool | — | — | not foreign; a flag is a UInt8 or Int32 and the binding compares it |
Pointer<Value> | Value * | Integer of pointer width (1 word); the checker keeps it a distinct type | Pointer<Void> for void *; Pointer<Pointer<Void>> for void ** |
CString | const char * | Integer of pointer width (1 word) | section 3.3 |
foreign type struct | struct of C layout | Record, Inline (flattened fields) | behind a Pointer first, by value in slice 6; section 3.4 |
Void result | void | VoidType |
Anything else (a String, a List, a closure with captures, a Result) is an error at the declaration: the message
names the type and the table.
3.2 Pointer<Value>#
A Pointer is one machine word. It has no arithmetic and no dereference in ordinary code: reading and writing through
one are functions of std/foreign (Pointer.read, Pointer.write, Pointer.offset(by:)), each an operation of the
kernel with the trust of Machine.load (VM.md section 6: "a wrong register offset is a wrong write"), which is why
std/foreign is a package a sandbox cannot import (the std/machine rule of VM.md section 6). Pointer.none is the null pointer; there is no
Pointer?, because C's null is a pointer. A function that can answer null documents it, and the binding turns that
into Result or Option at the edge.
3.3 CString#
CString(text) copies a String into a counted block with a terminating NUL and answers Result<CString, CStringError>: a text with a NUL inside is refused, because C would read it as shorter. A CString argument is
borrowed for the call. A CString a C function answers is a borrowed view of C's memory: toString() copies it
(and fails on invalid UTF-8) and nothing frees it, so the binding frees it with the library's own function where the
library says to.
3.4 foreign type: the C layout#
A foreign type is a record whose layout is the C compiler's for the target, not the IR's:
- Field order is declaration order, as in C. The IR never reorders the fields of a
foreign type. - Each field is aligned to its natural alignment (
Int81 toInt64/Float64/Pointer8 on the 64-bit tier 1 targets) and the whole type to its largest field; padding is inserted as C inserts it and is never written to, so aforeign typeis notEqualorHash(F3). - Fields are the types of section 3.1 and nested
foreign types. NoString, noList, no generics: aforeign typehas no type parameters and cannot be one. A fixed-size array field isArray<Item, Size>of a section 3.1 item, laid out inline (VM.md section 4:FixedArray). - No packed or bit-field layout in this design. A library that needs one is bound through accessor functions of
its own or through
Pointer.readat byte offsets (section 3.2). - The size and alignment are computed once, by the checker, per target from rules 1 to 3 and stored with the
type; the C back end additionally emits a
_Static_assertof both againstsizeof/_Alignofof the C type it declares, so a mismatch is a build failure on the generated C and not a silent wrong offset.
3.5 A Buffer<Item> handed to C#
Buffer<Item> (COLLECTIONS.md, gap 6) is contiguous heap storage. A foreign function takes one only as
Pointer<Item> obtained by buffer.pinned { pointer, length => ... }, a function of std/foreign:
- The pointer is valid for the duration of the closure and for nothing else. The closure is the pin: while it
runs the buffer is not moved, resized, freed or written by TorbScript code; the pointer is
Pointer<Item>, which F2 confines to the task, so it cannot escape into a field or a channel. - A pin of a
varwindow (Window<Item>, COMPUTE.md) is a writable pin; a pin of a value is read-only, and C writing through it is the binding's contract violation, as everywhere in this section. - One call in flight. The pin lasts one closure, and a pointer a C library keeps beyond it (a command buffer that reads it later) is the binding's business (section 7); for that the binding copies into C's own block.
Itemis a section 3.1 type (a sized number or aforeign type); aBuffer<String>has no C layout and is refused with the message of section 3.1.
3.6 Errors: errno, GetLastError and null#
C reports failure out of band. The binding turns it into Result at the edge, with two functions of std/foreign
that read the value of the calling thread and are valid only directly after the call:
Foreign.lastError(): Int32iserrnoon POSIX andGetLastError()on Windows. A foreign call does not clear it, so the binding reads it only when the function's own result says it failed (-1, a null pointer).- A null pointer result is
Pointer.none;pointer.orNone()answersOption<Pointer<Value>>, and the binding writesmatchintoResult<Handle, LibraryError>withlastError()as the code. - The runtime saves the error value right after the foreign call returns and before it releases anything, so the
releases of the call (a
CStringblock) cannot overwrite it beforelastError()is read.
4. Declarations and how a library is found#
4.1 The block and the manifest#
foreign "sqlite3" {
fn sqlite3_open(path: CString, database: Pointer<Pointer<Void>>): Int32
fn sqlite3_close(database: Pointer<Void>): Int32
}"sqlite3" is a logical name. project.trb of the package maps it per platform (CONCEPT; PROJECT.md section 1 keeps
"native and foreign" out of manifest switches, so the mapping is the one thing the manifest says and the block in
source is what makes it required):
foreign {
library "sqlite3", windows: "sqlite3.dll", linux: "libsqlite3.so.0", macos: "libsqlite3.dylib"
}A logical name with no row for the platform being built is a compile error for that target, not a load failure later; a row with no block that uses it is a warning. The file name is what the system loader gets, so the search path is the system's. An absolute path is not accepted: it would make the manifest machine-specific.
4.2 Loading — one behaviour in both back ends#
Options for the compiled binary: (a) link at build time (-lsqlite3, an import library on Windows); (b) load at
run time like the VM must. Decided (b):
(a)needs a sysroot with every foreign library's import library or shared object on the build machine, which the managedzig ccof RELEASE section 4 does not have for a library the project does not ship, and it breakstorb build --targetfor another machine;(a)fails beforemainwith a loader message,(b)fails at the call with a panic that names what was missing;- the VM cannot link, so with
(a)the two back ends would differ in when a missing library is found, which CONCEPT says must not be observable; - the runtime already loads with
LoadLibraryW;dlopenis the same function on the other systems.
The runtime gets torb_library_open(file) and torb_library_symbol(library, name) (internal, not natives; POSIX in
runtime/os/posix.c, Windows in runtime/os/windows.c, OS.md section 7). A library is opened once per process and
never unloaded; a symbol is resolved once and cached in a slot (a static in generated C, an entry of the program's
foreign table in the VM). A back end may later link directly where it can prove the library is there, which is its
private business.
Because loading is lazy and lowering only reaches what the entry function reaches (OS.md section 2), a foreign
declaration reached only through an arm of match OperatingSystem.current the target does not take is not emitted, and
its library is not opened.
5. The checker, the sandbox and the registry#
5.1 Rules#
| Rule | |
|---|---|
| F1 | the types of a foreign signature are those of section 3.1 |
| F2 | a type that contains a Pointer or CString (a foreign type included) is confined to its task: it cannot be a capture of spawn, an item of a Channel or a result of a fork-join region. containsPointer is computed beside containsShared in the layout |
| F3 | such a type is not Equal, Hash, Order or Encode, and cannot be a public signature type or a public field (checkExposure extends to it) |
| F4 | a foreign function can be called from any function; the call is not native's privilege. It cannot be passed as a value, because C's calling convention is not a TorbScript function's |
| F5 | a Close type that owns a Pointer is a shared type (DESTRUCTORS section 5); a Pointer field of any other type is allowed only if private |
| F6 | an argument of the closure kind must be a closure without captures and with a signature of section 3.1 (callbacks, slice 7) |
| F7 | foreign cannot appear in std/core, std/prelude or any module the VM's loop imports: those must work with no library loaded |
F2 is the one rule C cannot give a library: a handle is used by one task on one worker. A library that is not thread safe is therefore safe by default, and two workers may each hold their own handle. A library whose handles must stay on one OS thread (a GUI toolkit) is not served by this design (section 9).
5.2 The sandbox#
A script has no foreign functions, ever. Two checks, because the first is source analysis and the second is the machine:
Sandbox.loadrefuses a script, or a module it imports, that contains aforeignblock, with the diagnostic the refused grant ofstd/os/windows/registryalready uses (OS.md section 8);- the kernel's
ForeignCallrefuses to run while a sandbox is open and stops the script, the wayMachine.countUpanswersfalsefor every count while a sandbox is open (VM.md section 10). The first is the diagnostic, the second is why a bug in the first is not an escape.
5.2.1 --embed-vm binaries and torb test#
- An
--embed-vmbinary (VM.md section 11) interprets the whole program, so aforeigncall there is the VM'sForeignCall(section 6.2) and the library is loaded by the runtime inside the binary, at run time, from the file names the manifest mapping recorded at build time. It needs no compiler where it runs, and also no library bundled: the library must be on the machine that runs it, exactly as for a binarytorb buildcompiles. A script loaded by such a binary is a script and gets noforeign(5.2). torb testruns the program's tests under the toolchain's VM or as a compiled binary, as for any program, so the libraries must be installed where the tests run; a test that needs a library that may be absent uses the probe of section 11 item 3 and skips with a stated reason. The repository's own gate is the CI test library of slice 3, which CI builds, so it never depends on a system library.
5.3 Foreign functions in published packages — open, owner decision (#84)#
RELEASE section 2 names this as a 1.0 decision and FIRST-PARTY row 7 (torb/sqlite) cannot be built until it is made.
It is a published contract of the registry, so this record does not decide it. Open, owner decision (#84). The
options:
| # | Option | Consequence |
|---|---|---|
| 1 | none: published packages stay pure TorbScript | torb/sqlite is blocked for good; every binding to a C library stays outside the registry |
| 2 | a package may declare foreign functions against libraries already on the user's machine; no C sources in the package | unlocks torb/sqlite on Linux and macOS (an installed SQLite); the archive stays source only (RELEASE section 7.2); Windows has no system SQLite (section 11) |
| 3 | as (2), and first-party packages (torb/*) may also ship C sources that the toolchain compiles | needs the build-script design of PROJECT.md sections 8 and 9 and a decision on reproducibility; a later, separate decision |
| 4 | any package may ship C sources | unreviewed C in every consumer's build; the registry would store something that builds |
Recommendation: option 2. It keeps the registry a store of source that runs and builds nothing, and leaves option 3 open.
What holds under every option, and so is part of this record: foreign is visible in source; the package summary shows
foreign functions with the files that declare them (RELEASE section 7.8); torb add and the lock show it; the
registry never loads a library (torb doc --check --no-run runs none of a package's code, and the documentation worker
of RELEASE section 7.11 never calls a foreign function); the 9 MB native of RELEASE section 7.11 stays refused.
The answer is written by a follow-up PR into this section, into RELEASE.md section 2 and 7.2 and into
FIRST-PARTY.md rows 7 and 9. Until then RELEASE.md and FIRST-PARTY.md say "open, see FOREIGN.md 5.3". Slices 5 (the
library mapping, which a local project needs as well) and 8 (torb/sqlite) of section 10 are the ones that wait.
6. The two back ends#
6.0 A signature a back end cannot call#
BACKEND gap 22 (a missing intrinsic is a finding, never a link error or a wrong result) applies: a foreign
signature that a back end cannot call is a compile error that names the function, the type or arity that is not
supported and the back end, located at the declaration, and never a link error, a runtime crash or a wrong call.
The check runs in lowering, which knows the target: F1 is the language-wide rule, this is the per-back-end one (the
VM stubs of section 6.2 refuse a foreign type by value and an arity above the cap; the JavaScript and PHP targets
refuse every foreign function, section 1).
6.1 The C back end#
A call is a typed call through the slot: the generated C declares the function type from the signature, the slot is a
static void (*)(void) filled by torb_library_symbol and cast at the call. The ABI is the C compiler's own, so every
signature section 3.1 allows is right by construction, struct by value included. A CString is the borrowed pointer of
its block; a Pointer is a word.
6.2 The VM#
ForeignCall takes a library index, a symbol index and a signature index of the program's tables and the registers of
the operands, loads and caches the symbol, and calls it. The thunks of VM.md section 6 cannot do this: they are one per
known native. Two ways to call a function whose signature is only known at run time:
| For | Against | |
|---|---|---|
| libffi, vendored | every ABI and struct by value, maintained | a third-party C dependency in a runtime that is C11 and nothing else; a licence line in every download |
a call stub per ABI in runtime/ | no dependency; the set is closed (System V x86-64, Win64, AAPCS64) | assembly to write and test per ABI; scalar-only at first |
Decided: the stubs, scalars only first (integer-class and float-class arguments up to an arity cap, the result in one register), because the C back end covers the rest and slice 3 can ship without the VM. A spike in slice 4 is the check: if a stub per ABI costs more than a week or has to know struct classification, the decision is revisited with libffi on the table, and this record says so then.
The VM check and the CI gate of VM.md section 8 ("C and the VM agree") extend to it: a small C library is built in CI, its functions are called from the same test program under both back ends, and the outputs are compared.
7. Memory, destructors and callbacks#
- C's memory is freed by C's function, called from
close(). The shape is CONCEPT'sDatabasewithvar fn close() { sqlite3_close(handle) }.close()is synchronous, returnsVoid, never fails and runs on whatever release is the last (DESTRUCTORS sections 2 and 6), so a library whose close can fail is wrapped with anend()in front, the waySinkis. - A
Pointernever outlives its block unnoticed: aCStringmade for a call is released after the call, a pointer a C library keeps beyond the call (sqlite3_bind_textwith a static destructor) is the binding's business, and the record repeats it in the docs page because the checker cannot see it. - A fault inside C is not recovered. A signal in C (a segmentation fault,
SIGBUS, a Windows structured exception) ends the process with the platform's own exit status; the runtime installs no handler for it, and the recoverable panic of SCRIPTS.md and PANICS.md does not catch it, because a jump out of a faulting C frame promises nothing about the library's state. A foreign function that returns an error code is the only recoverable failure. The memory counts of the leak report do not include C's allocations (section 7, first bullet). - A panic inside a callback ends the program like a panic with no recovery point. It is not unwound through C frames, because the runtime's recovery is a jump and C code gives no promise about being jumped over.
- Callbacks (slice 7). C back end: a closure without captures is a C function of the right signature. VM: a
small fixed pool of native entry points per signature shape that call the interpreter through
Machine.install(VM.md section 10, "A call from the runtime into the interpreter"). A callback is only valid on the thread of the foreign call that received it; one from another thread aborts with a message that names the function.
8. What std/gpu and std/render need from this record#
Constraints on the design (COMPUTE.md layer 0 and 2), not a design of those packages. Each is covered or deferred:
| Needed | Where it is met |
|---|---|
| A struct by value (a descriptor, a matrix, a colour passed to a graphics call) | foreign type by value: slice 6; before it, behind a Pointer (section 3.4). The C back end is right by construction, the VM refuses it until libffi or a classifier exists (6.0, 6.2) |
Out-parameters (Create(&device)) | Pointer<Pointer<Void>> and Pointer<foreign type>; the binding allocates a block with a shared type, passes the pointer, reads it back with Pointer.read (section 3.2) |
Function-pointer tables (Vulkan's loader, ID3D12...Vtbl) | a table is a foreign type of Pointer<Void> fields; a function pointer called through it needs an indirect foreign call (Pointer<Void> plus a signature) that the design must add: a foreign function that takes its target as a value, slice 6 beside by-value structs. Open point 7 of section 11 |
| Pinned buffer windows (vertex and uniform data, tensors) | buffer.pinned (section 3.5), writable for a var window |
Loading at run time (vulkan-1.dll, libvulkan.so.1, Metal.framework, and an optional backend that may be absent) | section 4.2 and the probe of section 11 item 3: a package selects its backend with Foreign.available, and a missing library is a stated panic, not a loader failure at start |
| Callbacks (validation layers, window events) | slice 7 and section 7 |
| Thread affinity (a window or a graphics context on the main thread) | not met (section 9, "Not for thread-affine libraries"); std/render needs the answer of open point 8 before it can open a window |
9. What this is not#
- Not a way to run C compiled from a package. This record adds no
build.rsand no vendored sources; whether a published package may carry C is the open question of section 5.3. - Not a capability a sandbox can grant, and not a way around
std/os: the per-system natives of OS.md stay natives ofruntime/, and a user'sforeigncall togetpidis a user's choice that the package summary shows. - Not blocking-safe. A foreign call runs on the calling worker and stalls its other tasks for as long as it takes
(CONCURRENCY section 1: one event loop per worker). A
foreignmarkerblocking, run on a helper thread, is the likely answer and is open (section 11). - Not for thread-affine libraries, because of F2: a handle stays on its task's worker, but the worker is chosen at
spawnand nothing here pins it to the main thread. - Not
nativewith a body. NATIVE-BODIES.md is IR for the kernel, reserved forstd/;foreignis C for everybody.Pointer's operations are kernel operations written that way once it is built (before that, manifest natives, slice 1).
10. The slices#
| # | Slice | Seed impact |
|---|---|---|
| 1 | std/foreign: Pointer, CString, CStringError; F1 to F5 and F7 in the checker; docs page loses "nothing is declared" | new natives for the Pointer reads and writes: two commits and a seed refresh, unless NATIVE-BODIES slice 1 is in |
| 2 | torb_library_open and torb_library_symbol, POSIX dlopen beside LoadLibraryW | none (internal to runtime/) |
| 3 | lowering and the C back end: slot, call, foreign type behind a pointer; CI test library | none |
| 4 | VM: ForeignCall, the stubs (spike first), the sandbox refusal of 5.2, the CI comparison of both back ends | none (a row of the kernel table) |
| 5 | foreign { library ... } in project.trb, the per-platform check, torb add shows libraries, torb update asks | the manifest grammar |
| 6 | foreign type by value | none |
| 7 | callbacks | none |
| 8 | torb/sqlite (FIRST-PARTY.md row 7), the first real binding; COMPUTE.md's std/gpu after it | n/a |
Slices 1 to 3 give a working foreign on the compiled path; 4 gives torb run; 5 adds the manifest mapping (a package
of the registry additionally needs the answer of section 5.3). No slice needs
the compiler to compile itself, but 1 is cheaper after NATIVE-BODIES slice 1.
11. Open, for the owner#
A library the machine does not have. Windows has no system SQLite. Options: the download bundles
sqlite3.dll(RELEASE section 4), the package documents the install, or C in packages is decided after all (option 3 or 4 of section 5.3). Recommendation: the first for SQLite only, as part of the release, until section 5.3 is settled.A
blockingmarker on aforeignfunction that moves the call to a helper thread. Recommendation: yes, as a slice after 4, since SQLite and every network library need it.A probe for an optional library (
Foreign.available("sqlite3")). Recommendation: yes, a function ofstd/foreign, because a panic on the first call is the wrong answer for a feature that may be absent.libffi if the spike fails (section 6.2).
CONCEPT's "the compiler links directly" is replaced by section 4.2: a technical choice, decided here; the owner may overrule it on review.
Foreign functions in published packages (section 5.3): options 1 to 4, recommendation 2. Tracked in #84; a merge of this record is not the answer.
An indirect foreign call (a
Pointer<Void>target plus a signature), which function-pointer tables (section 8) need. Recommendation: yes, with slice 6; the C back end casts, the VM needs a stub per signature shape.A main-thread pin for libraries that must run on the process's first thread (windows, graphics contexts). Recommendation: a
foreignmarkermainthat runs the call on the threadmainruns on, as a slice after 4;std/renderwaits for it.
12. Where it is written down#
- Concept: CONCEPT.md, "Foreign Functions (Draft)".
- The language page: Foreign functions, rules 1 to 8.
- The question it leaves open: RELEASE.md section 2 and 7.8; the capability table is
SCRIPTS.md and
docs/tooling/package-capabilities.md. - Neighbours: NATIVE-BODIES.md, VM.md sections 6 and 10, OS.md sections 2 and 8, DESTRUCTORS.md, CONCURRENCY.md section 1, JAVASCRIPT-AND-PHP.md, PROJECT.md section 1.