Extensions
Giving the machine a capability it does not have and could not reasonably grow — a window, a socket, a codec — from a C file compiled on its own and named when a program is started. The header is solum/extend.h, the working example is tests/ext_probe.c, and tests/test_extension.c holds every promise on this page.
This page stands to extensions as embedding.md does to hosts, and the two are different jobs. A host contains a machine and runs programs through it. An extension is loaded into a machine somebody else started, and never sees argv, the chunk, or the run.
The case for the mechanism, and what was measured before any of it was built, is in ideas.md.
The whole of it
#include "solum/extend.h"
static SolValue prim_shout(SolVM *vm, SolValue self, SolValue *args, int argc)
{
(void)self;
if (argc != 1 || args[0].type != SOL_STRING) {
sol_vm_runtime_error(vm, "shout expects one string");
return SOL_NIL_VAL;
}
return sol_vm_send(vm, args[0], "asUppercase", NULL, 0);
}
int sol_extension_init(SolVM *vm, int abi)
{
if (abi != SOL_EXTENSION_ABI) return -1;
SolObject *probe = sol_object_new(vm, vm->object_class);
sol_object_define_primitive(vm, probe, "shout", prim_shout);
sol_vm_set_global(vm, "probe", SOL_OBJ_VAL(probe));
return 0;
}
cc -std=c11 -fPIC -shared -Isolum/include probe.c -o probe.so \
-Wl,-undefined,dynamic_lookup # macOS only; ELF needs nothing
bin/solvm --extension=probe.so program.sob
and in the program, nothing about loading at all:
probe:shout("quiet"):print. ; -- "QUIET"
probe is a global, sitting beside system and array. It is there because
the machine was started with the bundle, and a program run without it fails at
the first line that names it — undefined name 'probe' — rather than at load.
solvm, solis and solid all take the flag, repeatably, loading in the
order written. Every front end that runs a program takes it, including the
debugger — a program that needs an extension is exactly the kind worth stepping
through, and stepping stops inside Solum and never inside an extension, which is
C and has no lines to stop on.
solas does not, and never will. A compiler that loaded native code in
order to compile a file would put that requirement into the .sob, where every
machine that ever ran it would inherit it — including one that only wanted to
disassemble it.
Who decides
Whoever starts the program, and there is deliberately no message that loads an extension.
Native code runs past --steps, past --memory, past everything: those bound
what the machine does, and an extension is not the machine. A capability a
script can invoke is not a capability a host can withhold, and this is the
largest possible thing to be unable to withhold — which is
6.32
seen from its worst angle. Limits are settable only from C for exactly this
reason; loading is the same shape.
At a terminal this costs you nothing, because you can type the flag. It matters when the person starting the program is not the person who wrote the script.
There is also no @link directive, and that one fails worse: it would put
dlopen inside Solas, so the compiler would load native code in order to
compile a file, and the .sob would carry the requirement into every machine
that ever ran it — including one that only wanted to disassemble it.
Two doors
sol_extension_load(vm, path, &error) |
dlopen, then dlsym, then register. What --extension= calls. |
sol_extension_register(vm, init, name, &error) |
For an extension linked into the binary. No dynamic linker involved. |
Both end in the same call to sol_extension_init, and extend.h mentions
dlopen nowhere. The test suite uses the second, because building a shared
object part-way through a test run needs a compiler at test time — fragile
under three CI configurations and impossible under a sanitiser.
What an extension may rely on
sol_object_new(vm, proto) an object; proto is usually vm->object_class
sol_object_define(vm, obj, name, value) bind a name to a value
sol_object_define_primitive(vm, obj, name, fn)
sol_object_define_primitive_for(vm, obj, name, fn, type)
sol_vm_set_global(vm, name, value) hang the extension's global
sol_vm_global(vm, name, &out) read one back
sol_string_new(vm, chars, length) copies
sol_symbol_intern(vm, chars, length) a symbol, made only if new
sol_array_new(vm, capacity)
sol_array_add(vm, array, value)
sol_extension_retain(vm, value) keep it between calls
sol_extension_retained(vm, token, &out) get it back, or learn you cannot
sol_extension_release(vm, token) stop keeping it
sol_vm_call_block(vm, block, args, argc) then rule 4
sol_vm_send(vm, receiver, name, args, argc) then rule 4
sol_vm_runtime_error(vm, format, ...) rule 2
sol_gc_push_temp(vm, cell) / sol_gc_pop_temp(vm) rule 3
vm->object_class and the other built-in classes
vm->had_error
A primitive an extension installs is indistinguishable from a built-in. Same
slot, same dispatch, same speed. respondsTo finds it and slots lists it.
That is a promise rather than an accident: it is what would let a capability
leave the core one day without becoming second class.
Handing a resource back
A socket, a window, a connection, a compiled pattern — anything the program may hold and the machine cannot make sense of:
static void close_socket(void *handle) { close((int)(intptr_t)handle); }
/* net:udp(#port) */
return SOL_FOREIGN_VAL(sol_foreign_new(vm, (void *)(intptr_t)fd,
close_socket, "socket", 0));
and the primitive that receives one back asks for it by kind, never by casting:
void *handle = sol_foreign_handle(args[0], "socket");
if (handle == NULL) {
sol_vm_runtime_error(vm, "send expects an open socket");
return SOL_NIL_VAL;
}
The program sees an ordinary value. It renders as <socket>, compares by
identity, answers isKindOf(foreign), and cannot be made with new — a
resource comes from an extension or it does not exist.
release runs exactly once, and from two directions. The collector calls it
when the program lets go of the value; sol_vm_free calls it for everything
still alive at shutdown, whatever its reachability. So a socket is closed when
the program drops it and when a limit takes the program away mid-flight —
which is the case an explicit close could never cover, since a limit-stop is
uncatchable and does not run ensure
(6.33).
That is the argument for there being no close message at all.
kind must outlive the VM — a string literal does — and is compared with
strcmp, so one extension’s "socket" cannot be handed to another’s primitive
that wanted its own.
The currency the collector counts in
footprint is what the resource costs where the machine cannot see it — a
texture, a decoded image, a connection’s buffers. It is added to the live-byte
figure --memory is measured against, so a limit measures the texture rather
than the pointer to it. Zero when there is nothing honest to say, which is
usual.
And bytes are the wrong currency for a scarce resource, which was found by opening real sockets. A foreign cell is forty bytes however scarce the thing it holds, so a program opening descriptors in a loop exhausted the process while the heap was still nearly empty — measured at a 256-descriptor ceiling, where it died with no collection having happened at all.
So foreign cells carry a pressure count of their own:
SOL_GC_FOREIGN_PRESSUREof them forces a collection whatever the byte figure says. An extension does not have to do anything about this, and in particular should not inflatefootprintto buy scheduling — a wrong number there makes--memorylie. The same program now opens 5,000 sockets under a ceiling of 256.
The four rules
Each is something a newcomer gets wrong, and each was found by getting it wrong.
1. Arity is not checked for you. sol_object_define_primitive_for checks the
receiver and nothing else. A block checks its own arity because it has one to
check; a C function does not.
2. Failure is out of band. A primitive answers a SolValue and has no way to
say “this went wrong” in it. Call sol_vm_runtime_error and return
SOL_NIL_VAL.
3. Nothing may hold a heap pointer across an allocation unless it is reachable from a root. The collector marks the value stack, the frames, the temporary-root stack and the class objects. A cell held only in a C local, or in a struct C owns, is none of those.
The case that bites is a callback. A block registered as a graphics toolkit’s
user_datais reachable from nothing the tracer walks, so a collection between one call and the next sweeps it — and the next call runs whatever now occupies that cell. Measured: the failure was'block' takes 1 argument, got 0, an arity complaint about a block the program never registered. Not a crash, and nothing pointing at the collector.
sol_gc_push_tempcovers a short window inside one primitive and is eight deep; overflowing it callsexit(1)with no diagnostic. Anything held between calls goes in the registry below.Note that a foreign cell is not this problem: it is a value the collector knows about, and holding one in a slot roots it like anything else. The problem is a Solum block held by C.
4. Check vm->had_error after every call back into the language. After
sol_vm_call_block or sol_vm_send, before doing anything else. A limit-stop
sets it and is deliberately not catchable, so a loop that does not look will
keep calling into a machine that has already been stopped — the one way an
extension can defeat --steps.
Keeping a value alive between calls
Rule 3 covers a window inside one primitive. A toolkit holding your callback
holds it between calls, where nothing the tracer walks can see it — so retain
it, keep the token in user_data, and look it up when the callback fires:
typedef struct { SolVM *vm; SolRetained on_click; } Button;
button->on_click = sol_extension_retain(vm, args[0]);
static void clicked(GtkWidget *w, gpointer data)
{
Button *button = data;
SolValue block;
if (!sol_extension_retained(button->vm, button->on_click, &block)) {
return; /* released, or never valid */
}
sol_vm_call_block(button->vm, block, NULL, 0);
if (button->vm->had_error) { ... } /* rule 4 */
}
sol_extension_release(button->vm, button->on_click); /* widget gone */
Keeping the token rather than the SolValue is the point. The collector
does not move cells, so a retained value would stay valid — but a token that has
been released answers false, where a stale value answers a plausible wrong
block. The registry can tell you that you are wrong; a cached value cannot, and
being unable to is the entire defect this exists to end.
A token carries the slot’s generation as well as its index, so a token outliving its slot is detected rather than resolving to whatever was retained into that slot next — which would be the same silent misdispatch moved one layer up.
Not reference counted. Two retains of one value give two tokens, each released on its own. Retaining twice and releasing once leaves it rooted, which is the safe direction to be wrong in. Everything still retained is released when the VM goes down, so an extension that never releases leaks nothing beyond the machine’s life.
The handshake
SOL_EXTENSION_ABI is compared for equality. A bundle built against a different
number is refused by name, with what to do about it:
solvm: cannot load extension probe.so: refused ABI 1 -- built against a
different SolVM, rebuild it against this one
The policy is .sob’s
exactly — refuse, do not guess, rebuild — and for the same reason. SolValue is
passed by value and SolObject’s layout is exposed, so nearly any struct change
in the VM moves the number. You never rebuild solvm to add an extension; you
do rebuild your extensions when solvm changes.
It is deliberately not SOLUM_VERSION: a release that changes no struct should
not invalidate every bundle, and one that changes a struct without changing the
version must still be caught.
A refusal is not a half-load. The extension has bound nothing and the machine is
exactly as it was, so solvm reports it and exits 65 — the status a .sob that
cannot be read gets, because it is the same kind of thing.
Finding out what one gives you
A bundle’s surface is not written down anywhere and cannot be: there is no
manifest, only an sol_extension_init that binds whatever it binds. So the way
to read it is to load it and look, which is what solid --exports does.
$ solid --exports --extension=build/extensions/net.so
build/extensions/net.so
net
udp a primitive
port a primitive
send a primitive
receive a primitive
waitFor a primitive
No file need be named beside it, since a .so is the whole subject. A primitive
has no arity to report – it checks argc itself, and nothing records what it
will accept – so the names and the global they hang on are what there is. See
the reference, which covers a .sob the
same way.
It runs sol_extension_init to find out, with everything that implies: this
is loading the bundle, not inspecting it from outside, and an init that opens
a socket has opened one. That is the same decision --extension= always is.
The one that ships here
extensions/net is the third real one and the
first inside this repository — UDP sockets, five messages, built by make and
loaded by nobody unless a host names --extension=. What it publishes is
documented like any other surface, in NET.md. It may live here where the
two below may not, and the difference is the front page’s sentence rather than a
rule about extensions: a bundle needing a toolkit installed would make no
dependencies beyond a C11 compiler and make false, and sockets need POSIX,
which every dlopen and fork here already assumes.
It was the first customer for something this page did not promise, and the
list grew. The language’s convention for an answer with fields is a dictionary
— system:terminalSize gives "rows" and "columns" — and the surface carried
sol_object_new and sol_array_new and nothing that built one. So a received
datagram is an object with host, port and text, which reads well and was
not the first choice. The entry ended either the list grows a dictionary or
extensions answer objects; what should not happen is each bundle deciding
quietly — and when the surface was declared rather than inferred, that open
question was the first thing the review had to settle.
sol_dict_new, sol_dict_put and sol_dict_get are promised now, with
sol_type_name, sol_value_equals and sol_vm_class_of beside them: rule 1
asks a primitive to check its own arguments and say what it got, which needs the
name of a type. net is not rewritten, because an object with three named
slots is a perfectly good answer and changing a shipped surface to use a newer
call is churn. The next bundle has the choice this one did not.
And it found a trap in sol_foreign_handle worth repeating. It answers NULL
for a cell of the wrong kind or one already released, so a handle that is itself
NULL is indistinguishable from a released one — and file descriptor 0 is a real
descriptor. net stores fd + 1 for that reason. Anything wrapping a
zero-based handle should do the same.
Two real ones
solveig-gtk and solveig-sdl are written against this page rather than alongside it, each in its own repository and built by nothing here.
It is worth knowing what it settled, because two things about a real toolkit were genuinely uncertain and neither was answerable from a checksum:
| A foreign main loop calling back in | Free. sol_vm_call_block re-enters from a GTK signal handler exactly as it does from array:do, and an error inside one formats a trace naming the gtk:run line beneath it. |
| Widget lifetimes against a collector | g_object_ref_sink turns GTK’s floating reference into one the extension owns and the foreign cell releases; a parent taking a child adds its own. The two lifetimes do not fight. |
| Whether a limit still bounds a program with a window | It does, and only because rule 4 is kept: --steps=400 stops the counter mid-loop and exits 124. |
And it is the reason this repository still builds with no dependencies beyond a
C11 compiler and make.
The second one is the check on the first. SDL2 needed no change to the
mechanism — same header, same ABI, same loader, same foreign cell — and it is
deliberately not shaped like the GTK one, because SDL hands a program a frame
and gets out of the way where GTK owns the loop. So sdl has no run and no
callback at all, and the program writes an ordinary whileTrue.
That difference is the evidence for two decisions that were taken on argument:
| decision | what the second back end showed |
|---|---|
| The retain registry is a service, not the shape of an extension | solveig-sdl uses none of it. Had callbacks been the shape, it would be fighting the interface. |
| No back end names itself the general case | gtk: and sdl: share no vocabulary, and neither had to pretend to be the other. |
It also found the one thing missing from the list above — sol_symbol_intern,
which an extension answering what happened wants immediately — and that is
what a second customer is for.
Both are small on purpose, and the numbers are worth writing down
| library exports | the bundle calls | messages published | |
|---|---|---|---|
| GTK4 | 4,299 gtk_* |
21 | 15 |
| SDL2 | 837 SDL_* |
15 | 10 |
Half a percent of one and under two percent of the other. Neither is a binding anybody should write an application against: the GTK one has no entry and no text view, so it cannot make a form, and the SDL one has no textures, so it cannot draw a sprite.
They are the size they are because the expensive work is per toolkit, not per function, and a demonstration only has to pay it once:
| paid once | never paid again |
|---|---|
| lifetimes against the collector | every further widget |
| a foreign main loop re-entering the VM | every further callback |
| a callback surviving collection | every further signal |
| limits still applying | every further message |
After that a message is a primitive, an arity check, a sol_foreign_handle
call, and a line in sol_extension_init — about fifteen lines, and no decisions.
The first fifteen took a day; the next fifty would take an afternoon.
Past a few dozen, an interface this size is generated rather than typed. GTK ships GObject Introspection data describing 3,348 methods in XML, with types, ownership and nullability, which is what every other language’s binding is built from. SDL2 has no such data and does not need it, being small and regular.
Nothing is waiting on either. The trigger for more is a program that wants something a bundle does not have — the same rule that decided every question above it, and the reason the answer here is fifteen messages rather than four hundred.
What is deliberately not promised
A sandbox. Nothing on this page is one. See “who decides” above.
That an extension can be unloaded. The dlopen handle is never closed.
Primitives, and any string constant a message points at, live as long as the
machine, and unmapping the code underneath them would leave slots pointing into
a dead page. Nothing unbinds a global either — this is
3.10 seen from another side.
Deterministic close from inside a program. There is no close message; see
“handing a resource back” above for why. sol_foreign_release is there for an
extension that must close in a known order — a child before its parent — and is
not reachable from Solum.
Anything about two extensions agreeing. They meet at the root object and nowhere else. Two that bind the same global will overwrite each other in the order given on the command line, and nothing warns.
Portability of a bundle. A bundle is per-platform and per-build. There is no format here, only the platform’s.
What holds this page honest
tests/test_extension.c has a case for the contract — registration, the handshake, refusal, ordering, and that a limit still ends a program using an extension.
But the linker’s half is checked somewhere else, and the reason is worth
knowing. Whether a loaded bundle can resolve sol_* back into the program
that loaded it depends on what that program exports, and a symbol reaches an
executable’s export table only if the executable already referenced it. A test
binary that calls sol_vm_set_global on its own account finds it exported
however the link was done — so an assertion there passes while bin/solvm stays
broken. The first draft of that file made exactly this mistake and had to be
corrected.
So the decisive case is
test_an_extension_reaches_the_program, which builds a
real bundle and hands it to the real binary. Against a solvm linked the old
way it fails with
symbol not found in flat namespace '_sol_vm_set_global'
which is the defect the Makefile’s WHOLE_LIB exists to prevent: before it, the
four binaries exported four different accidental sets of sol_* — 100, 118, 133
and 118 — and every function in embed.c was in none of them, because no front
end here calls one.