137 KiB
calog -- Polyglot Script Broker: Design & Implementation Plan
A C "broker" that lets one application be written in a mix of scripting languages (Lua and my-basic first; Squirrel and others later). Native C functions are added once and become callable from every language. Functions and data exported from one module are callable from modules written in another language. Threading is actor-based; networking rides the same dispatcher. Data sharing is by-value for v1.
This document is the reconciled output of a design pass plus an adversarial verification pass. Where the verification corrected the first-cut design, the correction is folded in and noted as "[verified]".
1. Architecture: hub and spoke
Nothing talks to anything else directly. Every engine talks only to the broker, through two shared contracts:
- One universal value type,
ValueT(a tagged union). - One uniform native-function signature:
typedef int32_t (*NativeFnT)(ValueT *args, int32_t argCount, ValueT *result, void *userData);
A developer writes a native function once against that signature and registers it once.
A script function exported from a module is itself stored as a NativeFnT whose body
re-enters its owning interpreter -- so "call C from script", "call script from C", and
"call module A's function from module B" are all the same code path. Adding an engine is
O(1) adapter work, not O(N) per existing engine.
The single most important lesson from the verification pass: there must be exactly one
ValueT / AggregateT / ValueTypeE, defined in broker.h, included verbatim by every
adapter. The first-cut design had three divergent copies and would not have linked, let
alone round-tripped data. Section 2 is therefore the load-bearing part of this plan.
2. The canonical type system (broker.h) -- single source of truth
2.1 Value tags and the value struct
typedef enum ValueTypeE {
valueNilE = 0,
valueBoolE = 1,
valueIntE = 2, // int64_t
valueRealE = 3, // double
valueStringE = 4, // length-prefixed, binary-safe
valueAggregateE = 5, // hybrid array + map container
valueFnE = 6 // function value: refcounted handle to a CallableT
} ValueTypeE;
typedef struct StringT {
char *bytes; // owned; always NUL-terminated at bytes[length] for C consumers
int64_t length; // byte count excluding the convenience terminator (binary-safe)
} StringT;
typedef struct ValueT {
ValueTypeE type;
union {
bool b;
int64_t i;
double r;
StringT s;
struct AggregateT *agg; // heap-owned subtree
struct CallableT *callable; // refcounted, broker-owned
} as;
} ValueT;
2.2 The aggregate (one shape both engines map onto)
typedef struct PairT {
ValueT key; // marshal layer constrains to int/real/string keys (see 2.5)
ValueT value;
} PairT;
typedef enum AggregateKindE {
aggregateListE = 0, // empty container round-trips as a list by default
aggregateMapE = 1,
aggregateBothE = 2 // array part AND pairs part populated
} AggregateKindE;
typedef struct AggregateT {
AggregateKindE kind; // disambiguates empty/mixed containers across engines
ValueT *array; // dense elements [0, arrayCount)
int64_t arrayCount;
int64_t arrayCap;
PairT *pairs; // map part; preserves insertion order
int64_t pairCount;
int64_t pairCap;
} AggregateT;
A Lua table's sequence part maps to array, its remaining keys to pairs. A my-basic
LIST maps to array, a DICT maps to pairs. The explicit kind flag fixes two
problems the verifier flagged: an empty {} had no defined type on the far side, and a
mixed array+hash Lua table had no representation at all.
2.3 Function values: CallableT
valueFnE is the one deliberate exception to "by-value everything". A function cannot be
meaningfully copied between heaps, so it is shared by reference -- but safely, because it
is only ever invoked, never inspected, and invocation always routes to the owning
context's thread.
typedef struct CallableT {
NativeFnT fn; // uniform invoke entry
void *userData; // luaL_ref slot, pinned mb_value_t, or C closure ctx
uint32_t ownerCtxId; // context whose thread MUST run it
uint32_t ownerGen; // generation of that context (UAF guard, see sec 9)
int32_t refCount; // ATOMIC; shared-handle lifetime across threads
bool alive; // false once the owning context is torn down
} CallableT;
Rules (these resolve the verifier's critical "function value breaks the no-shared-pointer invariant" finding):
valueCopyof avalueFnEdoes an atomic refcount increment on the sameCallableT-- it does NOT clone the closure. The sharedCallableT*across threads is allowed precisely becauserefCountis atomic and the closure is touched only on its owner thread.valueFreeof avalueFnEdoes an atomic decrement. When it hits zero, the underlying closure must be released with an interpreter call (luaL_unref, my-basic unref) -- which can only run on the owner's thread. So a zero-drop on a foreign thread posts a release message to the owner context rather than calling the interpreter directly (sec 10).- Invoking a dead handle (
alive == false, owner gone) returns a clean broker error, never a call into a freed interpreter.
2.4 Value operation contract (one signature, used everywhere)
The verifier caught two designs declaring valueCopy with incompatible signatures. The
canonical form -- status-returning, dst-by-pointer, so OOM is checkable on the hot path:
int32_t valueCopy(ValueT *dst, const ValueT *src); // deep copy; brokerOkE / brokerErrOomE
void valueFree(ValueT *v); // recursive free; leaves a safe nil
void valueMove(ValueT *dst, ValueT *src); // zero-alloc ownership transfer
valueFree and valueCopy MUST have a default:/explicit case for every tag including
valueFnE -- a missing case was how the first cut silently leaked function payloads.
2.5 Cross-engine fidelity table (the honest limits)
By-value marshalling across Lua <-> broker <-> my-basic is lossless for the common cases
and lossy at documented edges. These are inherent to my-basic's value model (verified
against my_basic.h/.c), not marshalling bugs:
| Aspect | Lua | my-basic | Crossing Lua <-> BASIC |
|---|---|---|---|
| integers | 64-bit | int_t == int (32-bit, always) |
truncates above 2^31 -- range-check + error |
| reals | double | float (double w/ -DMB_DOUBLE_FLOAT) |
precision loss unless double build |
| int vs real subtype | distinct (5.4) | integral real auto-collapses to int | subtype not preserved through BASIC |
| strings | binary-safe (length) | bare char* (strlen) |
embedded NUL truncates -- detect + error |
| array / list | table sequence part | LIST |
OK |
| map / dict | table hash part | DICT (int/real/string keys) |
OK |
| mixed array+hash table | one table | no single LIST+DICT value | collapses to DICT (array part -> int keys), documented |
empty {} |
table | LIST or DICT? | kind flag; default LIST |
| function value | closure (luaL_ref) |
lambda/routine (MB_DT_ROUTINE) |
OK via CallableT (by-reference) |
| nested depth | bounded | bounded | one shared cap; defined error past it |
| cycles | rejected on ingress | rejected on ingress | impossible at rest (by-value, no shared refs) |
Policy decisions baked in to make the above deterministic:
- One shared recursion-depth cap applied on every recursive path -- both ingress and egress, all components -- failing with a defined status instead of overflowing the C stack. (The first cut bounded only Lua ingress.)
- One strict/lenient switch, owned by the broker and honored by both adapters: in strict mode an unrepresentable value/key (e.g. a function used as a table key, a 64-bit int into BASIC, a NUL-bearing string into BASIC) is an error; in lenient mode it is dropped/coerced with a documented rule. Never a silent surprise either way.
- Keys: broker allows int/real/string keys (my-basic dicts accept all three). Float keys get defined equality; non-representable keys follow the strict/lenient switch.
3. The engine vtable -- what makes the actor loop engine-agnostic
Each adapter implements one small vtable so the broker/actor core never special-cases an engine:
typedef struct EngineT {
const char *name;
void *(*createInterpreter)(struct ScriptContextT *ctx); // ON the owning thread
void (*destroyInterpreter)(void *interp);
int32_t (*loadSource)(void *interp, const char *src, int64_t len);
int32_t (*registerNative)(void *interp, const char *name, NativeFnT fn, void *userData);
int32_t (*callExport)(void *interp, void *exportRef, ValueT *args, int32_t argCount, ValueT *result);
void (*releaseExport)(void *interp, void *exportRef); // ON the owning thread
} EngineT;
createInterpreter, destroyInterpreter, releaseExport, and every callExport run on
the context's own thread -- that is the invariant that keeps each interpreter
single-threaded.
4. The Lua adapter
Target Lua 5.4 (note 5.1/5.3 deltas where they matter). The C API surface was verified accurate; the fixes below are about lifecycle, not API names.
4.1 Native registration and the trampoline
Each registered NativeFnT becomes a Lua C closure: push the NativeFnT and its
userData as upvalues with lua_pushcclosure, then lua_setglobal (or into a module
table). The single trampoline recovers them from upvalues, marshals the Lua stack into a
ValueT[], calls the NativeFnT, and pushes the result back.
lua_setglobalreturnsvoid(the first cut documentedint-- harmless but wrong).- Lua allocation APIs (
lua_newuserdatauv,lua_createtable, ...) longjmp on OOM and never return NULL -- so NULL-checks after them are dead code; the OOM path is a Lua error, not a C return. OnlyluaL_newstatecan return NULL and must be checked.
4.2 Marshalling ValueT <-> Lua (by value)
Scalars are direct. Strings use lua_tolstring + length (binary-safe, preserves NULs).
Tables deep-copy in both directions:
- Ingress (table ->
AggregateT): normalize the table index to absolute beforelua_next; route numeric keys throughlua_tointeger/lua_tonumber(do not letlua_tolstringmutate a numeric key in place); fillarrayfor the sequence part andpairsfor the rest; detect cycles via an ancestor-pointer stack (lua_topointer); uselua_rawset/lua_rawgetto avoid metamethods; enforce the shared depth cap. - Egress (
AggregateT-> table): build withlua_createtable, populatearrayas the sequence andpairsas keyed entries, with the same depth cap (the first cut had no egress cap -- a deep BASIC-origin structure could overflow the stack on the way back). Make the builder self-balancing: recordlua_gettopon entry andlua_settopback on any error return.
4.3 Exporting a Lua function (and the leak fix)
A Lua function crossing the boundary becomes a valueFnE: pin the closure with
luaL_ref(L, LUA_REGISTRYINDEX), wrap it in a CallableT. The fn body does
lua_rawgeti to retrieve, marshals args onto the stack, lua_pcall, marshals the return;
on error it pulls the message with luaL_tolstring and reports it as a broker error
(sec 8).
Two bugs the verifier found, fixed here:
- The exports array needs grow-on-demand. The context is
calloc'd, so the array starts NULL/0; the first export mustrealloc(double the cap, handle the NULL/0 seed) before storing, andluaL_unrefthe just-created ref if the realloc fails. - Transient vs persistent ownership. Every Lua function passed as a native argument
was creating a
luaL_refthat only got released atlua_close-- an unbounded leak for any long-lived context that takes callbacks. Fix: theCallableTrefcount owns theluaL_ref. When the lastvalueFreedrops the handle to zero, the ref is released (on the Lua thread per sec 10). A function the broker retains as a real export holds a reference for as long as it is registered; a function merely borrowed for the duration of one call is released when that call'sValueTargs are freed. Same mechanism, two lifetimes.
4.4 Calling a function value from Lua
A valueFnE marshalled into Lua becomes a Lua C closure over the CallableT*, so script
authors just write cb(x, y) and it transparently dispatches (to the owner's thread if the
function lives elsewhere). A universal call(fn, ...) native is also provided for
uniformity across engines.
4.5 Context lifecycle
luaL_newstate + luaL_openlibs on the owning thread; confine the lua_State to that
thread forever; teardown luaL_unrefs outstanding refs then lua_close.
5. The my-basic adapter
Verified against paladin-t/my_basic (my_basic.h + .c read directly). Zero
hallucinated calls. The interesting work is three my-basic-specific quirks that shape the
adapter; all three are forced by the source, not stylistic.
5.1 Lifecycle and the inverted register result
mb_init (once per process) / mb_open(&bas) / mb_load_string(bas, src, true) /
mb_run / mb_close / mb_dispose. The broker pointer is threaded through the interp's
single userdata slot via mb_set_userdata / mb_get_userdata.
mb_register_funcreturns a count, not a status -- nonzero means registered,0means duplicate/failure. That is the opposite of theMB_FUNC_OK == 0convention, so the success test must be inverted. Names are uppercased internally (mb_strupr), so the broker key is the uppercased identifier (BASIC is case-insensitive).
5.2 The native-function protocol and the trampoline bank
The native signature is typedef int (*mb_func_t)(struct mb_interpreter_t*, void**); --
no per-callback userData parameter, and the interpreter has only one userdata slot.
So a single shared C trampoline cannot tell which broker function it is serving.
Fix (the verifier confirmed this limitation is real): a macro-generated bank of
trampolines mbTramp0 .. mbTrampN, each hardcoding its slot index, each looking up
ctx->nativeBank[slot] (the NativeFnT + userData) via the interpreter's userdata
pointer. The bank size caps how many natives one my-basic context can host; size it
generously and document it.
Inside a trampoline the argument protocol is the real my-basic frame dance:
mb_attempt_open_bracket / loop mb_pop_value (honoring mb_has_arg) /
mb_attempt_close_bracket / compute / mb_push_value (or the typed mb_push_*).
5.3 String ownership (memdup is mandatory)
mb_pop_string hands back a borrowed interior pointer -- the broker must strdup/copy
it immediately. Pushed strings are taken over by the interpreter and later freed with its
allocator, so any string handed to mb_push_value/mb_make_string must come from
mb_memdup (not plain malloc). Embedded NULs cannot survive (bare char* + strlen)
-- enforce the strict/lenient policy on egress.
5.4 Aggregates: the collection API
There is no mb_make_coll. A list/dict is built by presetting coll->type = MB_DT_LIST/MB_DT_DICT then calling mb_init_coll, and accessed with
mb_get_coll / mb_set_coll / mb_remove_coll / mb_count_coll / mb_keys_of_coll.
Collection support is on by default (MB_ENABLE_COLLECTION_LIB). A broker aggregate with
both array and pairs populated collapses to a DICT (array part becomes integer keys)
per the fidelity table.
5.5 Exporting a BASIC routine -- the parked __BROKERSERVE frame
This is the my-basic-specific crux. To call a BASIC routine/lambda from C you use
mb_get_routine(s, l, name, &val) then mb_eval_routine(s, l, val, args, argc, &ret) --
and mb_eval_routine dereferences *l and hard-requires a live, non-NULL void** l
(verified at my_basic.c:14344/14358). A valid l only exists inside a running native
call. Therefore a my-basic context cannot be driven from arbitrary C; it must be parked
inside a native frame.
Design: register a native __BROKERSERVE whose C body is the context's message-pump /
serve loop. A module hands control to the broker by ending with a SERVE call (the adapter
appends one if absent). While parked there, the loop holds a valid l, which it uses to
mb_eval_routine whenever another context calls one of this module's exported routines.
mb_get_routine returns MB_FUNC_OK with a nil value when a name is absent, so the
not-found test is routine.type != MB_DT_ROUTINE, not the status code.
For the callback direction the parked frame is not the only way in: the fork also provides
mb_eval_routine_cold(s, routine, args, argc, ret) (see vendor/ourbasic/CHANGELOG), which
invokes a routine value from an idle interpreter with no live l -- it supplies the last AST
node as a clean return landing (the head segfaults) and passes args directly. Combined with the
bare-def-as-value evaluation (a routine identifier not followed by ( yields the routine
value instead of "Open bracket expected"), a my-basic script passes a top-level def/lambda to
psSubscribe / timerAfter / calogExport and it is fired later, cross-engine included. The
adapter invokes synchronously via the live-frame mb_eval_routine when a callback runs inside a
serving native call, and via mb_eval_routine_cold when it is delivered to an idle context. (A
callback must be a top-level routine; one local to a function dangles once that function returns.)
The same fork also gives my-basic bare-name resolution for exports. A third add-only patch adds
an mb_dynamic_func_handler hook (a field on the interpreter plus mb_set_dynamic_func_handler):
at the "invalid identifier usage" site in _calc_expression -- a bare name called like a function
that is neither variable nor routine nor collection -- the interpreter offers the name to the
handler before erroring. The adapter's handler resolves it against the export registry
(case-insensitively, since my-basic uppercases identifiers at parse time) via a fold variant of
the export resolver, and invokes it. So a BASIC script calls exportedFn(args) directly, without
an explicit calogCall -- the convenience the hook engines (Lua/JS/Squirrel/s7) get from their
unknown-name hooks. Wren and Berry, whose C APIs offer no such hook, still use calogCall.
5.6 Numeric and identity caveats
int_t is 32-bit unconditionally (64-bit broker ints truncate -- range-check + error or
promote to real with documented precision loss); integral reals auto-collapse to int so
real/int subtype is not preserved across a BASIC hop. Both are in the fidelity table; both
follow the strict/lenient switch.
6. Threading: the actor model
Each ScriptContextT owns one interpreter, one OS thread (pthreads -- chosen over C11
<threads.h> for portability/maturity), and one inbound MPSC message queue. Interpreters
are single-threaded; only the owning thread ever enters callExport. A cross-context call
is a message; the caller blocks for the reply on a per-call condvar future (lost-wakeup
safe via a predicate loop). The verifier confirmed the core is sound: no path lets two
threads touch one interpreter, the deep-copy ownership ledger is correct on the success
path, and the epoll thread enqueuing while a context is mid-dispatch is race-free.
The fixes folded in from verification:
- One error channel. The first cut carried a separate error
ValueTin the reply that the caller never freed -- a leak on every errored call, lost error text, and a second source of truth contradicting the broker's "error travels inresult" contract. Fix: on failure the adapter writes the error string intoresult(asbrokerSetErrordoes); the reply carries only{status, result}. One channel, one owner, freed once. valueCopychecked on enqueue. Use the canonicalint32_t valueCopy(dst, src), check each arg, and unwind partially-copied args on OOM (mirroring the broker route path). The actor layer should call the broker's marshalling, not reimplement a copy loop.- Shutdown drains everything. On
SHUTDOWN, error-reply every queuedCALLand free every queued/stashedREPLY(result + error) before join -- the first cut leaked in-flight replies unwound by a nested shutdown. - Explicit thread stack size. The reentrancy depth bound counts dispatch nesting, not
C-stack bytes; set a validated stack size with
pthread_attr_setstacksize(or lower the bound) so the "clean catchable depth error" promise actually holds instead of a UB overflow. - Split the ready-handshake condvar off the queue condvar so
queueCondhas exactly one semantic (latent lost-wakeup footgun if a second waiter is ever added).
6.1 What a context does while blocked: always-live nested pump [DECIDED]
When context A makes a synchronous cross-context call and waits for the reply, A's thread pumps its own inbound queue instead of sleeping idle. An incoming call to A -- including a re-entrant B->A issued during the very call A is waiting on -- is serviced on A's own thread, then A resumes waiting. This was chosen over strict run-to-completion because it never deadlocks and needs no wait-for-graph deadlock detector; the rejected alternative would have had to raise a "synchronous call cycle" error on A->B->A and would leave a busy context unresponsive to other callers. The verifier validated the pump as sound: only A's thread ever enters A's interpreter (the single-threaded invariant holds), reply nesting is strict LIFO, and depth is bounded.
The contract this commits the runtime and script authors to:
- Re-entry happens only at explicit cross-context call points (
x = getUserInfo(),data = sockRecv(c)), never mid-statement -- a call point is a yield point. - Module-global state may differ after a cross-context call returns, because another call may have run on this context while it was outstanding (the same contract as any RPC). Local variables are unaffected.
- Reentrancy is depth-bounded with a catchable error (backed by an explicit pthread stack size, sec 6 fixes), so runaway ping-pong fails cleanly instead of overflowing.
Script code stays plain synchronous-blocking regardless -- info = getUserInfo() just
works; this only governs what the runtime does while a call is outstanding.
7. Networking and the dispatcher
One dedicated I/O thread runs epoll (Linux; kqueue/poll for portability) and owns no
interpreter. Async socket primitives (sockConnect, sockListen, sockSend, sockRecv,
sockClose, plus a timer) are registered once through the broker, so every language gets
them. The recommended v1 model is synchronous-blocking at the script level: data = sockRecv(conn) parks the calling context on a reply future; when epoll reports readiness,
the I/O thread builds a CALL/reply and enqueues it onto the owning context's queue, so
the result lands on the right interpreter thread. Callbacks are opt-in on top: pass a
valueFnE (e.g. onConnect(myFunc)) and the completion invokes it via the same dispatch,
always back on its home thread. No separate async keyword, no per-engine coroutine support
needed.
Fixes from verification:
- The I/O command queue must be strict tail-append FIFO (a
sockSendissued right aftersockConnectmust be processed after the connect that registered the handle); assert it. - The resolver/connect path must deep-copy
hostbefore the command is freed (the first cut had an unconditionalfree(cmd->host)that would UAF if stored by pointer). - Wake the epoll thread for new interest via
eventfd/self-pipe; deregister on close; draineventfdtoEAGAIN. - Portability note:
pthread_condattr_setclock(CLOCK_MONOTONIC)is absent on Darwin -- guard it with#ifdefand derive any monotonic timed wait accordingly (a monotonic deadline cannot be handed to a realtime-clock condvar).
8. Error model (one source of truth)
A NativeFnT returns a status int and, on failure, writes a human-readable message into
result (a valueStringE tagged as an error, or a small error-struct convention). That
single in-band channel crosses the actor boundary unchanged, is freed exactly once by the
caller, and is surfaced into the calling engine as that engine's native error
(luaL_error/lua_error for Lua, mb_raise_error for my-basic). There is no second error
field anywhere.
9. Context lifetime and the registry (UAF fix)
The critical use-after-free: contexts were addressed by raw ScriptContextT* (and exports
held raw owner pointers), while contextShutdown frees the context and destroys its
mutex/cond at runtime -- so a foreign thread could enqueue onto a freed queueMutex.
Fix:
- Address contexts by a stable integer id through a locked registry; never by raw
pointer.
contextEnqueue/contextCall/ioDispatchresolve id -> context under the registry lock and either hold the lock across enqueue or take a reference so the context (and its mutex) cannot be freed mid-enqueue. - Add a generation counter to context ids and to
CallableT.ownerGenso a recycled id cannot misroute an in-flight completion to a different context. contextShutdown: under the lock, mark dead and remove from the id map; reject new enqueues with a defined "dead context" error; drain and error-reply queued work; wait for in-flight references to drain; then free.
10. Function-value lifecycle across threads
CallableT.refCount is atomic. valueCopy bumps it; valueFree drops it. The subtlety:
releasing the underlying closure is an interpreter op (luaL_unref / my-basic unref) that
must run on the owner's thread. So when a drop reaches zero on a foreign thread, the
broker posts a release message to the owner context instead of touching the interpreter
directly; the owner releases the closure on its own thread and frees the CallableT. If
the owner is already gone (alive == false), the CallableT shell is freed directly (the
closure is already gone with the interpreter) and any pending invoke returns a clean error.
11. Build order
- Broker core:
broker.h(the canonicalValueT/AggregateT/CallableT/enums),valueCopy/valueFree/valueMovewith full tag coverage and the depth cap, the name->entry registry,brokerCall, the error convention. Unit-test value round-trips and deep-copy/free under a leak checker before any engine exists. - Lua adapter against the core: trampoline, scalar+string marshalling, table
deep-copy both directions with caps, native registration, export with the refcounted
luaL_reflifecycle and exports-array growth. Test C<->Lua and Lua-export-called-from-C single-threaded. - my-basic adapter: lifecycle, the trampoline bank, the arg-frame protocol,
mb_memdupstring ownership, the collection mapping, and the parked__BROKERSERVEexport frame. Test C<->BASIC and the full Lua<->broker<->BASIC round-trip against the fidelity table (assert the lossy edges error or coerce exactly as documented). - Actor layer:
ScriptContextT, the MPSC queue, the reply future, the id+generation registry, the single error channel, the chosen block-while-waiting semantics (sec 6.1), and shutdown drain. Stress cross-context calls and teardown under a thread sanitizer. - Networking/dispatcher: epoll I/O thread, the FIFO command queue, the socket/timer
natives, completion dispatch onto owning queues, callbacks via
valueFnE. - Squirrel (later): a third adapter validates that the vtable + canonical
ValueTreally make new engines O(1).
12. Open decisions
- Block-while-waiting semantics: DECIDED -- always-live nested pump (sec 6.1).
- Strict-vs-lenient default for the lossy marshal edges (recommend: strict by default so truncation/loss is an explicit error; lenient opt-in per call).
- my-basic native-bank size (cap on natives per BASIC context).
- Whether a foreign function injected into BASIC should be transparently callable as a
routine value (
cb(x)) or only via the portableCALL(fn, ...)primitive (Lua gets the transparent form for free; BASIC's transparent form needs confirming).
13. Implementation notes (as-built: broker core + both adapters)
Built and tested: broker.h/value.c/broker.c (core), luaAdapter.* (Lua 5.4),
mybasicAdapter.* (vendored my-basic in vendor/), with testBroker/testLua/
testMyBasic/testPolyglot -- 378 checks, clean under ASan+UBSan. The polyglot test
proves the thesis: one C native called from both engines, and a Lua function invoked from
a BASIC program through the broker.
Core refinement. The single global callable-release hook could not distinguish a Lua
closure from a my-basic routine, so release is now a per-callable CallableReleaseFnT
passed to callableCreate (design sec 10's "owner releases the closure", just synchronous
for now). Added callableUserData so a release fn can reach its closure handle.
Lua adapter. Context pointer lives in lua_getextraspace. Native bindings are
context-owned {fn,userData} structs referenced by a light-userdata upvalue on one shared
trampoline. A Lua function crossing out becomes a CallableT over a pinned luaL_ref
(released via luaL_unref in the per-callable release fn); transient callback args are
freed automatically because valueFree drops the handle. A CallableT crossing in becomes
a callable userdata with __call/__gc. Lua allocation APIs longjmp on OOM (no NULL
checks). Caveat: release exported callables before luaContextDestroy (the luaL_ref
lives in that state's registry).
my-basic adapter (the high-effort one; these rules were forced by ASan):
- Build with
-DMB_DOUBLE_FLOAT(double reals) and link-lm. - Native signature has no per-call userData and one interpreter userdata slot, so a
macro-generated trampoline bank (
MB_BANK_SIZE) supplies slot-specific entries that recover the binding from the context. mb_register_funcreturns a count: nonzero = success, 0 = failure (inverted vs the usualMB_FUNC_OK == 0).- Ownership is asymmetric and was the main source of bugs (verified against the my-basic
source during adversarial review):
- A popped collection is owned by the consumer (
mb_dispose_valueafter marshalling); a popped string is a borrowed interior pointer (copy, never free). mb_set_collcopies a scalar/string key-value (dispose your copy after) but stores a collection by pointer without a reference -- so a nested collection needs an explicitmb_ref_valuebefore the set, and must then NOT be disposed (the parent owns it).mb_push_valuetransfers a collection, but borrows a string -- a string result must be pushed withmb_push_string(which marks it for lazy destroy), notmb_push_value.mb_eval_routineborrows its arguments (it never frees them), so marshalled routine args are disposed by the caller after the call -- and the return value is marshalled out first, because a routine may return one of those borrowed arguments.- Routine values are not ref-counted (
mb_ref_value/mb_unref_valuecorrupt them); a routine name must be uppercased beforemb_get_routine(BASIC uppercases at parse). - int64 entering BASIC is range-checked to 32-bit
int_t(brokerErrRangeEon overflow).
- A popped collection is owned by the consumer (
- Routine export uses
mb_get_routine(by name) +mb_eval_routine, both of which need a livevoid** l. That cursor only exists inside a native call, so the dispatch stashes it incurrentL; an exported BASIC-routineCallableTis therefore valid only while the context is serving (a native frame is on the stack -- what the actor layer's parked__BROKERSERVEframe will guarantee). For now, fetch and invoke within one native call. - One interpreter per program: a my-basic context hosts a single program; reset+reload after disposing native-pushed collection intermediates is unreliable, so the tests spin a fresh context per run. (The actor layer will own one long-lived parked context per module, which sidesteps this.)
Build/verify. Core compiled strict (-Wconversion -Wsign-conversion); adapters drop
those two (engine headers use wide macros) but keep -Wall -Wextra -Werror. All three
engines are vendored under vendor/ and built from source -- vendor/lua (Lua 5.4.6,
library = src/*.c minus the lua.c/luac.c mains), vendor/ourbasic (our-basic, a fork of my-basic),
vendor/squirrel-src (Squirrel 3.2) -- each relaxed and un-sanitized but linked into the
sanitized binaries so cross-boundary heap misuse is still caught. Nothing depends on a
system-installed engine or pkg-config, so the build is reproducible. The Lua platform
define is selected automatically: $(OS) first (Windows sets Windows_NT and has no
uname -> no define / ISO C), else from uname -s (LUA_USE_LINUX + -ldl /
LUA_USE_MACOSX / LUA_USE_POSIX). NB the project is otherwise Unix-only (pthreads,
sanitizers, setarch), so the Windows branch only keeps the define correct.
Function-value lifecycle across threads (sec 10), DONE. callableInvoke and the
final callableRelease are now thread-correct. The core exposes two installable hooks
(callableSetInvokeHook/callableSetReleaseHook, the same pattern as brokerSetRouteHook)
so it stays independent of the actor layer; actorInit installs them. An invoke from a
thread other than the callable's owner is marshalled to the owner's thread by reusing the
CALL machinery (a callable's fn+userData are exactly a native call -- callableFn is
the one new accessor). The final reference drop is routed too: a new messageReleaseE
posts the finalize (fire-and-forget) to the owner, which runs the engine release
(luaL_unref / sq_release) on its own thread. callableFinalize is the shared "run
release + free shell" tail; the core still runs it inline when no actor is present (so the
single-threaded testCallableDead semantics -- a dead callable still runs its release on
last drop -- are preserved). testEngineLua captures a Lua closure on its context's
thread, then invokes and releases it from the main thread; both marshal to the owner,
ASan/TSan-clean. Limit: releasing a callable whose owner context has been destroyed is
the deferred non-quiescent-teardown case (sec 9) -- best-effort inline finalize for now.
JavaScript adapter (Duktape), the fourth engine. Vendored Duktape 2.7.0 (the
single amalgamated duktape.c/duktape.h/duk_config.h) in vendor/duktape, built
relaxed/un-sanitized. src/js/jsAdapter.* mirrors the Lua/Squirrel adapters: one shared
trampoline recovers its binding from a hidden property on the function object
(duk_push_current_function + an internal \xFF-prefixed key) and dispatches through
brokerCall; marshalling covers scalars, binary-safe strings, and the hybrid aggregate
(JS array <-> list, object <-> map) with the depth cap. JS numbers are doubles, so an
integral in-range number round-trips as an int (else a real). A JS function crossing out
becomes a refcounted CallableT over a Duktape heap pointer kept alive by a per-heap
export registry object in the global stash (the slot is dropped on release) -- and it
participates in the sec-10 cross-thread routing, so a JS closure captured on its context's
thread is invoked and released from another thread correctly. src/js/jsEngine.* is the
EngineT binding. testJs (single-threaded: scalar/string/array/object marshalling, export
- invoke-from-C, closure-as-arg callback, error paths) and
testEngineJs(threaded: cross-context call + the sec-10 callback) -- clean under ASan/UBSan and TSan (make tsanjs). Adding the engine touched zero lines of the broker core or actor layer (one adapter TU + one engine-binding TU + Makefile rules), re-confirming the O(1)-engine-add thesis. v1 limit (as with Squirrel): pushing a foreignCallableTinto JS is unsupported.
Source layout. src/ holds the project source (core + actor directly in src/,
one subdir per script language: src/lua, src/mybasic, src/squirrel, src/js);
tests/ holds the test programs; obj/ collects every object file (ours and the vendored
engines, via patsubst into obj/); bin/ collects the binaries. The Makefile finds our
sources by VPATH and groups object rules by flag set; -MMD -MP generate header
dependencies automatically. make clean removes obj/ and bin/. make test builds and
runs all ten binaries; make tsan/make tsansq/make tsanjs are the ThreadSanitizer
variants.
16. Threading model rewrite -- host-thread natives, fire-and-forget scripts
Supersedes the earlier "natives run inline on the calling context thread" model. The
host's own thread is now an implicit host context (id 0): it has a queue but no OS
thread of its own, and the host drives it by calling calogPump in its loop.
- Scripts are fire-and-forget.
calogContextEval(ctx, src)enqueues the script onto the context's thread and returns a status (not the result); the script runs asynchronously, and results come back by calling natives. - A registered native runs on the host thread, serialized. A script calling one
posts a CALL onto the host queue and parks; the host runs it during
calogPump. So host C code is never called concurrently and needs no locking.actorRouteinlines a call already on the host thread; otherwise it marshals to the host context (id 0). calogRegisterInlineis the escape hatch: the registry entry'srunInlineflag (which replacedownerCtxId) makes the native run on the calling script's thread.- Errors from a fire-and-forget script are posted to the host queue and delivered
to the
CalogErrorFnThandler (calogSetErrorHandler) duringcalogPump(default: log to stderr). - Function values (
CalogFnT) still run on their owning engine's thread -- sec 10 routing is unchanged, andcalogFnInvokefrom the host blocks-and-pumps the host queue while it waits (the same nested pump, now applied to the host context). - Nested eval is allowed: a new eval that arrives while a context is mid-script
(parked on a native call) runs nested via
pumpUntil-- consistent with the sec-6.1 re-entrancy contract (interpreters support nestedpcall/peval).
API shape: calogRegister(c,name,fn,ud) / calogRegisterInline(...);
calogContextOpen(c,engine) -> CalogContextT* (create+start merged, since nothing is
registered between them anymore) and calogContextClose; calogContextEval(ctx,src)
fire-and-forget; calogPump; calogSetErrorHandler. CalogConfigT and the
createInterpreter config parameter are gone -- a context now exposes every
registered native (the engine binding walks the registry via the internal
calogForEach). Tests rewritten to drive calog the host way (register, open, eval,
pump-until-a-native-records-the-result); testActor is now engine-free, exercising the
dispatch machinery with C callables (calogFnCreate) on synthetic contexts. Verified:
make test 441 checks across 11 binaries (incl. examples/embed.c), gcc + clang
strict, ASan/UBSan + TSan clean (make tsan/tsansq/tsanjs).
CalogT owns its contexts; ids are unbounded. The active-context registry moved
from context.c file-static globals into struct CalogT (now defined in
calogInternal.h): a runtime owns both its native-function registry and its
active-context registry (ctxMutex, ctxSlots, freelist). context.c reaches it via
one runtime pointer set in calogActorInit (which also refuses a second runtime).
So calogDestroy closes every still-open context automatically -- the host need not
track them (a test opens 32 and never closes them; ASan confirms no leak). Context ids
widened to uint64 (32-bit slot index + 32-bit generation), so neither the live
count nor open/close churn hits a preset ceiling; calogContextId/calogCurrentId
return uint64_t. The now-dead ownerGen parameter was dropped from calogFnCreate
(generation lives in the packed id). Re-verified: make test 473 checks, ASan no
leaks, TSan clean, gcc + clang strict.
Independent runtimes in one process. The one-runtime limit was not fundamental --
just process-global state that hadn't moved into CalogT. All of it now has: the host
context, the routing hooks (routeHook/invokeHook/releaseHook), and the error sink
are CalogT fields; a CalogFnT carries a runtime pointer so calogFnInvoke/release
reach the right hooks (the callable path has no CalogT otherwise). The dispatch
reaches its runtime through the object it already holds -- the route hook is handed its
calog, the callable hooks read calogFnRuntime, context-thread code uses
context->broker, and the rest take an explicit calog argument. The only remaining
process global is currentContext, and it is thread-local (it names the calling
thread's context). So the setters (calogSetRouteHook etc.) are gone -- calogActorInit
assigns the fields directly -- and the runtime static that the earlier review flagged
is deleted. Runtimes are isolated: don't pass a value or callable between them (a
cross-runtime reply cannot route). A test spawns N threads, each creating, driving, and
destroying its own runtime concurrently.
One thread may host several runtimes. calogPump(calog) sets currentContext to
calog's host context for the drain and restores it after, so a single thread can drive
many runtimes by pumping each in turn -- a native serviced during calogPump(A) sees
calogCurrent() == A even if the thread also hosts B. Two consequences fall out and are
handled: context ids number from 1 in every runtime, so the "already on the owner's
thread" (inline) and "caller can take the token/pump path" (reply) decisions match the
runtime too, not the id alone -- a foreign or wrong-runtime caller takes a reply box,
which cannot misroute. A test creates two runtimes on one thread, runs a script in each,
and pumps both in a loop, asserting each runtime's native resolved calogCurrent() to
its own runtime (it fails if the pump doesn't rebind currentContext). Re-verified:
make test 480 checks, ASan no leaks, TSan clean (both concurrent runtimes and one
thread pumping two), gcc + clang strict.
Loading a script by filename. Each engine carries a NULL-terminated extensions
list ({"lua"} / {"js"} / {"nut"} / {"bas"}), and a host makes engines available
for filename-based loading with calogRegisterEngine(calog, &engine).
calogContextLoad(calog, base) then walks the registered engines in registration order
(each engine's extensions in order), forms "<base>.<ext>", and the first one that
fopens wins: it reads the file on the calling thread, opens a context on that engine,
and loads the contents fire-and-forget -- returning the context (NULL if nothing matched
or the load failed). Registration matters for more than search order: hardcoding the
built-in engine vtables in the core would force-link all of them (and their vendored
runtimes) into every binary, defeating the per-engine archives -- so the host opts in,
and a binary that never references an engine pulls in none (testActor stays
engine-free). Engine selection is fundamentally a build-time (link) choice, so
calogRegisterBuiltinEngines (a header-inline in calog.h) registers exactly the
engines whose CALOG_WITH_<ENGINE> macro is set -- the host defines those alongside the
archives it links, and the inline emits nothing (references no engine) unless called, so
it never force-links.
my-basic as an actor engine. Making my-basic loadable meant running it under the
actor model for the first time, which exposed two things. (1) Its native dispatch called
the C function directly instead of through calogCall, so natives ran on the my-basic
context thread rather than marshalling to the host -- fixed by routing mbDispatch
through calogCall (the binding now stores the registry name), matching the other
engines. (2) my-basic keeps process-global state -- lazy mb_init singletons and a
global _mb_allocated counter touched on every allocation (forced on in the vendored
header) -- so two my-basic contexts on different threads race (TSan-confirmed). The
singletons are built once by mb_init and read-only thereafter, so the only
execution-time shared write is that counter; a one-line vendored patch makes it
_Atomic (the original is preserved as vendor/ourbasic/ourBasic.c.upstream). With the
counter safe, the my-basic engine (not the adapter, which stays usable single-threaded
and lock-free) needs a lock only across lifecycle -- mb_init's first-context build,
mb_dispose's last-context teardown, and the shared context refcount -- and NOT across
runSource, so several my-basic scripts execute concurrently. A tsanmb target proves
the parallel case is race-free (verified further by a 4-context stress running
arithmetic, strings, lists, and booleans). Verified: make test 494 checks (13
binaries), ASan no leaks, TSan clean on all four engines
(tsan/tsansq/tsanjs/tsanmb), gcc + clang strict.
15. Public embedding API (calog.h) -- as-built (superseded by sec 16 for threading/API)
calog is packaged as an embedding library: a host links it, registers its own native
C functions, creates script contexts on an engine, and runs scripts. Every public
symbol carries a calog prefix (types Calog...T, enums Calog...E) so the library
is a good citizen in a host binary. The API was curated to the minimum:
- One handle, one header.
CalogTis the runtime;calogCreate()composes the registry with the actor layer (installs the routing hooks) andcalogDestroy()tears both down -- no separate init/shutdown for the host. The entire embedding surface issrc/calog.h(~30 functions); internal machinery (the registry entry type, the route/invoke/release hooks, the low-level callable lifecycle, the splitcalogBrokerCreate/calogActorInit) lives insrc/calogInternal.h, which host code never includes.calog.hleaks no internal symbol. - One config type. The three per-engine configs collapsed into
CalogConfigT(exposeNames+exposeCount), used by every built-in engine vtable. - Value model unchanged, just prefixed.
CalogValueT/CalogAggT/CalogFnT+ constructors (calogValueInt, ...), ops (calogValueCopy/Free/Move/Equals), aggregates (calogAgg*), function values (calogFnInvoke/Retain/Release), andcalogFail/calogTypeNamefor writing natives. Contexts:calogContextCreate/Start/Eval/Destroy/Id, pluscalogCurrentId/calogCurrentfor natives. The built-in engine vtables arecalogLuaEngine,calogJsEngine,calogSquirrelEngine(a host may also supply a customCalogEngineT). - Packaging.
makebuildslib/libcalog.a(calog itself: core + actor + every adapter/binding) and separate vendored-engine archives (liblua.a,libduktape.a,libsquirrel.a,libmybasic.a). A host linkslibcalog.aplus whichever engine archives it uses; unused adapters (and their engine deps) stay unlinked because static members are pulled only when referenced -- so a JS-only host never links Lua/Squirrel. The tests consume the archives; the threaded/engine tests use onlycalog.h, validating that the public surface is complete.examples/embed.cis a ~30-line host (public header only) that registers a native and calls it from JavaScript. - Reconfirmed: rename + restructure kept all 441 checks passing across 10 test binaries, clean under ASan/UBSan and TSan (all four engines), gcc + clang strict.
14. Implementation notes (as-built: actor layer, engine-on-a-thread, Squirrel)
The actor layer (context.h/context.c, build step 4) is built and tested:
testActor exercises cross-context routing, the always-live nested pump (the
re-entrant A->B->A deadlock test), and a concurrent fan-out stress; clean under
ASan+UBSan and ThreadSanitizer (make tsan, run under setarch -R -- some kernels
hand out more mmap randomization than TSan's shadow tolerates). One thread + one
MPSC queue per ScriptContextT; brokerCall routes through an installed hook
(brokerSetRouteHook) so owner-0/same-context calls run inline and others marshal
to the owning thread; an external caller blocks on a private reply box, a context
caller pumps. The reply carries only {status, result} -- the single error channel
(sec 8) is structural, the error string rides in result. contextSendBlocking
and contextReply are the shared enqueue-wait and reply tails behind both CALL and
EVAL dispatch.
Generationed registry (sec 9), DONE. Context ids pack a 16-bit slot index and
a 16-bit generation; the registry is a slot table plus a freelist. contextDestroy
unlinks a context under the registry lock (after stopping+joining its thread) and
returns the slot to the freelist; the next reuse bumps the generation. A stale id
(slot since freed/recycled) resolves to brokerErrDeadE, never misroutes to the
recycler -- testActor's generation test proves it. The registry lock is held
across enqueue, so a foreign enqueue cannot race a destroy onto a freed queue mutex.
Still quiescence-assuming (no call to the context in flight at teardown); in-flight
reference draining is the remaining sec 9 hardening.
Engine on a thread (the EngineT vtable). EngineT gained runSource;
contextEval(context, source, result) marshals a script run onto the context's own
thread (a new messageEvalE) and blocks like a call. Each adapter's engine binding
lives in its own TU (luaEngine.*, squirrelEngine.*) -- the only Lua/Squirrel
files that depend on the threading layer, keeping the adapters thread-agnostic so
testLua/testPolyglot link them without context.o/pthread. createInterpreter
runs on the thread and exposes the configured natives there. Crucially, the exposed-
native trampolines now dispatch through brokerCall (by broker+name) instead of a
captured fn pointer, so an exposed native owned by another context is transparently
routed to its thread -- the script author still writes doubleIt(21). With no route
hook installed this is identical to the old inline path (testLua still passes).
testEngineLua proves a real Lua interpreter on a context thread calling a thread-
agnostic native and a cross-context native, on the correct threads.
Squirrel adapter (sec 11 step 6), the O(1)-engine-add validation. Vendored
Squirrel 3.2 in vendor/squirrel-src (C++), built relaxed/un-sanitized with
-D_SQ64 -DSQUSEDOUBLE so SQInteger/SQFloat are 64-bit int / double matching
ValueT -- the adapter shares those defines so the ABI matches. squirrelAdapter.*
mirrors the Lua adapter: one shared trampoline recovers its binding from the
closure's single free variable (which the VM pushes onto the stack after the args,
so it sits at the top -- verified in sqvm.cpp CallNative), marshals scalars,
binary-safe strings, and the hybrid aggregate (array<->list, table<->map) with the
shared depth cap, and dispatches through brokerCall. testEngineSquirrel runs a
real VM on a thread doing the cross-context call plus string and array round-trips;
clean under ASan+UBSan and TSan (make tsansq). The total surface a new engine
added: one adapter TU + one engine-binding TU + Makefile rules -- no change to the
broker core or the actor layer, which is the thesis.
Squirrel closure export, DONE. A Squirrel closure crossing the boundary now
becomes a refcounted CallableT over a pinned HSQOBJECT (sq_addref/sq_release,
mirroring Lua's luaL_ref lifecycle): squirrelExport fetches a named global
closure, and a closure passed as a native argument is exported the same way during
ingress (the VM's foreign pointer -- finally used -- recovers the owning context).
squirrelCallableInvoke runs sq_pushobject+sq_call on the owner's VM and
marshals the return; squirrelCallableRelease sq_releases on the owner thread.
Single-threaded testSquirrel covers export+invoke-from-C, a closure passed as an
argument and called back through the broker, and the not-found/type-error paths;
ASan-clean (no addref/release leak). Caveat (same as Lua): release exported
callables before squirrelContextDestroy. (The reverse direction -- a foreign CalogFnT
pushed INTO Squirrel -- is now supported too: a native closure whose one free variable is
a release-hooked userdata holding the CalogFnT; see sec 18.)
make test runs all seven binaries (411 checks). make tsan covers the actor core
and the Lua engine path; make tsansq the Squirrel path.
Adversarial review (3 parallel reviewers: actor concurrency, Squirrel adapter, Lua trampoline + engine bindings). Two real defects found and fixed:
- NULL-interpreter crash.
threadMainignorescreateInterpreter's status, so a failed create (e.g. a config expose-name that was never registered) left a context serving withinterp == NULL;contextDispatchEvalonly checkedrunSource != NULL, so the first eval calledrunSource(NULL,...)-> NULL deref. Fixed by guardinginterp == NULL(the context still serves native calls, just rejects evals); regression test intestEngineLua(testFailedInterpreter). - OOM lost-wakeup.
contextReply's context-caller branch allocated a fresh REPLY and, oncallocfailure, dropped the wakeup -- the caller hung inpumpUntilforever. Fixed by reusing the request message as the reply (it already carries the token and replyToId), which removes the allocation entirely, so the wakeup can no longer be lost to OOM. The Squirrel adapter was traced clean against the real Squirrel source (trampoline free-var indexing, stack balance, ValueT ownership, the throwerror/free order, binary strings); addedsq_reservestackguards before the recursive marshallers to match the Lua adapter'slua_checkstackdiscipline. Documented (not changed): the registry must be frozen before contexts start (brokerCallreads it locklessly from context threads -- noted in broker.h); teardown still assumes quiescence (sec 9); and the hybrid-aggregate-to-Squirrel-table egress flattens array indices and integer keys into one table (same lossy edge as elsewhere in the fidelity table).
17. Engine expansion -- QuickJS-ng, and three new languages (Berry, s7, Wren)
calog now ships seven engines. Each is one adapter TU (marshalling + native
trampoline + the sec-10 callable export) plus one engine-binding TU (the four-hook
CalogEngineT), a vendored-from-source archive, a testEngine*, and a tsan* target --
the core, the actor layer, and calog.h were untouched, re-confirming the O(1)-per-engine
thesis. Which engines a binary pulls in is a link-time choice: the header-inline
calogRegisterBuiltinEngines references only the CALOG_WITH_<ENGINE>-selected vtables,
so testActor still links zero engine code.
QuickJS-ng replaces Duktape (same calogJsEngine / .js, same jsAdapter.h /
jsEngine.c -- only jsAdapter.c and the Makefile changed). The wins: a JS BigInt
round-trips to int64 exactly (JS_ToBigInt64), closing the double-only fidelity gap
Duktape had (proven by a 2^53+1 test); JS functions are refcounted JSValues
(JS_DupValue / JS_FreeValue), replacing the Duktape heap-pointer-pinning registry
behind CalogFnT; and the broker/name binding rides on JS_SetContextOpaque +
JS_NewCFunctionData. One gotcha: a missing global reads back as undefined (not an
error), so calogJsExport maps JS_IsUndefined to not-found while a bound non-function
stays a type error. Core library = quickjs.c + libregexp.c + libunicode.c +
dtoa.c, built with -D_GNU_SOURCE.
Berry (.be) is a Lua-like stack VM (64-bit ints, binary-safe be_pushnstring).
Natives are Berry native closures carrying two upvalues (the context comptr and the
name), recovered with be_getupval(vm, 0, pos); a Berry function crossing out is pinned
under a uniquely-named hidden global (globals are GC roots) and dropped by setting it to
nil. The sharp edge: be_pcall(vm, argc) leaves the result in the function's slot
(base+1), not at -1 (which holds the last stale argument). Vendoring needs Berry's
coc codegen prebuild plus its OS port (be_port.c) and module/class tables
(be_modtab.c). A subtlety for records: be_newmap/be_newlist push raw containers
that a script cannot subscript, so an aggregate crossing out is wrapped in its map/list
class instance (map(raw) via be_getbuiltin + be_call, then be_moveto/be_pop to
drop the raw and init's nil return) -- then user['name'] works. Ingress reverses it:
a list/map instance holds its raw container in the hidden .p member, iterated with
be_pushiter/be_iter_next (see sec 18).
s7 Scheme (.scm) uses the current official s7 (an older mirror lacked s7_free,
which would leak a heap per context). Since s7 native functions carry no user data, all
natives route through one generic %calog-call dispatcher plus a per-name Scheme
wrapper ((define (report . a) (apply %calog-call "report" a))); the context rides on a
*calog-context* c-pointer global. Callables are kept alive by s7_gc_protect and
invoked with s7_call. Because s7_eval_c_string evaluates a single form, calogS7Run
wraps the (escaped) source in (catch #t (lambda () (eval-string ...)) handler), so both
read and run errors surface as a value -- a marker pair the runner detects. An aggregate
crossing out is a Scheme list, or an (applicable) hash-table when keyed, so a materialized
record reads as (user "name"); reading a script's keyed value back in is a v1 limit. s7's intentional
"permanent string" interning (which s7_free does not reclaim) is a small, bounded
allocation, suppressed with a documented, allocation-site-specific __lsan hook. s7 is
per-interpreter thread-safe -- no serialization needed (unlike my-basic).
Wren (.wren) is the outlier: Wren has no bare function calls, so every native is
reached through a single foreign method. A preamble defines class Calog { foreign static call(name, args) }, and scripts call Calog.call("report", [42]); the C dispatcher
recovers the context from wrenGetUserData, marshals the argument list, and dispatches
through calogCall. A Wren function crossing out is a retained WrenHandle, invoked with
a cached per-arity call(_) handle. Wren numbers are IEEE doubles, so int64 above
2^53 loses precision (the same edge my-basic and old-JS have). An aggregate crossing out
is a Wren List, or a Map when keyed (a record reads as user["name"]); reading a
script's list/map back in is a v1 limit -- Wren's C API cannot enumerate a Map's keys.
Wren keeps no process-global state, so contexts run in parallel.
Aggregate egress is therefore uniform across all seven engines: a host native can return a
keyed CalogValueT record and every engine reads its fields with native syntax
(user.name / user['name'] / user["name"] / (user "name")). The reverse -- a script
handing a list/map back to C -- is complete on Lua/JS/Squirrel/my-basic and a v1 limit on
Berry/s7/Wren.
Verified across all seven engines: make test (531 checks, including a materialized-
record read per new engine), ASan/UBSan clean, a tsan<engine> target clean for each, and
gcc + clang strict on the core.
18. Closing the v1 marshalling limits (function-into-script, aggregate ingress)
The engines above shipped with two directional gaps in the value bridge: a foreign function value could not be pushed into a script (only Lua did it), and a script could not hand a keyed aggregate (map) back to C on the three newest engines. Both are now closed everywhere they can be, with one honest exception each.
New public API calogFnFromNative(out, calog, fn, userData) -- wraps one of your
natives as a host-owned function value (owner id 0, runs on the host thread during
calogPump, like calogRegister but anonymous). Without it, function-into-script was
unusable from calog.h alone (calogFnCreate is internal), so a host could only forward
a script-derived callable, never one of its own natives. Return the result from a native
and the script gets a callable that routes back to the host.
Function value -> script (each *FromValue calogFnE case): an engine callable
object wraps the CalogFnT*, a trampoline marshals the script's args -> calogFnInvoke
-> marshals the result, and a finalizer runs calogFnRelease; calogFnRetain at push.
Per engine:
- Lua (pre-existing): userdata +
__call+__gcmetatable -- the reference for the rest. - JS: a
JSClassDefwith both.calland.finalizer; the finalizer only receives the runtime, soJS_SetRuntimeOpaquecarries the context to it. - Squirrel: a native closure whose single free variable is a release-hooked userdata
holding the
CalogFnT(freeing the closure frees the userdata -> the hook releases). - s7: an applicable c-object (
s7_make_c_type+s7_c_type_set_reffor the call,s7_c_type_set_freefor release); thereffn gets(obj . args), so the object iss7_car. Script calls(f ...). - Berry: no per-value finalizer exists, so the
CalogFnTs pushed into a context are tracked on the context and released together incalogBerryDestroy; the callable is a native closure over(context, CalogFnT)comptr upvalues. (berryFromValuegained the context parameter so it could record them.) - Wren: a
foreign class CalogFn { construct new() {} foreign call(args) }whosecalltakes a list (Wren method arity is fixed, so a list absorbs any argument count); finalize releases. Script callsf.call([...]). Gotcha: Wren requires newlines between class members, so the preamble is multi-line. - MY-BASIC: a host callable becomes a refcounted usertype-ref (
mb_make_ref_value, whose dtor drops the retainedCalogFnT); a script invokes it withcalogInvoke(fn, ...args), which marshals the args, calls the callable, and marshals the result back. (A script also passes its owndef/lambda out to a native, invoked later viamb_eval_routine_cold; see sec 5.5.)
Aggregate ingress (*ToValue map/list): Lua/JS/Squirrel/MY-BASIC already read both.
Added:
- s7: read a hash-table by
s7_make_iterator+s7_iterate(each yields a(key . value)cons; gotcha: at ends7_iteratereturns a non-pair sentinel even when the at-end flag was still false, so guardif (!s7_is_pair(pair)) break). - Berry: a list/map instance's raw container is its hidden
.pmember; iterate withbe_pushiter/be_iter_hasnext/be_iter_next(which take the container index with the iterator kept on top, pushing one value for a list and key+value for a map -- restore the stack to[container, iterator]after each entry). - Wren: a
Listreads back directly. AMapneeds key enumeration, which upstream Wren's C API lacks (wrenGetMapValueis by-key only) -- so calog adds a small patch to the vendoredwren.c/wren.h(wrenGetMapCapacity/wrenGetMapEntry, mirroring Wren's own internalmap_iterate), and the adapter walks the raw table skipping empty slots. With the patch, Wren too reads maps back in. (Documented in LICENSE.md; re-apply if the amalgamation is regenerated.)
Closed in the fork since: MY-BASIC ints are now 64-bit (int_t widened to long long
in vendor/ourbasic, so calog's full int64 range round-trips; the old >2^31 clamp is gone), and
function-into-script works on MY-BASIC too (a host callable enters as a usertype-ref, invoked with
calogInvoke(fn, ...) or a bare name).
Deliberately not "fixed" (inherent to the engine's value model): MY-BASIC NUL-in-string
truncation (its strings are char*/strlen, so binary data is cut at the first NUL -- this is
what stops MY-BASIC from hosting the WebSocket/binary httpd script the way the 8 binary-safe engines
do; fixing it means a length-carrying string type + every string builtin, a separate project) and
serialize-at-load, and JS/Wren int64 above 2^53 (IEEE doubles -- JS could
emit a BigInt but that breaks arithmetic mixing with Number, a worse trap than the
documented precision edge). WREN_MAX_CALL_ARITY (16) is pinned to Wren's own engine
limit (MAX_PARAMETERS) and can't be raised. MB_BANK_SIZE (the MY-BASIC native cap) was
32 -- the one hard cap a real app could hit, since MY-BASIC natives can't carry userdata
so each needs a hand-materialized slot trampoline; it is now 256 (the trampoline bank
and its [MB_BANK_SIZE] table are regenerated together, so a count mismatch fails to
compile). Berry's BE_BYTES_MAX_SIZE was likewise raised from 32 kb to 256 MB.
Verified: make test (539 checks) with a testForeignFunction per engine and a
testMapIngress on s7 and Berry (the Berry one nests a list to exercise list ingress);
ASan-clean on every engine (retain/release balanced); gcc strict; per-engine tsan*.
19. mruby -- the eighth engine (Ruby)
mruby (.rb, calogMrubyEngine) is the first engine whose static library is not compiled from
vendored .c but generated by the engine's own build: mruby 4.0.0's Rake build (needs a host
Ruby + bison) emits libmruby.a from a trimmed embed gembox -- stdlib + math + metaprog (which
carries mruby-compiler, i.e. runtime eval) plus mruby-bin-mrbc as a build-time tool to
compile the Ruby-written stdlib into the archive; no bins, no file/socket IO. MRB_INT64 keeps
script integers full 64-bit. The Makefile runs the Rake build as a prerequisite of the adapter
object, since mruby emits a per-build mruby/presym.h the adapter must include. Linked footprint is
~1.1 MB (QuickJS class -- not the CPython-scale bloat the feasibility study found real Ruby drags in).
Dispatch resolves two mruby facts. mrb_func_t carries no user data, so every native binds one
shared trampoline that recovers which native was called from the current method id (mrb_get_mid
-> mrb_sym_name); the context rides on mrb->ud (mrb_state's spare auxiliary pointer --
script-invisible, no global). Natives are public Kernel methods, so cryptoUuid() works bare.
Callables cross both ways with real Ruby semantics. A Ruby proc/lambda crossing out is GC-pinned
with mrb_gc_register behind CalogFnT, invoked with mrb_funcall_argv(..., "call", ...) (so a
Proc, a lambda, or a Method all work). A foreign CalogFnT crossing in becomes a genuine Ruby
Proc via mrb_proc_new_cfunc_with_env (the CalogFnT travels in the proc's cfunc env as a
cptr), so a script calls it f.call(x) / f.(x) and can pass it as a block -- no .call-method
wrapper like Wren, no calogInvoke helper like my-basic. As with Berry, that cfunc env has no
per-value finalizer, so foreign callables are tracked on the context and released at destroy;
marshalling is bracketed by mrb_gc_arena_save/restore so transient objects don't overflow the
arena.
Aggregates are Array <-> list and Hash <-> map (a keyed record's sequence part goes at integer
keys); strings are binary-safe (mrb_str_new carries a length). Bare-name exports resolve via
Kernel#method_missing: a top-level exportedFn(args) that is not a defined method is looked up in
the export registry and invoked -- but only when self is the main object (mrb_obj_equal against
mrb_top_self), so a missing method on any other receiver (obj.foo) stays a plain NoMethodError
and is never hijacked. This puts Ruby among the hook engines without a VM change -- its own
method_missing is the hook my-basic needed a fork to fake.
IO by design. Ruby's File/IO/Socket are left out (no mruby-io): scripts use calog's
fs*/net*/http* natives like every other engine, so all IO stays on one path. A partial File
shim would footgun (whole-file ops map to calog's fs, but the streaming object model does not), so
it is all-or-nothing and deferred; the adapter supplies only puts/print/p (to stdout).
mruby keeps no shared mutable process state -- one independent mrb_state per mrb_open, no GIL --
so contexts run in parallel, the concurrency gate mruby was chosen on (over MRI/CRuby and MicroPython,
which both fail it). Its Rake-built archive links un-sanitized, so tsanmruby instruments the
adapter/engine/actor code (like tsanlibs with Lua); each VM is single-threaded per instance.
Verified: testEngineMruby (13 checks -- natives, Hash egress/ingress, a foreign Proc, a
cross-thread lambda callback, the error handler, three concurrent mrb_states), ASan-clean;
tsanmruby clean; cross-engine both ways (Ruby calling a Lua export, Lua invoking a Ruby lambda
export); make test 29/29.
20. Tcl -- the ninth engine (Tool Command Language)
Tcl (.tcl, calogTclEngine) is the second engine whose static library is produced by the
engine's own build rather than compiled from vendored .c: Tcl 9.0.4's autoconf (configure --disable-shared, out of tree into vendor/tcl/build/) emits libtcl9.0.a with no host deps beyond
a C toolchain -- Tcl bundles its own zlib fallback and libtommath. Two build facts mattered: make libtcl9.0.a pulls in the zipfs library-embed step, so vendor/tcl/library/ must be present
(over-trimming it broke the build); and calog never calls Tcl_Init, because the whole core language
(proc/if/expr/string/list/dict/...) is C built-ins registered by Tcl_CreateInterp -- so
the on-disk script library (init.tcl) is not needed. Linked footprint is ~2 MB.
Concurrency is the reason Tcl was chosen: its apartment model -- one Tcl_Interp per thread, no
global interpreter lock -- IS calog's actor model. Each context creates its interp on its own thread
and never shares it; the one process-global init (Tcl_FindExecutable + caching the internal-rep type
pointers) runs once under pthread_once, and each context thread runs Tcl_FinalizeThread at
teardown. Natives are Tcl commands bound to one trampoline (the name is objv[0], the context rides
on per-interp assoc data), so cryptoUuid is callable bare.
The value model is the interesting part. Tcl is "everything is a string", so egress cannot read the
canonical type off a bare value: tclToValue dispatches on the Tcl_Obj internal representation
(typePtr, cached by minting a sample of each -- Tcl_GetObjType returns NULL for "int"/"bytearray"
in 9.0) for int64 (Tcl_WideInt), double, Tcl list <-> list, and Tcl dict <-> map. For a value with a
string (or no) rep it applies Tcl's own coercion -- a pure integer/real string becomes a number, so
enetHost 8080 works -- otherwise a string. The documented EIAS losses: nil has no Tcl analogue and
round-trips to the empty string, and bool round-trips to 0/1. Binary-safe strings map to byte-array
objects (Tcl_NewByteArrayObj), since Tcl's string rep is UTF-8, not NUL-safe.
Callbacks cross both ways on commands, with no custom Tcl_ObjType. A foreign CalogFnT (in
tclFromValue) or a Tcl command prefix wrapped by the adapter's calogCallback command both become a
calogFn<N> command whose objProc is tclForeignCmd and whose clientData is the CalogFnT (released
by the command's delete proc -- Tcl commands carry per-registration data, unlike mruby's mrb_func_t).
The command NAME is the value the script holds -- it calls $fn a b -- and tclToValue recognizes
such a name (Tcl_GetCommandInfo -> objProc == tclForeignCmd) to marshal it back OUT as a function
value, so a Tcl callback reaches psSubscribe/timerAfter/calogExport. Bare-name exports work via
Tcl's own unknown handler (also normally from init.tcl): the adapter defines it to resolve an
undefined command against the export registry and invoke it -- and because Tcl has no receiver-method
syntax, unknown firing always means a genuinely-missing command, so no guard is needed (unlike Ruby's
method_missing). puts/print write to stdout directly (Tcl's channel puts needs the IO subsystem
calog does not init); there is no file/socket IO -- scripts use calog's fs*/net* natives.
Verified: testEngineTcl (13 checks -- natives, dict egress/ingress with a nested list, a foreign
command callable, a cross-thread Tcl callback via calogCallback, the error handler, three concurrent
interpreters), ASan-clean; tsantcl clean; bare-name exports and cross-engine both ways; make test
30/30.
21. Janet -- the tenth engine (a Lisp)
Janet (.janet, calogJanetEngine) is the tenth engine and returns to the single-file amalgamation
pattern of s7 and Wren rather than the engine-run build of mruby and Tcl: Janet 1.41.2 ships as one
janet.c (plus janet.h and janetconf.h), compiled at -O2 with -DJANET_NO_NET,
-DJANET_NO_PROCESSES, and -DJANET_NO_DYNAMIC_MODULES -- the language core only, with the net,
subprocess, and dynamic-module subsystems trimmed out, so scripts do IO through calog's
fs*/net*/http* natives like every other engine. The amalgamation compiles to a single object
(janet.o, ~4.8 MB unstripped at -O2, dead-stripped at link).
Concurrency is again the qualifying property. Janet's entire runtime state is a JANET_THREAD_LOCAL janet_vm (__thread on gcc), so each context runs janet_init/janet_deinit on its own thread and N
contexts are N fully independent VMs that share nothing -- calog's actor model with no adaptation.
tsanjanet is the GATE-A race check: N thread-local VMs on N threads must never alias.
Values marshal both ways through CalogValueT. Janet numbers are IEEE doubles with no separate
script-level integer type worth preserving past 2^53, so calog ints cross as janet_wrap_number and
egress through the canonical double classifier (an exact integer becomes an int, else a real) -- the same
2^53 ceiling as Lua/JS/Wren. nil round-trips. Strings are the bright spot: Janet strings are
length-prefixed, so egress reads janet_string_length and a JANET_BUFFER reads its ->count --
binary-safe, embedded NUL bytes and all, where Tcl fell back to byte-arrays and my-basic cannot do it at
all yet. Symbols and keywords also egress as strings. Aggregates map JanetArray and JanetTuple ->
list, and JanetTable and JanetStruct -> map (a keyed record's integer-keyed part lands at numeric
table keys).
Dispatch is the Janet-specific twist. A JanetCFunction carries no user data, so a bare cfunction
cannot recover which native it backs -- so calog does not use cfunctions at all. Every exposed native is
instead a Janet abstract value (gNativeType) whose JanetAbstractType.call handler dispatches through
calogCall by the name kept in the abstract's payload; it is janet_def'd into the core env under that
name, so cryptoUuid is callable bare, and the context rides in the payload with no global. At VM
creation the engine walks the broker registry (calogForEach) and installs every entry this way. The
abstracts are deliberately not registered with janet_register_abstract_type -- calling and GC need no
registration, and the type's pointer identity is enough to recognize calog's own values on the way back
out.
Callables cross both ways on that same abstract mechanism. A foreign CalogFnT pushed in becomes a
gForeignType abstract whose .call invokes calogFnInvoke (the fn is calogFnRetain'd and released
by the abstract's .gc at collection), so a script calls it like any function. A Janet function handed
out is wrapped as a CalogFnT and the JanetFunction is janet_gcroot'd so the collector keeps it
alive until the callable's last reference drops (janetScriptRelease unroots it, on the owner thread).
A value coming back that is already one of our gForeignType abstracts is unwrapped straight to its
CalogFnT by pointer-identity on the type, so a callback round-trips with no wrapper layer -- reaching
psSubscribe/timerAfter/calogExport like the rest. The error model is fiber-native: a failed native
or marshal calls janet_panic (a longjmp back to the nearest fiber), and because it never returns each
handler frees the heap CalogValueT arguments it owns and copies any message into a stack buffer before
panicking. Janet collects only at VM instruction boundaries, so C-side marshalling never races the GC;
the one value that must outlive a VM call -- a function crossing out -- is the one explicitly rooted.
The honest limit is bare-name exports. Janet is not a hook engine: it resolves symbols at compile
time and offers no runtime unbound-symbol hook, unlike Tcl's unknown, s7's *unbound-variable-hook*,
Ruby's method_missing, or the fork my-basic needed. Natives -- and any exports already registered when
the VM opens -- are installed as real janet_def bindings and so ARE callable bare; but a function
exported by another engine AFTER this VM opened cannot be reached by bare name, and is called through the
calogCall native ((calogCall "name" ...args)) instead.
Verified: testEngineJanet (14 checks -- a host native receiving its argument, a default native on
the host thread versus an inline native on the script's own thread, int and binary-safe string fields
read both from a materialized record table and from a table the script built, a foreign function value
invoked from the script, a Janet function captured as a CalogFnT and invoked cross-thread back to its
owner, the error handler naming the failing context, and three concurrent thread-local VMs all
dispatching to the host), ASan-clean; tsanjanet clean; cross-engine both ways; the full make test
suite (38/38) passes with Janet included.
22. my-basic binary data -- the byte-buffer type (closing the last string limit)
my-basic was the one engine whose strings could not carry a calog string with embedded NUL bytes: its
MB_DT_STRING is a NUL-terminated C char* with no length, and section 2.5 recorded the loss as "embedded
NUL truncates". Rather than rewrite the ~19.6k-line vendored interpreter's string, pool, comparison, and
UTF-8 builtin paths -- a high-risk change whose only clean form is full, and which would force a
bytes-vs-codepoints decision on LEN/MID -- calog adds a distinct byte-buffer type and leaves the
string path untouched. Binary data and text are different things (the Python-3 str/bytes split), and
my-basic's own extension protocol makes the byte type almost entirely an adapter concern.
The type is an MB_DT_USERTYPE_REF (the refcounted usertype-ref my-basic already offers, enabled in this
build) whose payload is { uint8_t* data; size_t length; } -- length-carrying and NUL-safe. The VM
already dispatches the ref's hooks, so the whole type lives in mybasicAdapter.c: a dtor frees the buffer,
a clone deep-copies it, a hash (FNV-1a) and a cmp (memcmp plus a length tiebreak) make it a
content-addressed dict key, a fmt renders bytes[N] for PRINT, and an MB_MF_ADD meta-operator override
makes + concatenate byte buffers -- and string + bytes, since _core_add consults the meta-operator
before its string path. _clone_usertype_ref copies the operator table, so + survives assignment. The
calog boundary picks the representation by content: a calog string that contains an embedded NUL ingresses
as a byte buffer (a NUL-bearing blob is not a valid my-basic C-string); a NUL-free string stays an ordinary
my-basic string, so existing text scripts are wholly unaffected. On egress a byte buffer becomes a
length-carrying calog string, faithful to the last byte. Scripts get byteLen, byteAt, byteSlice,
byteConcat (which also accepts strings, for building a response from text headers and a binary body),
and strToByte/byteToStr; + concatenation and =/<> content comparison work as operators.
One vendored change was required -- a fourth, small and guarded, patch to the calog fork.
_instruct_obj_op_obj, the operator behind = <> < > <= >=, compared two same-type values by
their raw representation (pointer identity for a usertype-ref). It now routes through a helper that uses
the ref's cmp hook when one is present and falls back to the historical raw compare otherwise, so byte
buffers compare by content while every other type is unchanged (a NULL cmp -- e.g. a foreign-fn ref --
keeps identity). The adapter also gained two ownership fixes, both surfaced by ASan and an adversarial
review. First, a popped usertype-ref handed to a native was not released by the marshalling argument loop
(only LIST/DICT were), so any byte buffer -- or foreign callable -- passed as a native argument
leaked; the loop now disposes usertype-refs too. Second, the byte natives disposed a popped argument
unconditionally on their type-error paths -- but a popped my-basic string is a borrowed interior
pointer (only a collection, routine, or usertype-ref is an owned reference), so byteLen("abc") would
double-free it; a single mbDisposePopped helper now encodes the own-vs-borrow rule at every site. The
byte payload carries a tag so egress and the compare hook can tell a byte buffer from the adapter's other
usertype-ref (a foreign callable) and never misread one as the other.
Verified: testEngineMyBasic grew from 8 to 20 checks -- egress of a 3-byte a\0b blob at full
length, byteLen/byteAt reading the embedded NUL, + concatenation preserving an interior NUL, a text
prefix concatenated with a byte body, = comparing byte buffers by content (equal, and differing only
past the NUL), a byte buffer used as a content-addressed dict key, byteSlice across the NUL, byteConcat
joining buffers and strings, an egress/ingress round-trip through a native, and a strToByte/byteToStr
text round-trip -- ASan+UBSan-clean, tsanmb clean, and the byte-native type-error paths driven under
ASan (no double-free of a borrowed string, no leak of a refused collection). make test green.
23. my-basic sandbox parity -- memory cap, time budget, and INPUT
Section 2.5 and testSandbox recorded that per-context memory and wall-clock limits applied to Lua and
QuickJS only -- their allocators/interrupts take per-state userdata, while the other engines (my-basic
included) got only the engine-agnostic native allow-list. Since the fork is calog's own, my-basic now
enforces all three, entirely in the adapter plus one VM typedef.
Time budget. my-basic calls a per-statement hook (_prev_stepped) before every statement; the
adapter installs one (mb_debug_set_stepped_handler) for a limited context. The hook checks the
wall-clock deadline (every MB_STEP_TIME_CHECK statements, to amortise the clock read) and, once past it,
calls calogCurrentRetire() and returns an error that unwinds the run -- the my-basic analogue of Lua's
instruction-count luaTimeHook. Unlimited contexts install no hook, so the common path keeps its
per-statement cost at zero.
Memory cap. This one needed care. my-basic's memory manager (mb_set_memory_manager) is
process-global and receives only a size, and mb_malloc mb_asserts that allocation never fails -- so an
allocator that returned NULL on over-budget would crash, not error (the very reason memory caps were
"Lua/QuickJS only"). Instead the adapter wraps the global allocator with a COUNTING one that never
refuses: every allocation carries a small header recording its size and owning context, charged through a
thread-local pointer to the running context's limit state (each context runs create/run/destroy on one
dedicated thread, so the thread-local is exact and memUsed needs no atomics), and the cap is enforced at
the next statement boundary by the same step hook. The bound is therefore statement-granular -- a single
statement can transiently overshoot before the next boundary retires the context -- rather than Lua's
exact-allocation, but the outcome is the same: an over-budget context is retired with its error at the
handler. The allocator is installed once, before the first mb_init, so its header is present on every
my-basic allocation uniformly (an unlimited context is simply never charged).
A latent crash fixed on the way (fork patch #5). The alloc-stat size tag mb_mem_tag_t was
unsigned short, and mb_malloc returns NULL -- which the caller then dereferences -- for any size that
does not fit the tag. A single allocation over 65535 bytes (a >64 KB string or array) crashed the host: a
sandbox escape worse than any missing limit, and independent of the new cap. Widening the tag to 64-bit
removes it -- together with the memory-manager callback's size parameter, which was still unsigned: an
adversarial review caught that the tag widening alone was incomplete, since mb_malloc passes size + tag through that unsigned param, so a >4 GiB request would truncate into a tiny buffer the caller then
filled at full size (a heap overflow instead of the old NULL-crash). The memory-cap test (doubling a
string past 2 MiB) drives this path.
INPUT. my-basic's INPUT fell back to mb_gets -> fgets(stdin), which would block the context
thread and read host input. The adapter installs an inputer that yields an empty line, so I/O stays on
calog's natives like every other engine.
Verified: testSandbox gained the my-basic trio alongside the existing Lua/JS cases -- the allow-list
denies a forbidden native, a runaway WHILE 1 loop is retired on its wall-clock budget, and a string
doubling past a 2 MiB cap is retired -- ASan+UBSan-clean, tsanmb clean, and driven end-to-end through
bin/calog (INPUT yields empty without reading stdin; a 160 KB string builds without crashing).
24. Sandbox parity across the remaining engines -- memory cap + wall-clock budget
Section 23 brought my-basic to sandbox parity with Lua and QuickJS. This section closes the rest of the gap: the memory cap and the wall-clock budget (the allow-list was always engine-agnostic) now hold for nine of the ten engines -- Lua, JavaScript, my-basic, Berry, Tcl, mruby, Squirrel, Wren, and Janet. Only s7 remains allow-list-only, by deliberate omission (below).
A per-VM feasibility audit (grounded in each vendored VM's source, then adversarially re-checked)
found that none of the seven allow-list-only engines was impossible, but the cost and mechanism
differ per VM. Each engine's calogXCreate gained a CalogLimitStateT *limits parameter (threaded
from calogContextLimitState), and each enforces both limits by the cleanest mechanism its VM
allows. An unlimited context installs nothing and pays ~zero overhead.
| Engine | Memory cap | Wall-clock budget | Bound granularity |
|---|---|---|---|
| Berry | be_realloc refuses a grow past the cap (GC-retry then be_throw) |
instruction heartbeat hook (be_set_obs_hook) -> be_raise |
exact (refuses) |
| mruby | override the global mrb_basic_alloc_func (refuse -> clean NoMemoryError) |
per-instruction code_fetch_hook (MRB_USE_DEBUG_HOOK) |
exact (refuses) |
| Tcl | count in the zippy allocator; a Tcl async unwinds when over cap | built-in Tcl_LimitSetTime + a monotonic-authoritative handler |
async-granular |
| Squirrel | counting sq_vm_*; the Execute loop unwinds on a tripped flag |
Execute-loop deadline poll -> Raise_Error+SQ_THROW |
per-instruction |
| Wren | the bytecode loop checks the VM's own bytesAllocated vs the cap |
same loop back-jump checks the deadline -> RUNTIME_ERROR |
per-loop-iteration |
| Janet | header allocator interrupts the VM on an over-cap allocation | out-of-band watchdog thread -> janet_interpreter_interrupt |
per-allocation |
Cross-cutting design points:
-
Resolving the running context. Several VMs allocate through a process-global allocator (my-basic, Squirrel, mruby, Janet) or a global one with no calog pointer in the loop (Tcl). The running context is resolved through a
_Thread_locallimit-state pointer set on the context's dedicated thread (each context = one OS thread), the pattern established by my-basic'sgMbLimits. Where the VM passes exact sizes to free/realloc (Tcl, Squirrel) no per-block header is needed; where it does not (mruby, Janet) a{size, owner}header is prepended, and storing the owner in the header means a block allocated before the cap was armed (the base runtime) is never mis-uncharged --memUsedcannot drift negative from base-runtime frees. -
Refuse vs count-and-interrupt. An allocator may refuse (return NULL) only where the VM turns NULL into a clean, catchable error: Berry (
be_throwafter a GC retry) and mruby (mrb_raise_nomemory). Squirrel and Tcl deref/assert on a NULL allocation, and Janetexit(1)s, so those never refuse -- they count and let the enforcement point (the Execute loop, a Tcl async, or a VM interrupt) unwind. Count-and-interrupt is therefore allocation-or-loop granular: a single operation can transiently overshoot before the next check, exactly as my-basic's statement-granular cap (sec 23). -
The exponential-doubling trap. A
s = s + sloop grows memory exponentially, so a coarse instruction-count poll OOMs the host between polls. The check must sit at the allocation itself (Janet, or via the trip flag Squirrel's allocator sets and its loop reads every instruction) or at each loop back-jump (Wren), bounding the overshoot to one iteration. -
Re-entrancy. The unwind path itself allocates (building the error object) while still over cap, which can re-trigger the limit. Tcl hit this concretely:
Tcl_SetObjResultin the async handler re-marked the async, andTcl_AsyncInvoke's retry loop re-invoked the handler forever. The fix is a re-entrancy guard (gTclInMemAsync) so charging does not re-arm while the handler runs; Squirrel's loop re-verifies the cap so a transient freed within one opcode does not retire the context. -
Janet's watchdog. Janet alone has no in-VM periodic hook that fires inside a tight loop, so the wall-clock budget is enforced by a per-context watchdog thread that sleeps to the deadline then calls
janet_interpreter_interrupt(cross-thread-safe: it only touches an atomic). The watchdog readsdeadlineMs/janetVm, both set beforepthread_create(happens-before), and is stopped and joined before the VM is torn down. Memory still needs the allocator (a 5 ms watchdog poll cannot bound an exponential loop), so the counting allocator interrupts on an over-cap allocation.
s7 -- the documented remaining limit. s7's Malloc/Calloc/Realloc are unchecked macros with no
allocator hook (a single large allocation deref-crashes), its only heap control is a cell-count
(*s7* 'max-heap-size) (not byte-accurate, and it does not stop a single big allocation), and its
begin_hook does not fire inside s7's optimizer-collapsed loops -- the sandbox-critical case. Both
limits would require large, invasive surgery to the s7 interpreter. Per the project's
prefer-omission-over-partial rule, s7 stays allow-list-only and is documented as such
(calog.h CalogLimitsT) rather than shipped with a footgunny partial cap.
Verified: testSandbox grew from the Lua/JS/my-basic cases to cover all nine enforcing engines
(three checks each -- allow-list denial, a runaway loop retired on the wall-clock budget, and a memory
bomb retired), 43 checks / 0 failed under ASan+UBSan; each engine's ThreadSanitizer target
(tsanberry/tsantcl/tsanmruby/tsansq/tsanwren/tsanjanet) clean, plus a dedicated
ThreadSanitizer harness for Janet's watchdog (four concurrent limited contexts, race-free); vendored
patches are marked in place (// --- calog patch ... ---) with no separate backup, matching the
existing wren.c convention. make test = 846 checks / 0 failed across 38 binaries.
Adversarial review + accepted limitations. A per-engine review of the six patches (each finding
independently re-verified against the code) drove several hardening fixes: Janet's os/realpath frees
a libc-malloc'd buffer, which the allocator macros mis-routed to the header free (an out-of-bounds
read, proven under ASan) -- now a raw free; Berry's cap check could be_throw while vm->errjmp
is NULL (the adapter marshalling a callback's args or reading an error string between be_pcalls),
turning a cap trip into an abort -- now gated on errjmp != NULL, enforcing only while a script is
executing; Wren's loop-only check missed unbounded recursion (which executes no LOOP), so the check
also runs after each completed call; Janet's callback path now retires on an interrupt like the eval
path; and the Tcl cap now charges the bulk Tcl_Obj pool chunk (individual objects come from a pool
that bypassed the per-block charge). Two limitations are accepted and documented rather than fixed:
- A script that deliberately catches the sandbox error and loops defeats the wall-clock budget and
pins its context thread -- the retire is cooperative (serviced after the eval returns), and a
caught error never returns. This is a property of the cooperative model, not a given engine: the
Lua reference behaves identically (
while true do pcall(function() while true do end end) endis not retired). A real fix is preemptive thread termination, a model-level change out of scope here. A merely runaway script (no catch) IS retired on every engine. - A single bytecode op that allocates O(script-integer) -- e.g. Squirrel
array(n), WrenList.filled(n)-- overshoots the cap by that one allocation before the next check fires, and a request larger than host RAM crashes the VM (Squirrel/Wren/Janet do not NULL-check allocations, and Janet'sJANET_OUT_OF_MEMORYisexit(1)). This is the allocation-granular bound already noted, the same class as my-basic's statement-granular cap; the host-OOM crash is inherent to embedding these VMs and predates the cap.
25. calogExit that does not return -- the runtime abort latch
API.md had always said of the runner's calogExit([code]): "Does not return." It did. The native
set two atomics (gExitCode, gShutdown) and handed control straight back to the script, which ran
on to the end of its chunk while the host pump loop was still one 0.5 ms tick away from noticing. Two
consequences, both silent:
- Work after the exit still happened.
if (failed) { calogExit(1) } ... deploy()deployed. - A later exit overwrote an earlier one. The last
calogExitto run named the exit code, so a script ending incalogExit(0)reported success no matter which failure had asked for1first.
That is a footgun for exactly the use calog is good at -- a build/CI script in whichever language suits the job -- so the fix restores the documented semantics rather than documenting the behavior.
The mechanism already existed
Every adapter turns a native that returns non-zero into an engine-level raise (lua_error,
JS_Throw, sq_throwerror, be_raise, s7_error, wrenAbortFiber, mrb_exc_raise, TCL_ERROR,
janet_panic, MB_FUNC_ERR). That is how the sandbox's wall-clock hook already stops a runaway
script (sec 24). So a native CAN unwind its caller: it only has to return an error and be sure
nothing reports it as a failure. calogAbortAll is that, made explicit:
int32_t calogAbortAll(CalogT *calog, CalogValueT *result); // latch the runtime, return calogErrAbortE
bool calogAborting(CalogT *calog); // true once the caller has been stopped
nativeCalogExit records the code and returns calogAbortAll(...). The engine raises it, the script
unwinds out of its chunk at the call site, and the statement after calogExit never runs. The native
must be registered with calogRegisterInline for this to work at all -- an abort raised on the host
thread could not unwind a script on another thread.
One latch, runtime-wide
The obvious design -- abort only the calling context -- needs cross-context propagation, because the
error status is flattened into a string every time it crosses an engine boundary (script A calls
script B's export, B calls calogExit; A only ever sees B's message). A single _Atomic bool on
CalogT, checked at the two dispatch choke points every script call already funnels through, is both
simpler and stronger:
| choke point | what it refuses once latched |
|---|---|
calogCall (broker.c) |
every native, from every context and every engine -- including calogExit itself |
calogFnInvoke (value.c) |
every script function value: a queued timer or pubsub callback never starts a script body |
So the abort needs no propagation: whichever context is next to touch C is stopped there, and a
script that catches the unwind (pcall, try, catch) cannot call a native or invoke a callable
afterwards -- it can only spin until the host joins it. The latch is per-runtime, so independent
CalogTs in one process are unaffected, and it is one-way: a latched runtime is a tearing-down
runtime.
An aborted script did not fail
The unwind reaches each adapter's run function as an ordinary engine error, which would print a
diagnostic and report a script error -- two spurious lines for a perfectly normal calogExit(3). So
every adapter's RUN-failure branch (never its compile branch: a syntax error cannot be an abort) does
its usual cleanup and returns calogOkE when calogAborting(context->broker). Returning ok is what
keeps contextDispatchEval from posting an error, so the error handler never sees it and the runner
never marks that context failed.
Janet is the one engine that needed more. Alone among the ten it reports the failure itself, from
inside janet_dobytes (error line plus stack trace, through janet_eprintf), before the adapter can
decide anything. janet_eprintf honors the err dynamic binding, so the adapter binds a capture
buffer for the duration of the run and relays it to stderr verbatim only for a real failure. The
binding has to go in the TOP dyn table -- janet_dobytes prints after janet_continue has returned,
when no fiber is current -- and Janet does not mark that table, so the buffer and the :err keyword
are janet_gcrooted by hand for the run. A script's own (eprint ...) is untouched: inside a fiber
the lookup finds the context env's bindings, not the top table.
Exit-code precedence in the runner
gExitRequested latches the first request of any kind -- a calogExit, a signal, or a script
erroring out -- and everything after it is ignored. First-writer-wins is the only rule under which a
failure cannot be masked: a later calogExit(0), from the same script or a sibling, no longer clears
an error that already happened, and an error thrown up during teardown no longer overwrites the code
a script deliberately asked for. A second calogExit never even reaches the native (the abort latch
refuses it), and a failed launch reports 1 without ending the run for the scripts that did launch.
"First" means first observed, and a script error is observed when it reaches the host thread. So in
a multi-script run, a sibling erroring at the same moment one script calls calogExit(0) is a race
-- as everything else between concurrently running scripts is. Within one script there is no race at
all, which is the case that matters: its own calogExit(1) unwinds it, so no later line of that
script, calogExit(0) included, can ever run.
Teardown order stays the host's
calogAbortAll deliberately does not close the calling context. Teardown order is the host's --
calogDestroy releases the cross-context reference holders while every context is still alive, and
only then joins them (that is why calogTimer registers its shutdown calogDestroyBeforeContextsE).
A context that retired itself from inside the abort would destroy its own interpreter first,
stranding the callables those registries still hold: harmless for a VM that frees everything on
close, fatal for QuickJS, which asserts it owns no live objects at JS_FreeRuntime. Stopping the
scripts is the latch's job and needs no context to close.
Accepted limitations
- A script that deliberately catches the unwind is not preempted between hook ticks -- the same cooperative-model limit as the sandbox (sec 24). It is stopped at its next native call or its engine's next interpreter hook, whichever comes first.
Two limitations listed here originally -- that a signal could not abort scripts, and that four engines swallowed a syntax error along with the abort -- were closed later; see sec 29.
Test
tests/testExit.c runs, on all ten engines, mark(); stopAll(); <loop forever>. The loop is the
evidence: native calls cannot prove the point (the latch refuses them whether or not the script
unwound), but a script that kept running would pin its context thread, so the check is that the
context's thread has exited within the pump budget -- the test's own native adds a
calogCurrentRetire, serviced only once an eval returns, to make "the eval returned" observable.
The same run asserts the abort reached no error
handler, that the runtime stays latched, and that a later calogCall is refused with
calogErrAbortE without reaching the native. A final case proves the latch is per-runtime.
26. Reclaiming a context's callables -- and ending one script
A CalogFnT is a handle to a function living inside one VM. Section 25 made calogExit unwind
scripts; this section fixes the class of bug that made the next question -- "how does one script
end itself?" -- unsafe to answer.
The bug: a handle outliving its VM
threadMain destroys a context's interpreter as that context's thread exits. Anything still holding
a CalogFnT owned by that context is then holding a handle into a VM that no longer exists, and the
engine-side release never runs: calogFnFinalize sees the owner is gone and frees only the shell.
Most VMs hide it -- they free everything on close -- but QuickJS asserts it owns no live objects and
aborts the process:
calog: vendor/quickjs/quickjs.c:2682: JS_FreeRuntime: Assertion `list_empty(&rt->gc_obj_list)' failed.
Three separate holders reproduce it, all deterministically:
| holder | reproducer (JavaScript, because QuickJS is the VM that checks) |
|---|---|
| a library registry | psSubscribe('t', function(){}); then exit, error, or taskExit |
| another engine | calogCall('keep', function(){}) into a Lua script that stores it, then die |
| an in-flight invoke | a delivery holding the last reference across the owner's teardown |
The first instinct -- move the pubsub and export destroy hooks from calogDestroyAfterContextsE to
calogDestroyBeforeContextsE, as calogTimer already does -- fixes only the whole-runtime teardown
path, and only for the last runtime in the process. It does nothing for a context that dies while the
runtime lives on, which is the common case: a script that errors after subscribing, taskExit, and
or taskExit. It also cannot reach a value another VM is holding, where no registry is involved.
The fix: the owner reclaims, on its own thread
The rule that actually holds is a context releases every engine handle it owns before its
interpreter goes away, and only the owner's own thread can do that. So each context now lists the
callables it creates (calogContextTrackFn / calogContextUntrackFn, guarded by that context's
queue mutex, locked in the runtime's established ctxMutex -> queueMutex order), and threadMain
sweeps the list after the context hooks and before interpDead / destroyInterpreter:
contextReclaimCallables(context); // per callable: mark dead, run the engine release, clear the hook
calogFnReclaim marks the callable dead and runs the engine release while the VM is still alive,
then clears the hook -- so every later holder, whatever thread it is on and whenever it gets there,
finalizes an empty shell. An invoke of a reclaimed callable fails with calogErrDeadE, the same
answer the actor layer already gave for a context that is gone (which is what the timer library
cancels a timer on, and what calogFnMarkDead -- until now dead code with no callers -- was for).
The sweep takes the whole list under the lock before touching anything, and retains each entry across
the pass: an engine release can cascade (a VM finalizer dropping another of this context's callables)
straight back into calogContextUntrackFn, and must not find a list being walked or free an entry
the sweep has not reached.
This makes the destroy-phase question moot -- no phase changed -- because by the time any registry
releases its reference, the handle it names is already gone. Two smaller repairs came with it:
psSubscribe and calogExport now refuse to add to a registry that has already been drained (the
guard timerSchedule already had: gInitMutex held across the whole insert, in that lock order, so
it cannot straddle a shutdown), and calogActorShutdown destroys ctxMutex after the host-queue
drain rather than before, since a callable finalized by that drain resolves its owner under it.
Closing the registry before walking it
The same audit found a second teardown defect, unrelated to callables. calogActorShutdown walked
calog->ctxSlots and calog->ctxCount without holding ctxMutex -- and that walk is exactly
when scripts are still running and still free to call taskSpawn / taskLoad, each of which opens a
context and can realloc the slot array out from under the walk. A context registered after the walk
had passed its slot was then freed by the third loop with its thread still running, and that thread
outlived the slot array it was registered in.
calogContextOpen made it worse from the other side: it filled the slot before pthread_create
and published started after, so even a locked read could catch a context whose thread was already
running but whose started flag said otherwise -- skipped by both the shutdown-request and the join
loops.
Both halves close with one idea -- latch the registry, then walk it:
calogActorShutdownsetstearingDownunderctxMutexas its first act and readsctxCountin the same critical section. From that momentcalogContextOpenrefuses (ataskSpawnracing the teardown simply fails), so the slots it is about to walk are the complete and final set and the array can no longer move.- Every slot read in the walks takes
ctxMutex(contextAtIndex), because a script thread can still be unlinking a context of its own. pthread_createandstarted = truemoved inside the registration critical section, so a locked read sees either an empty slot or a slot whose thread exists. Nothing waits on the new thread while the lock is held, so starting it there cannot deadlock -- the new thread merely waits out the handful of instructions to the unlock if its own first act needs the registry.
tests/testHooks.c pins it deterministically: a per-context shutdown hook runs on the context's own
thread while calogDestroy is walking the registry, which is precisely the window, and opening a
context from inside that hook must be refused. tests/testTask.c adds the stress companion, a spawn
loop racing calogDestroy on its own runtime.
One closer per context
The latch fixes the registry, not the contexts in it. taskClose is an inline native, so a script
thread can call calogContextClose on a task at the same moment the teardown is stopping that very
context -- and pthread_join from two threads is undefined, quite apart from one of them freeing the
context the other is still inside. A registry lock cannot express that: the join must not be held
under a lock the joined thread may itself need.
What it needs is a claim. Each context carries a closing flag guarded by ctxMutex, and
calogContextClose takes it or returns:
if (broker->tearingDown || context->closing) { unlock; return; } /* someone else owns this one */
context->closing = true;
tearingDown is the teardown's claim on every context at once, so once the latch is up no new close
can start. The only case left is a close that was already running when the latch went up, and
calogActorShutdown waits for exactly those -- polling the slots it is about to walk until none is
flagged closing, since that closer unlinks the slot when it finishes. The latch guarantees the set
only shrinks, so the wait terminates; in practice it never runs a single iteration.
A script that calls taskClose during teardown simply gets a no-op: the context it named is stopped
and freed moments later by the teardown that already owns it.
tests/testTask.c races a loop of taskSpawn + taskClose against calogDestroy on its own
runtime -- clean under ASan across repeated runs, and under ThreadSanitizer with the Lua and
JavaScript engines linked.
Ending one script -- the API that was not needed either
With reclamation in place a context can safely end itself, so the core briefly gained
calogAbortCurrent: the same unwind as calogAbortAll, scoped to one context. It was removed
again in the same pass that removed calogEnd (see sec 29), for the same reason and with the same
evidence: nothing in the product ever called it. Its only caller was the test written to exercise it,
which is circular, and public API is far harder to withdraw once an embedder depends on it. Widening
calogAborting to answer "the runtime latch OR this context" went with it, so the question ten
adapters ask is once again the simple one.
The runner briefly exposed this as a script native, calogEnd([code]), and it was removed again
after measuring what it actually added. Two things had been conflated:
- Reaping a self-ended script -- the thing that was actually missing -- is a RUNNER fix, not a
new native. The pump loop only closed contexts flagged
failed, so a context that retired itself sat ingLaunchedwithliveCountnever dropping and the runner never exiting. Closing any context whose thread has finished is what fixed that, andtaskExit(which has always retired the calling context) inherits it for free: a lone script ending withtaskExit()now exits the run. - What was left once reaping worked was immediacy plus an optional exit code.
error(...)already ends one script with a failing status while its siblings continue, andcalogExit(code)names the status when you want everything to stop -- leavingcalogEnda third verb for the narrow slice of "a specific non-zero status from one script while the others carry on".
So the runner keeps two verbs and the library keeps its own:
| ends | process status | |
|---|---|---|
taskExit() |
this script (deferred -- the current chunk finishes) | unclaimed |
error(...) |
this script | 1 (the run did not fully succeed) |
calogExit([code]) |
everything, immediately | code (default 0) |
What ends one script is therefore taskExit (deferred, and the only mechanism that ships), and
tests/testTeardown.c drives the early-context-death reclaim path through calogCurrentRetire --
the same call taskExit makes -- rather than through an API nothing else used.
Test
tests/testTeardown.c uses JavaScript throughout, because on any other engine a stranded handle is
invisible -- there, surviving the teardown IS the assertion. It covers a subscriber, an export and a
timer callback left registered at calogDestroy; each of those whose script instead errors out
first; a script that ends itself; a closure handed to a Lua script
whose owner then dies (invoking it afterwards must fail cleanly, not reach into a destroyed VM); and
the drained-registry guards.
27. What the cross-builds caught -- three portability regressions
Sections 25 and 26 changed the actor core, so the Windows/macOS/musl cross-builds were re-run
afterwards. The core changes ported cleanly. Everything else the run turned up predated them, and had
been sitting there unnoticed for one structural reason worth stating plainly: the cross-builds are
not part of make test. Nothing else in the project can drift silently for weeks; these can, and
did. The habit that catches it is to run tools/crossBuild.sh after touching the core or a vendored
dependency -- it is a couple of minutes, and it is what surfaced all three of these.
An ABI-shaped define that only half the build knew about
tools/crossWinFull.sh stopped at mrubyAdapter.c: no member named 'code_fetch_hook' in 'struct mrb_state'. MRB_USE_DEBUG_HOOK enables the per-instruction hook the sandbox wall-clock
budget needs (sec 24) -- and it changes the layout of mrb_state. src/mruby/build_config.rb
sets it for the library and the Makefile passes it to the adapter compile, so the two agree natively.
The cross config generated by tools/crossDeps.sh set only MRB_INT64, and neither cross*Full.sh
passed the macro, so the cross libmruby.a and the adapter disagreed about the struct.
It had been broken since sec 24 landed. Failing loudly at compile time was the good outcome: had the member merely moved rather than vanished, this would have been a silent ABI mismatch in a shipped binary. The macro now lives in the generated cross config and in both full-CLI adapter compiles. The general rule it earns: a define that changes a vendored library's layout has to be set every place that compiles against that library, and the cross path is a place.
A libc that returns endptr somewhere else
testEngineMyBasic failed 13 of 20 checks on musl while passing under native gcc, native clang
-O2, and zig targeting glibc -- so the variable was musl, not the compiler. my-basic classified a
symbol as a number by asking whether strtoll/strtod had reached the string terminator:
strtoll("\n") strtoll("+")
glibc: consumed=0, endptr at start consumed=0, endptr at start
musl: consumed=1, endptr at NUL consumed=1, endptr at NUL
musl advances endptr past leading whitespace and a sign even when no conversion happens. So on
musl +, - (the operators) and \n (the statement separator) each classified as the integer 0:
every expression containing an operator failed to parse, and every numeric assignment failed to run,
both surfacing as "Operator expected". It reproduces in a pure my-basic program with no calog code at
all (x = 1 -> MB_FUNC_ERR on musl, MB_FUNC_OK on glibc), which is how it was pinned down. The
fork now rejects a consumed span that is nothing but whitespace and sign; vendor/ourbasic/CHANGELOG
has the detail. The lesson generalizes past my-basic: a failed strtol does not leave endptr
where you assume -- test what was consumed, not just where it stopped.
A dependency that grew under a script that had stopped looking
testNet failed to build on all four targets: libs/calogNet.c has included <openssl/bio.h> since
the tcp transport gained TLS, and tools/crossBuild.sh passed no OpenSSL flags. The test itself uses
no TLS -- only the library it links does -- so nothing was wrong with the coverage, just with the
link line. It now links the per-target OpenSSL that tools/crossDeps.sh already produced for
Windows and macOS, and that script gained a musl target for OpenSSL alone (the full musl CLI is
make static on Alpine, where the toolchain is already musl -- not a cross build). When a target's
OpenSSL has not been built, the case SKIPS with the command that produces it, and the summary counts
skips separately: a skip must never be able to read as a pass.
Where that leaves the ports
| Before | After | |
|---|---|---|
tools/crossBuild.sh |
7 ok, 4 failed | 11 ok, 0 failed |
musl testNet |
did not build | runs 10/10, fully static, matching the native run |
musl testEngineMyBasic |
13 of 20 failing | 20/20 |
| Full CLI: Windows PE, macOS x86_64 + arm64 | Windows did not build | all three build, carrying sec 25/26 |
The standing gap is unchanged and deliberate: Windows and macOS are build-verified only. Running those binaries needs a Windows/wine or Mac host, so "it links" is the strongest claim the evidence supports, and PORTING.md says exactly that rather than rounding it up.
28. Three latent edges, closed
None of these ever fired in a test. They were found by reading the code around sections 26 and 27 -- two in the actor core, one in the my-basic fork -- and each is the kind that stays quiet until the day it does not.
The release that could run on the wrong thread
actorReleaseCallable marshals a callable's finalize to its owner's thread, because the adapter's
release is an interpreter op (luaL_unref, JS_FreeValue, ...) and only the owner may touch that
interpreter. Its fallback did not hold that line:
if (contextPostRelease(runtime, owner, callable) != calogOkE) {
calogFnFinalize(callable); /* on THIS thread -- not the owner's */
}
contextPostRelease fails for two different reasons, and they are not equally harmless. If the owner
is gone, calogFnFinalize takes its "owner gone" branch and only frees memory -- fine. But it
also fails when the calloc for the message fails while the owner is alive, and then
calogContextRegistered returns true and the engine op runs from the wrong thread. That is memory
corruption inside a running VM, reached under memory pressure -- the moment a program is least able to
cope with it.
calogFnFinalizeForeign is the fallback now: untrack, free the adapter's block, free the shell, and
deliberately leak the handle inside the interpreter. A leak on an OOM path beats corrupting a VM
that is still running. The "owner gone" case is unchanged -- it already did exactly this.
One subtlety, caught in review before this shipped: it frees userData on exactly the condition
calogFnFinalize does -- only when a release hook exists. The hook is what makes userData calog's
to free (an adapter's single heap block, per the CalogReleaseFnT contract). A host-owned callable
from calogFnFromNative has no hook and a userData the embedder owns; freeing that would
corrupt their heap, which is worse than the corruption this function exists to prevent. And the leak
is worth stating honestly: the handle is pinned until that interpreter is destroyed, which for a
long-lived context is not "a moment".
The window where a queue accepted work nobody would serve
serveLoop returns when messageDequeue finds the queue empty and shuttingDown set. interpDead
-- which is what stops registryResolveLocked handing the context out -- is set later, after the
per-context hooks and the callable reclaim. Between those two points the context still resolved, so a
message enqueued there was accepted and never served: a fire-and-forget eval silently dropped, and
a blocking call left its sender waiting on a reply that could not come (the reply-box path waits on a
condvar with no timeout, so calogDestroy's join on that sender never returns).
Moving interpDead earlier would have been the wrong fix -- the reclaim depends on its ordering. The
fix is to make closing the queue atomic with the decision to stop serving, which is a different
fact from the interpreter is gone:
queueClosedon the context, guarded by that context'squeueMutex;messageDequeuesets it in the same critical section where it decides to return NULL;enqueueRawrefuses while holding that mutex, andcontextEnqueuereportscalogErrDeadE.
Every caller already handled that status, so nothing downstream changed. tests/testHooks.c pins the
window deterministically: a per-context shutdown hook runs inside it (after serveLoop, before
interpDead), and queueing work from there must be refused. That check fails against the pre-fix code
and passes after -- the test has teeth, which for a window this narrow is the only evidence worth
having.
The sweep that could adopt a corpse
Sections 26's reclaim sweep took the owned-callable list under queueMutex, unlocked, and only then
retained each entry. A foreign thread dropping the last reference in that gap leaves the sweep
retaining a callable whose calogFnRelease has already committed to finalizing it -- reviving a
corpse, and freeing it twice when the sweep drops its own reference.
The fix is to make taking the list and taking the references one critical section, with a
conditional retain: calogFnRetainIfLive is a CAS loop that refuses to bump a count that has already
reached zero, so an entry already being finalized is simply left out of the sweep and its own
finalize frees it, as it was going to. That is safe to evaluate under the lock because every path
that frees a shell untracks first, and untrack needs that same mutex -- so while the sweep holds it,
no shell can go away underneath the CAS.
Writing a deterministic test for a window this narrow is not practical, so tests/testTeardown.c
churns it instead: twenty rounds of a JavaScript context arming a 1 ms repeating timer, a subscriber
and an export, then being closed while the timer thread is still retaining, invoking and releasing
its callback. That test immediately earned its keep -- under ThreadSanitizer it found two more
defects that no amount of ASan running had:
A mutex used after it was destroyed. calogActorShutdown destroyed ctxMutex, but the
after-context destroy hooks run later, in calogDestroy, and releasing what those registries hold
finalizes callables -- which lock ctxMutex to untrack and to resolve the owner. So pubsub's own
shutdown locked a destroyed mutex, every time, deterministically. (Half of that predates the callable
tracking: calogContextRegistered has always locked it there.) ctxMutex is now destroyed at the
end of calogDestroy, after those hooks; by then every slot is gone, so the late locks find nothing,
which is the answer they want.
A data race on the callable's own fields. calogFnReclaim used to null release and userData
after running the engine release, to stop a later finalize repeating it. But those fields are read by
actorInvokeCallable on other threads -- the timer thread was reading userData to marshal a call
at the moment the owner nulled it. The alive flag does not close that: the invoke checks it before
reading the fields. So reclaim now changes nothing except one atomic: a reclaimed flag claimed
with atomic_exchange, so the engine release runs exactly once whether reclaim or a finalize gets
there first, while fn/userData/release stay write-once-at-create and are safe to read from any
thread. An in-flight marshal can still carry a dead callable's fields to the dispatch layer, where it
is refused -- the queue was closed before the sweep ran (above), which is what makes that safe.
An index that could go negative
_get_priority_index (my-basic) linearly searches a table of operator function pointers and returns
-1 when it finds none. _get_priority then does _PRECEDE_TABLE[idx1][idx2], and the assert
guarding it checks only the upper bound. calog defines no NDEBUG, so today the mb_assert
fires; an embedder who builds with NDEBUG gets a silent out-of-bounds read instead.
The table already has a marker for "these two cannot operate together" -- a space -- and the
evaluator turns it into a clean SE_RN_FAILED_TO_OPERATE script error. So a negative index now
returns ' ': an unknown operator becomes a reported script error rather than a read off the front of
a static array. (It has never fired -- an instrumented build confirmed the lookup always resolves --
which is exactly why it was worth closing while it was still theoretical.)
29. Interrupts that interrupt, and diagnostics that survive them
Three things were closed here. Two of them were listed as accepted limitations of sec 25 and turned out not to deserve the status; the third was public API that nothing used.
Ctrl-C that stops a script
SIGINT/SIGTERM only ever set gShutdown, so the pump loop dropped out and calogDestroy went to
join the context threads -- threads still running script. A script doing real work therefore ignored
Ctrl-C entirely until it finished on its own. On a build script that means the interrupt does
nothing, which is the opposite of what an interrupt is for.
The handler now also records an abort request, and main latches the runtime the moment the pump
loop drops out. The latching is not done in the handler: calogAbortAll is not async-signal-safe and
the handler has no runtime pointer, so the handler does the one thing it may -- an atomic store --
and abortIfSignalled consumes it. That single change is enough for every script that calls a
native, because the dispatch choke point (sec 25) already refuses natives on a latched runtime. A
loop that sleeps, prints, reads a socket or touches a database stops at its next call.
What it does not reach is a script that calls nothing: a bare while true do end never passes
through calogCall, so nothing can see the latch on its behalf. That needed the interpreters, and
the mechanism was already built -- the sandbox work of sec 24 put a per-instruction or heartbeat hook
in nine of the ten engines to enforce the wall-clock budget. Those hooks were installed only for a
limited context, which is exactly why they were no help here: nothing under bin/calog is limited.
They are now installed unconditionally and check the abort latch first, before any budget arithmetic:
| engine | hook | abort reaches a native-free loop |
|---|---|---|
| Lua | lua_sethook, every 1000 instructions |
yes |
| JavaScript | JS_SetInterruptHandler |
yes |
| Squirrel | Execute loop poll, every 1024 opcodes |
yes |
| my-basic | per-statement stepped handler | yes |
| Berry | be_set_obs_hook VM heartbeat |
yes |
| Wren | bytecode LOOP back-jump |
yes |
| mruby | code_fetch_hook, amortized |
yes |
| Tcl | time-limit handler, re-armed every 100 ms | yes |
| Janet | watchdog thread, 5 ms poll | yes |
| s7 | -- | no |
The hooks deliberately do not retire the context the way a budget overrun does. calogAbortAll
leaves teardown order to the host (sec 25), and a context that closed itself from a hook would
destroy its own interpreter while the registries still held its callables -- the sec 26 crash, from a
new direction.
Three of these were worth a note. Tcl has no periodic hook at all, only a limit handler that
fires when a limit is exceeded; so every Tcl context now arms a 100 ms time limit whose handler
re-arms itself, making a limit mechanism into a poll. A real budget still wins, and never re-arms
further out than one poll interval, so a long budget cannot go quiet. Janet has no in-loop hook
either, so its watchdog thread -- previously started only for limited contexts -- now runs for all of
them; it interrupts from a foreign thread, which is why Janet alone still asks calogAborting rather
than calogAbortRaised below (the marker is thread-local to the context being marked). Wren and
Squirrel enforce from patched VM loops, and both patches got smaller: the reason codes and the
retire decision moved out of the vendored file into the adapter, which now hands back the message to
raise. The vendored loops no longer contain a policy or a magic number, and the abort text has one
definition instead of three.
s7 is the exception, and its own architecture is the reason. Its only evaluation hook,
s7_set_begin_hook, fires at the start of a begin block -- but only for the un-optimized OP_BEGIN
form, so (do ((i 0 (+ i 1))) (#f)) and a tail-recursive named let both run straight past it. It
was implemented, measured, and removed: partial interruption that works for some loop shapes and
not others is worse than a documented "no", and it cost something real (see below). s7 remains what
it already was in sec 24 -- the engine with no usable VM hook -- and a native-free s7 loop is still
only ended by killing the process.
Diagnostics the abort must not eat
Sec 25 listed four engines that "cannot tell a compile failure from a run failure", so a script with a syntax error handed to an already-latched runtime had its diagnostic swallowed along with the abort. QuickJS, Tcl, mruby and s7 all compile and run in one API call, and every adapter was asking the same question: is the runtime aborting? -- which is true whether the script failed on its own or we stopped it.
The right question is did WE put this error here, and it is now asked directly. A context carries a
flag set at the three -- and only three -- places an abort can enter running script code:
calogAbortAll, the dispatch choke point that refuses every later native, and an engine hook
unwinding a native-free loop. calogAbortRaised reads it. That is exact regardless of whether an
engine can separate parsing from running, so it replaced the old question in all ten adapters rather
than only the four that were wrong -- one concept, and the six that were already correct by accident
of their API are now correct on purpose.
One engine cannot benefit, and it is worth being precise about why. my-basic has no meaningful
parse step: mb_load_string accepts IF IF IF and the error appears only when the statement runs.
So on my-basic a broken script handed to a latched runtime is unwound by the step hook before it can
say what was wrong with it. The two goals are in direct conflict on an engine with no separate parse,
and the hook wins because a runaway loop is the more common problem. tests/testExit.c asserts the
asymmetry per engine rather than hiding it. This is also what settled s7: keeping its begin hook
would have moved s7 into the same bucket, trading a diagnostic that worked for an interrupt that only
sometimes did.
calogAbortCurrent, removed
Added in sec 26 as the per-context counterpart to calogAbortAll. Nothing in the product ever called
it -- not a library native, not the runner -- and its only caller was the test written to exercise it.
A public function whose sole consumer is its own test is not evidence of a need, and calog.h is a
much harder place to take something out of than to put it in. Removed, along with the per-context
aborting flag it set and the widening of calogAborting that read it. The test now drives the same
path through calogCurrentRetire, which is what taskExit calls and therefore what actually ships.
Test
tests/testExit.c gained a case per engine: latch the runtime first, then hand the engine a source
it can only reject, and require the diagnostic to survive (or, for my-basic, record that it cannot).
The four adapters were reverted in place to confirm the check fails against the old question -- it
failed on exactly QuickJS, Tcl, mruby and s7, and on nothing else, which is also what confirmed the
other six were already right.
The interrupt itself is verified end to end against the shipped binary rather than in-process: a
native-calling loop and a native-free loop per engine, SIGINT after one second, measured. Nine
engines stop in about a second; s7's native-free loop is the one that does not, as the table says.
30. Closing the doors around the broker
Three holes, all the same shape: a capability that reached the host without passing the one place calog can see it. The allow-list, the memory cap and the wall-clock budget all live at the native-dispatch choke point, so anything that reaches the machine another way is not merely unrestricted -- it is invisible to every policy the API offers.
A task no longer escapes its parent's sandbox
taskSpawn and taskLoad called calogContextOpen, the UNLIMITED constructor. A script under a
2 MiB cap, a 200 ms budget and a strict allow-list could do its work in a child and have none of
them: one line, all three limits gone. Proven with a probe -- a child allocated ~200 MB and was still
running 15x past its parent's deadline, and a native the parent was denied ran happily in the child.
The fix is a policy object that is shared, not copied. A SandboxT holds the limit state, the
allow-list and a live count; a context points at one, and a spawned child points at the SAME one.
That single decision gives all three inheritances at once:
- memory -- one pool for the tree, because the child's allocator hook charges the same counter.
memUsedbecame_Atomic, which turns every adapter's existing+=/-=into an atomic op with no adapter-side change at all. A check-then-charge pair is still two operations, so concurrent allocations can overshoot by at most one allocation per thread -- the same allocation-granular overshoot a single context already documents. - time -- the deadline was already resolved to an ABSOLUTE instant at open, so sharing it means the whole tree dies at one moment. Inheriting the duration instead would have let a chain of spawns walk the budget forward forever.
- allow-list -- shared verbatim; a child cannot call what its parent may not.
Inheriting those three is still not enough, which is the part worth remembering: while true do taskSpawn(...) end stays inside every one of them and exhausts the host's threads. So CalogLimitsT
gained a fourth field, maxContexts, checked with a CAS on the shared live count -- a bound two
scripts spawning at once cannot both slip past. The count is also the reference count, so the policy
outlives any single context in its tree; a parent that finishes first must not pull the budget out
from under the children it started.
calogContextOpenLimited and the new internal calogContextOpenInheriting both funnel into one
contextOpenWithSandbox, so the only difference between them is where the policy came from.
The engines' own standard libraries
Every engine that ships an operating-system interface was handing scripts a way around the broker
entirely. A survey of all ten, each finding reproduced by running it, found the damage was not
theoretical: Lua's package.loadlib dlopened a purpose-built .so and executed native code inside
the calog process; os.exit(77) terminated the host mid-run; Tcl's exec, socket and load did
the same three things again; s7's (system ...) ran a shell and its file ports wrote anywhere;
Janet's ffi/ executed attacker-supplied machine code; Berry's os.system and open were wide
open; and my-basic's IMPORT "path" read an arbitrary file and executed it as BASIC.
Six engines are now sealed at interpreter creation. Squirrel and mruby needed nothing -- they ship no
host bindings. Wren and QuickJS keep only benign surface (writing to stdout, in-VM Meta.eval,
clock/timezone reads).
The mechanism differs per engine because the engines do:
- Lua --
luaL_openlibsreplaced by an explicitluaL_requireflist (base, coroutine, table, string, math, utf8). Dropping the libraries beats hiding the globals: the library is never inserted intoLOADED, so nothing canrequireit back. - Janet --
janet_sandbox()is Janet's own mechanism and covers most of it. Two bindings are not guarded by it and had to go by hand:os/exitcallsexit()with no assert, andos/cwdleaks the working directory. - Tcl -- a seal script rather than a list of
Tcl_DeleteCommandcalls, because deleting the visible command is NOT enough for an ensemble: afterrename file {}the 37::tcl::file::*implementation commands were still callable by fully-qualified name. Membership varies by build, so the namespaces are enumerated and swept. Unregistering the standard channels was likewise not enough on its own --Tcl_GetChannelre-resolves the namesstdin/stdout/stderrthroughTcl_GetStdChannel, sochan puts stdoutstill reached the host until the channel commands went too. - s7 -- the only engine where nothing can be done from the adapter:
unletand#_namerecover the original binding of anything a script rebinds, so a shadowedopen-output-fileis no defence. Build flags remove some of it (WITH_SYSTEM_EXTRAS=0,WITH_C_LOADER=0, andWITH_R7RS=0, which is what actually removesgetenv); the rest is a vendored patch making the C implementations refuse. Patching the two funnel points --open_input_file_1ands7_open_output_file-- covers the whole file family, includingcall-with-output-file, which reaches the opener internally rather than through the symbol. - Berry -- mostly config macros (
BE_USE_OS_MODULE,BE_USE_SHARED_LIB, the bytecode loader/saver,BE_USE_INTROSPECT_MODULE).openis named by a precompiled builtin table, so removing it would mean regenerating that table withtools/coc; the function is made to refuse instead, which is the same result without the codegen step. - my-basic -- the import happens at PARSE time, not run time (
_core_importis a no-op), so the refusal goes in the parser's import branch. Module imports (IMPORT "@name") are in-memory and stay.
Pure computation is untouched everywhere. What is gone is the ability to touch the machine without going through a native the host registered and can revoke.
make static builds again
The fully-static path had been unbuildable since the tcp transport gained TLS: libs/calogNet.c has
included <openssl/*.h> since then, but the obj/rel rule passed no $(OSSLINC) and
bin/calogStatic linked no $(SSLARCH). Worse than the plain failure was the near-miss -- with
system OpenSSL headers installed it compiled against THOSE while linking the vendored archives.
The same defect had already been found and fixed once, in tools/crossBuild.sh (sec 27); the sibling
Makefile path was missed because nothing built it. So static is now a prerequisite of make test.
It costs a handful of -O2 objects and a link -- the vendored archives are shared with the rest of the
tree and OpenSSL is already required by bin/testNet -- and it is the only thing that stops this
particular rot recurring a third time.