// context.c -- actor threading: one thread + one MPSC queue per context, plus an // implicit "host context" (id 0) driven by the host thread via calogPump. // // Model (design.md sec 6): // - Scripts run fire-and-forget on their own context threads (calogContextEval // enqueues and returns). // - A registered native runs on the HOST thread: a script calling one parks and // posts a CALL onto the host queue, which calogPump services on the host thread. // So native C code is serialized on one thread and needs no locking. An inline // native (calogRegisterInline) instead runs on the calling thread. // - A script function value (CalogFnT) runs on its owning engine's thread (sec 10). // - A script error is posted to the host queue and delivered to the error handler // during calogPump. // // All per-runtime actor state (the context registry, the host context, the routing // hooks, the error sink) lives in CalogT, so independent runtimes coexist in one // process. The dispatch reaches its runtime through the object it already holds -- the // serving context's ->broker, a callable's ->runtime, or an explicit calog argument -- // never a process global. currentContext (a thread-local) names the calling thread's // context; calogPump sets it to the pumped runtime's host for the drain, so ONE thread // can host several runtimes by pumping each in turn. Because context ids number from 1 // per runtime, inline/reply decisions match the runtime too, not the id alone. A value // or callable is still not shared across runtimes (a cross-runtime reply cannot route). // // Everything a thread waits on flows through its own queue; a caller that is itself // a context pumps its own queue (servicing re-entrant calls) rather than sleeping. // Ownership across the queue: a CALL deep-copies its args; the target frees them and // MOVES the result back. No heap pointer is shared between threads at rest. #define _POSIX_C_SOURCE 200809L #include "calogInternal.h" #include #include #include #include #include #include #define CONTEXT_INITIAL_CONTEXTS 8 #define CONTEXT_INITIAL_ENGINES 4 // The host context id (script contexts get 1..N). currentContext == calog->hostContext // means "on this runtime's host thread." // CALOG_HOST_ID is defined in calogInternal.h (shared with value.c's finalize path). // A script context id packs a 1-based slot index in the low CONTEXT_INDEX_BITS and a // generation counter above it. When a slot is recycled its generation is bumped, so a // handle minted against the old occupant no longer resolves (design.md sec 9). The id // is 64-bit -- a 32-bit slot index + 32-bit generation -- so neither the live-context // count nor open/close churn hits a preset ceiling in practice. #define CONTEXT_INDEX_BITS 32 #define CONTEXT_INDEX_MASK 0xFFFFFFFFu typedef enum MessageKindE { messageCallE = 0, messageReplyE = 1, messageEvalE = 2, messageReleaseE = 3, messageErrorE = 4 } MessageKindE; typedef struct ReplyBoxT { pthread_mutex_t mutex; pthread_cond_t cond; bool done; int32_t status; CalogValueT result; } ReplyBoxT; typedef struct MessageT { MessageKindE kind; CalogNativeFnT fn; // messageCallE: the native to run void *userData; // messageCallE: its userData CalogValueT *args; // messageCallE: deep-copied arguments int32_t argCount; char *source; // messageEvalE: script source; messageErrorE: error text CalogFnT *callable; // messageReleaseE: the callable to finalize uint64_t replyToId; // messageErrorE: the failing context id ReplyBoxT *replyBox; uint64_t token; int32_t status; CalogValueT result; struct MessageT *next; } MessageT; // One sandbox policy, shared by a context and every task it spawns. Sharing is the whole design: a // spawned child does not get a fresh budget, it draws on the SAME memory pool, dies at the SAME // absolute deadline, and is bound by the SAME allow-list, so a limited script cannot escape its // policy by doing its work in a child. `live` is both the reference count and the number of contexts // in the tree, which is what maxContexts bounds -- without it, inheriting the other three limits // would still leave `while true do taskSpawn(...) end` to exhaust the host's threads. typedef struct SandboxT { CalogLimitStateT state; // what the engine adapters install their allocator/hook against char **allow; // sorted allowed-native names, or NULL = every native permitted int32_t allowCount; int32_t maxContexts; // 0 = unbounded bool metered; // a memory cap or a deadline is set, so adapters need `state` _Atomic int32_t live; // contexts sharing this policy; the last one out frees it } SandboxT; struct CalogContextT { uint64_t id; CalogT *broker; const CalogEngineT *engine; void *interp; pthread_t thread; pthread_mutex_t queueMutex; pthread_cond_t queueCond; MessageT *head; MessageT *tail; MessageT *stash; uint64_t nextToken; _Atomic bool shuttingDown; // set by contextRequestShutdown; polled lock-free by calogCurrentShuttingDown _Atomic bool retireRequested; // set by calogCurrentRetire (taskExit); serveLoop retires AFTER the current eval _Atomic bool finished; // set by threadMain just before the thread returns; lets any thread safely reap it bool started; bool interpDead; // set (under ctxMutex) once the interpreter is torn down SandboxT *sandbox; // the policy this context runs under, SHARED with any task it // spawns, or NULL when the context is unrestricted _Atomic bool abortRaised; // the abort error was raised INTO this VM (see calogAbortRaised) bool closing; // guarded by broker->ctxMutex: a close already owns this context bool queueClosed; // guarded by queueMutex: serveLoop has stopped serving, refuse new messages bool reclaimDone; // guarded by queueMutex: the callable sweep has run, stop adopting bool callsClosed; // guarded by queueMutex: refuse CALLs only (errors still land) // Every callable this context created, so it can reclaim their engine handles before its // interpreter is destroyed (contextReclaimCallables). Guarded by queueMutex -- a finalize can // reach it from a foreign thread on the best-effort path in actorReleaseCallable. CalogFnT **ownedFns; int64_t ownedCount; int64_t ownedCap; }; // currentContext is the ONLY process-global actor state -- and it is thread-local: it // names the context (host or script) the calling thread belongs to. A foreign thread // (never a calog thread) leaves it NULL and reaches its runtime through a passed // handle instead. static _Thread_local CalogContextT *currentContext = NULL; // Cross-boundary trace: a thread-local stack of the cross-context callable hops the CALLING thread // is currently inside (each entry = the context a callable is owned by). tracePush/Pop bracket every // actorInvokeCallable; calogLastTrace formats it for live introspection. Post-mortem, the error // STRING itself carries the chain -- traceEnrich prepends the owner's "[engine ctx N]" tag as an // error unwinds out of each boundary, so the tag accumulates across the reply and reaches onError. #define CALOG_TRACE_MAX 32 typedef struct TraceFrameT { CalogT *runtime; uint64_t ctxId; } TraceFrameT; static _Thread_local TraceFrameT traceFrames[CALOG_TRACE_MAX]; static _Thread_local int32_t traceDepth = 0; static int32_t actorInvokeCallable(CalogFnT *callable, CalogValueT *args, int32_t argCount, CalogValueT *result); static void actorReleaseCallable(CalogFnT *callable); static int32_t actorRoute(CalogT *calog, CalogEntryT *entry, CalogValueT *args, int32_t argCount, CalogValueT *result); static int32_t contextDispatch(CalogT *calog, uint64_t targetId, CalogNativeFnT fn, void *userData, CalogValueT *args, int32_t argCount, CalogValueT *result); static void contextDispatchCall(CalogContextT *context, MessageT *message); static void contextDispatchError(CalogT *calog, MessageT *message); static void contextDispatchEval(CalogContextT *context, MessageT *message); static void contextDispatchRelease(MessageT *message); static void contextDrainQueue(CalogContextT *context); static CalogContextT *contextOpenWithSandbox(CalogT *broker, const CalogEngineT *engine, SandboxT *sandbox); static SandboxT *sandboxCreate(const CalogLimitsT *limits); static void sandboxRelease(SandboxT *sandbox); static SandboxT *sandboxRetainForChild(SandboxT *sandbox); static int32_t contextEnqueue(CalogT *calog, uint64_t targetId, MessageT *message); static CalogContextT *contextAtIndex(CalogT *calog, int64_t index); static int32_t contextPostRelease(CalogT *calog, uint64_t targetId, CalogFnT *callable); static void contextReclaimCallables(CalogContextT *context); static void contextReply(CalogT *calog, MessageT *message, int32_t status, CalogValueT *result); static void contextRequestShutdown(CalogContextT *context); static int32_t contextSendBlocking(CalogT *calog, uint64_t targetId, MessageT *message, CalogValueT *result); static bool dispatchCommon(CalogContextT *context, MessageT *message); static bool enqueueRaw(CalogContextT *context, MessageT *message); static int32_t hookGrow(void **array, int64_t count, int64_t *cap, size_t elemSize); static void hostCallsClose(CalogT *calog); static void hostDispatch(CalogT *calog, MessageT *message); static uint64_t idCompose(int64_t index, uint32_t generation); static uint32_t idGeneration(uint64_t id); static int64_t idIndex(uint64_t id); static int limitNameCompare(const void *a, const void *b); static MessageT *messageDequeue(CalogContextT *context); static void messageFree(MessageT *message); static bool onOwnerThread(CalogT *runtime, uint64_t owner); static int32_t pumpUntil(CalogContextT *context, uint64_t token, int32_t *outStatus, CalogValueT *result); static void registryFreePush(CalogT *calog, int64_t index); static CalogContextT *registryResolveLocked(CalogT *calog, uint64_t id); static void serveLoop(CalogContextT *context); static void traceEnrich(CalogT *runtime, uint64_t ctxId, CalogValueT *result); static const char *traceEngineName(CalogT *runtime, uint64_t ctxId); static void tracePop(void); static void tracePush(CalogT *runtime, uint64_t ctxId); static MessageT *tryDequeue(CalogContextT *context); static void *threadMain(void *arg); int32_t calogActorInit(CalogT *calog) { // Build this runtime's host context (id 0, no thread) and claim the calling thread // as its host. All actor state hangs off CalogT, so multiple runtimes coexist. calog->hostContext = (CalogContextT *)calloc(1, sizeof(*calog->hostContext)); if (calog->hostContext == NULL) { return calogErrOomE; } calog->hostContext->id = CALOG_HOST_ID; calog->hostContext->broker = calog; pthread_mutex_init(&calog->hostContext->queueMutex, NULL); pthread_cond_init(&calog->hostContext->queueCond, NULL); calog->hostThread = pthread_self(); currentContext = calog->hostContext; pthread_mutex_init(&calog->ctxMutex, NULL); calog->routeHook = actorRoute; calog->invokeHook = actorInvokeCallable; calog->releaseHook = actorReleaseCallable; return calogOkE; } // True when the calling thread is the owner context's own thread. Ids alone are // ambiguous across runtimes (each numbers contexts from 1), so the runtime must match // too; a foreign thread (currentContext NULL) is never the owner. static bool onOwnerThread(CalogT *runtime, uint64_t owner) { return currentContext != NULL && currentContext->broker == runtime && currentContext->id == owner; } static void tracePush(CalogT *runtime, uint64_t ctxId) { if (traceDepth >= 0 && traceDepth < CALOG_TRACE_MAX) { traceFrames[traceDepth].runtime = runtime; traceFrames[traceDepth].ctxId = ctxId; } traceDepth++; // still counts past the array so the matching pop stays balanced } static void tracePop(void) { if (traceDepth > 0) { traceDepth--; } } static const char *traceEngineName(CalogT *runtime, uint64_t ctxId) { CalogContextT *context; const char *name; if (ctxId == CALOG_HOST_ID) { return "host"; } name = "?"; pthread_mutex_lock(&runtime->ctxMutex); context = registryResolveLocked(runtime, ctxId); if (context != NULL && context->engine != NULL) { name = context->engine->name; } pthread_mutex_unlock(&runtime->ctxMutex); return name; } // Prepend "[engine ctx N] " to an error result as it unwinds out of a cross-context callable, so // the chain accumulates in the string that crosses the reply back to the caller (and to onError). static void traceEnrich(CalogT *runtime, uint64_t ctxId, CalogValueT *result) { char prefix[96]; char *combined; const char *engine; size_t prefixLen; size_t oldLen; if (result->type != calogStringE) { return; } engine = traceEngineName(runtime, ctxId); prefixLen = (size_t)snprintf(prefix, sizeof(prefix), "[%s ctx %lld] ", engine, (long long)idIndex(ctxId)); if (prefixLen >= sizeof(prefix)) { prefixLen = sizeof(prefix) - 1; } oldLen = (size_t)result->as.s.length; combined = (char *)malloc(prefixLen + oldLen + 1); if (combined == NULL) { return; } memcpy(combined, prefix, prefixLen); memcpy(combined + prefixLen, result->as.s.bytes, oldLen); combined[prefixLen + oldLen] = '\0'; calogValueFree(result); calogValueString(result, combined, (int64_t)(prefixLen + oldLen)); free(combined); } static int32_t actorInvokeCallable(CalogFnT *callable, CalogValueT *args, int32_t argCount, CalogValueT *result) { CalogNativeFnT fn; CalogT *runtime; uint64_t owner; int32_t status; // Inline when already on the owner's thread; otherwise marshal to the owner (in the callable's // own runtime). A callable's fn + userData are exactly a native call, so the CALL machinery // carries it unchanged. The boundary hop is traced so a cross-context failure reads clearly. runtime = calogFnRuntime(callable); owner = calogFnOwner(callable); tracePush(runtime, owner); if (onOwnerThread(runtime, owner)) { fn = calogFnNative(callable); status = fn(args, argCount, result, calogFnUserData(callable)); } else { status = contextDispatch(runtime, owner, calogFnNative(callable), calogFnUserData(callable), args, argCount, result); } tracePop(); if (status != calogOkE) { traceEnrich(runtime, owner, result); } return status; } static void actorReleaseCallable(CalogFnT *callable) { CalogT *runtime; uint64_t owner; // Finalize inline when already on the owner's thread; otherwise marshal the // finalize to the owner so the engine release (luaL_unref / sq_release) runs there. runtime = calogFnRuntime(callable); owner = calogFnOwner(callable); if (onOwnerThread(runtime, owner)) { calogFnFinalize(callable); return; } if (contextPostRelease(runtime, owner, callable) != calogOkE) { // The release could not be marshalled: the owner is unreachable (a quiescence violation -- // design.md sec 9), or the message itself would not allocate while the owner is very much // alive. We are NOT on the owner's thread either way, so finalizing normally could run the // adapter's engine op (luaL_unref, JS_FreeValue, ...) against a live interpreter from the // wrong thread. Free the memory and deliberately leak the engine handle instead: it dies // with its interpreter, and a leak beats corrupting a VM that is still running. calogFnFinalizeForeign(callable); } } static int32_t actorRoute(CalogT *calog, CalogEntryT *entry, CalogValueT *args, int32_t argCount, CalogValueT *result) { // Sandboxing: a limited context (allow != NULL -- only a limited SCRIPT context ever sets it; // the host context never does) may call only the natives on its allow-list. currentContext is // the CALLING context, so this gates every engine uniformly through the one dispatch choke point. if (currentContext != NULL && currentContext->sandbox != NULL && currentContext->sandbox->allow != NULL && bsearch(&entry->name, currentContext->sandbox->allow, (size_t)currentContext->sandbox->allowCount, sizeof(char *), limitNameCompare) == NULL) { calogValueNil(result); return calogFail(result, calogErrUnsupportedE, "native not permitted by this context's allow-list"); } // An inline native, or any call already on this runtime's host thread, runs inline; // otherwise the native is marshalled to the host thread (serviced by calogPump). // currentContext alone is not enough: it is a single per-thread slot, so a thread // hosting several runtimes in turn (calogCreate, calogCreate) only matches the most // recently created one. pthread_equal against the physical host thread recorded at // calogActorInit catches the older runtime too -- nobody else will ever pump its // host queue, so marshalling there would deadlock forever. if (entry->runInline || currentContext == calog->hostContext || pthread_equal(pthread_self(), calog->hostThread)) { return entry->fn(args, argCount, result, entry->userData); } return contextDispatch(calog, CALOG_HOST_ID, entry->fn, entry->userData, args, argCount, result); } void calogActorShutdown(CalogT *calog) { // How long to park between checks while waiting out a close that is already in flight (0.5 ms, // the same tick the host pump uses). It is a rendezvous that almost never happens. struct timespec closeWait = { 0, 500000 }; int64_t count; int64_t index; // Latch the registry closed and read its extent in the same breath. Until now a script was free // to keep opening contexts (taskSpawn, taskLoad); from here calogContextOpen refuses, so the // slots below are the complete and final set, ctxSlots cannot move under a realloc, and no // thread can be started that these loops would miss and then free out from under itself. pthread_mutex_lock(&calog->ctxMutex); calog->tearingDown = true; count = calog->ctxCount; pthread_mutex_unlock(&calog->ctxMutex); // A close that was already running when the latch went up (a script's taskClose) owns that // context's stop-join-free and unlinks its slot when it is done. Wait those out rather than race // them -- joining one thread from two is undefined, and freeing a context another thread is // still inside is worse. The latch refuses any new one, so this settles and cannot grow. for (;;) { bool pending; pending = false; pthread_mutex_lock(&calog->ctxMutex); for (index = 0; index < count; index++) { if (calog->ctxSlots[index].context != NULL && calog->ctxSlots[index].context->closing) { pending = true; break; } } pthread_mutex_unlock(&calog->ctxMutex); if (!pending) { break; } nanosleep(&closeWait, NULL); } for (index = 0; index < count; index++) { CalogContextT *context; context = contextAtIndex(calog, index); if (context == NULL) { continue; } contextRequestShutdown(context); } // Join EVERY thread before freeing ANY context: a context tearing down may release a // callable owned by a sibling (marshalled across contexts), which resolves/posts to that // sibling -- so no sibling may be freed while another's thread still runs. for (index = 0; index < count; index++) { CalogContextT *context; context = contextAtIndex(calog, index); if (context != NULL) { pthread_join(context->thread, NULL); } } // All context threads are stopped. Unregister each under ctxMutex (so any in-flight // registryResolveLocked from the host sees NULL rather than a freed slot), drain any // orphaned release, then free. for (index = 0; index < count; index++) { CalogContextT *context; pthread_mutex_lock(&calog->ctxMutex); context = calog->ctxSlots[index].context; calog->ctxSlots[index].context = NULL; pthread_mutex_unlock(&calog->ctxMutex); if (context == NULL) { continue; } contextDrainQueue(context); sandboxRelease(context->sandbox); pthread_mutex_destroy(&context->queueMutex); pthread_cond_destroy(&context->queueCond); free(context); } free(calog->ctxSlots); free(calog->ctxFree); calog->ctxSlots = NULL; calog->ctxFree = NULL; calog->ctxCount = 0; calog->ctxCap = 0; calog->ctxFreeCount = 0; calog->ctxFreeCap = 0; // Drain and free the host context. Only clear the calling thread's currentContext // if it named THIS runtime's host -- the thread may still host other runtimes. if (calog->hostContext != NULL) { bool wasHost; wasHost = (currentContext == calog->hostContext); // contextDrainQueue (not a bare messageFree loop) finalizes any orphaned // messageReleaseE so a pending callable is not leaked. contextDrainQueue(calog->hostContext); pthread_mutex_destroy(&calog->hostContext->queueMutex); pthread_cond_destroy(&calog->hostContext->queueCond); free(calog->hostContext); calog->hostContext = NULL; if (wasHost) { currentContext = NULL; } } calog->routeHook = NULL; calog->invokeHook = NULL; calog->releaseHook = NULL; calog->errorHandler = NULL; calog->errorUserData = NULL; // ctxMutex is deliberately NOT destroyed here. Finalizing a callable locks it -- to untrack the // callable and to resolve its owner -- and callables are still being finalized after this // returns: the after-context destroy hooks (pubsub, export, kv, task) release everything their // registries hold. calogDestroy destroys it once those have run. Every slot is gone by now, so // those late locks find nothing, which is the answer they want. } CalogT *calogContextBroker(const CalogContextT *context) { return context->broker; } // The public runtime lifecycle: calogCreate composes the registry with the actor // layer (which builds the host context and installs the routing hooks); calogDestroy // tears the actor layer down (joining context threads) before freeing the registry. CalogT *calogCreate(void) { CalogT *calog; calog = calogBrokerCreate(); if (calog == NULL) { return NULL; } if (calogActorInit(calog) != calogOkE) { calogBrokerDestroy(calog); return NULL; } return calog; } #define CALOG_HOOK_INITIAL_CAP 4 static int32_t hookGrow(void **array, int64_t count, int64_t *cap, size_t elemSize) { void *grown; int64_t newCap; if (count < *cap) { return calogOkE; } newCap = (*cap == 0) ? CALOG_HOOK_INITIAL_CAP : *cap * CALOG_GROWTH_FACTOR; grown = realloc(*array, (size_t)newCap * elemSize); if (grown == NULL) { return calogErrOomE; } *array = grown; *cap = newCap; return calogOkE; } int32_t calogAtContext(CalogT *calog, CalogContextHookFnT init, CalogContextHookFnT shutdown, void *userData) { int32_t status; if (init == NULL && shutdown == NULL) { return calogOkE; } status = hookGrow((void **)&calog->contextHooks, calog->contextHookCount, &calog->contextHookCap, sizeof(*calog->contextHooks)); if (status != calogOkE) { return status; } calog->contextHooks[calog->contextHookCount].init = init; calog->contextHooks[calog->contextHookCount].shutdown = shutdown; calog->contextHooks[calog->contextHookCount].userData = userData; calog->contextHookCount++; return calogOkE; } int32_t calogAtDestroy(CalogT *calog, CalogDestroyHookFnT fn, CalogDestroyPhaseE phase) { int32_t status; if (fn == NULL) { return calogOkE; } status = hookGrow((void **)&calog->destroyHooks, calog->destroyHookCount, &calog->destroyHookCap, sizeof(*calog->destroyHooks)); if (status != calogOkE) { return status; } calog->destroyHooks[calog->destroyHookCount].fn = fn; calog->destroyHooks[calog->destroyHookCount].phase = phase; calog->destroyHookCount++; return calogOkE; } void calogDestroy(CalogT *calog) { int64_t index; if (calog == NULL) { return; } // FIRST, before any hook runs: see hostCallsClose. The hooks below join background threads, and // a thread waiting on a host reply that can no longer come would never be joinable. hostCallsClose(calog); // Before-context destroy hooks: a library with a background thread that invokes context // callbacks stops it here, while those contexts are still alive. Reverse registration order. for (index = calog->destroyHookCount - 1; index >= 0; index--) { if (calog->destroyHooks[index].phase == calogDestroyBeforeContextsE) { calog->destroyHooks[index].fn(); } } calogActorShutdown(calog); // After-context destroy hooks: free process-global state now that no context thread survives. for (index = calog->destroyHookCount - 1; index >= 0; index--) { if (calog->destroyHooks[index].phase == calogDestroyAfterContextsE) { calog->destroyHooks[index].fn(); } } free(calog->destroyHooks); free(calog->contextHooks); // Only now is ctxMutex dead. The after-context hooks above release the callables their registries // hold, and finalizing one locks this mutex (calogContextUntrackFn, and calogContextRegistered to // decide whether the engine release may run) -- so destroying it inside calogActorShutdown, as // this used to, left those hooks locking a destroyed mutex. ThreadSanitizer calls that "use of an // invalid mutex"; the C standard calls it undefined behaviour. pthread_mutex_destroy(&calog->ctxMutex); calogBrokerDestroy(calog); } // Create + start a context in one step (design: no owned-native registration happens // between the two, so there is nothing to do in between). Returns NULL on failure. uint64_t calogMonotonicMillis(void) { struct timespec ts; clock_gettime(CLOCK_MONOTONIC, &ts); return (uint64_t)ts.tv_sec * 1000u + (uint64_t)(ts.tv_nsec / 1000000); } CalogLimitStateT *calogContextLimitState(CalogContextT *context) { if (context->sandbox == NULL || !context->sandbox->metered) { return NULL; } return &context->sandbox->state; } bool calogContextSandboxed(CalogContextT *context) { return context->sandbox != NULL; } static int limitNameCompare(const void *a, const void *b) { return strcmp(*(const char *const *)a, *(const char *const *)b); } // Build the policy for a freshly limited context. Returns NULL for "no limits at all" (which is not // a failure) and leaves *failed true only if an allocation failed. The deadline is resolved to an // ABSOLUTE instant here, once: every context in the tree then shares that instant, so a chain of // spawns cannot walk the budget forward by restarting it. static SandboxT *sandboxCreate(const CalogLimitsT *limits) { SandboxT *sandbox; if (limits == NULL) { return NULL; } if (limits->memoryBytes <= 0 && limits->wallClockMillis <= 0 && limits->allowList == NULL && limits->maxContexts <= 0) { return NULL; } sandbox = (SandboxT *)calloc(1, sizeof(*sandbox)); if (sandbox == NULL) { return NULL; } atomic_store(&sandbox->live, 1); sandbox->maxContexts = limits->maxContexts > 0 ? limits->maxContexts : 0; if (limits->memoryBytes > 0) { sandbox->state.memCap = limits->memoryBytes; sandbox->metered = true; } if (limits->wallClockMillis > 0) { sandbox->state.deadlineMs = calogMonotonicMillis() + (uint64_t)limits->wallClockMillis; sandbox->metered = true; } if (limits->allowList != NULL) { int32_t count; int32_t i; count = 0; while (limits->allowList[count] != NULL) { count++; } sandbox->allow = (char **)calloc((size_t)(count > 0 ? count : 1), sizeof(char *)); if (sandbox->allow == NULL) { free(sandbox); return NULL; } for (i = 0; i < count; i++) { sandbox->allow[i] = strdup(limits->allowList[i]); if (sandbox->allow[i] == NULL) { sandbox->allowCount = i; sandboxRelease(sandbox); return NULL; } } sandbox->allowCount = count; qsort(sandbox->allow, (size_t)count, sizeof(char *), limitNameCompare); } return sandbox; } // Drop one context's reference. 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 spawned -- so only the last // one out frees it. NULL (an unrestricted context) is a no-op. static void sandboxRelease(SandboxT *sandbox) { int32_t i; if (sandbox == NULL) { return; } if (atomic_fetch_sub(&sandbox->live, 1) != 1) { return; } for (i = 0; i < sandbox->allowCount; i++) { free(sandbox->allow[i]); } free(sandbox->allow); free(sandbox); } // Take a reference for a context about to be spawned INTO this policy, refusing if that would push // the tree past maxContexts. The CAS loop is what makes the bound real: two scripts spawning at once // must not both read live == max-1 and both succeed. static SandboxT *sandboxRetainForChild(SandboxT *sandbox) { int32_t current; if (sandbox == NULL) { return NULL; } current = atomic_load(&sandbox->live); for (;;) { if (sandbox->maxContexts > 0 && current >= sandbox->maxContexts) { return NULL; } if (atomic_compare_exchange_weak(&sandbox->live, ¤t, current + 1)) { return sandbox; } } } CalogContextT *calogContextOpen(CalogT *broker, const CalogEngineT *engine) { return calogContextOpenLimited(broker, engine, NULL); } // Open a context already bound to `sandbox`, taking ownership of the reference the caller holds (so // a failure here releases it). Both public entry points funnel through this: the difference between // them is only WHERE the policy came from -- freshly built by the host, or inherited from the script // that asked for the spawn. static CalogContextT *contextOpenWithSandbox(CalogT *broker, const CalogEngineT *engine, SandboxT *sandbox) { CalogContextT *context; int64_t index; uint32_t generation; // A limit the engine would silently ignore is refused instead: a limited open, or a sandboxed // script's taskSpawn, would otherwise hand back a context free of the caps it was asked for. if (engine != NULL && engine->ignoresLimits && sandbox != NULL && sandbox->metered) { sandboxRelease(sandbox); return NULL; } context = (CalogContextT *)calloc(1, sizeof(*context)); if (context == NULL) { sandboxRelease(sandbox); return NULL; } context->broker = broker; context->engine = engine; // Bound BEFORE the thread starts: createInterpreter reads the limit state to install its // allocator/hook and runs on the new thread, which pthread_create orders after this write. context->sandbox = sandbox; pthread_mutex_init(&context->queueMutex, NULL); pthread_cond_init(&context->queueCond, NULL); pthread_mutex_lock(&broker->ctxMutex); // The runtime is being destroyed: refuse rather than join a registry that is already being // walked and freed (a taskSpawn racing calogDestroy would otherwise leave a thread running // past the teardown that never saw it). if (broker->tearingDown) { pthread_mutex_unlock(&broker->ctxMutex); goto fail; } if (broker->ctxFreeCount > 0) { broker->ctxFreeCount--; index = broker->ctxFree[broker->ctxFreeCount]; generation = broker->ctxSlots[index].generation + 1u; } else { if (broker->ctxCount + 1 > (int64_t)CONTEXT_INDEX_MASK) { pthread_mutex_unlock(&broker->ctxMutex); goto fail; } if (broker->ctxCount == broker->ctxCap) { int64_t newCap; CalogRegistrySlotT *grown; newCap = (broker->ctxCap == 0) ? CONTEXT_INITIAL_CONTEXTS : broker->ctxCap * CALOG_GROWTH_FACTOR; grown = (CalogRegistrySlotT *)realloc(broker->ctxSlots, (size_t)newCap * sizeof(CalogRegistrySlotT)); if (grown == NULL) { pthread_mutex_unlock(&broker->ctxMutex); goto fail; } broker->ctxSlots = grown; broker->ctxCap = newCap; } index = broker->ctxCount; generation = 0; broker->ctxCount++; } context->id = idCompose(index, generation); broker->ctxSlots[index].context = context; broker->ctxSlots[index].generation = generation; // Start the thread and publish `started` INSIDE the registry lock. A teardown reads both under // that same lock, so it can never catch this context half-registered -- either the slot is still // empty, or it is filled and the thread it names is running. The new thread may need the lock // itself (its first callable, a context init hook); it simply waits the moment or two until the // unlock below, and nothing here waits on the new thread, so the two cannot deadlock. if (pthread_create(&context->thread, NULL, threadMain, context) != 0) { broker->ctxSlots[index].context = NULL; registryFreePush(broker, index); pthread_mutex_unlock(&broker->ctxMutex); goto fail; } context->started = true; pthread_mutex_unlock(&broker->ctxMutex); return context; fail: // Shared teardown for every failure above: nothing beyond the queue's own mutex/cond, this // context's reference on the policy, and the context shell has been allocated at any of these // points. sandboxRelease(context->sandbox); pthread_mutex_destroy(&context->queueMutex); pthread_cond_destroy(&context->queueCond); free(context); return NULL; } CalogContextT *calogContextOpenLimited(CalogT *broker, const CalogEngineT *engine, const CalogLimitsT *limits) { SandboxT *sandbox; // A policy that asks for nothing is no policy: sandboxCreate returns NULL for it, which is // exactly what an unrestricted context wants. Telling that apart from an allocation failure is // why the emptiness test lives there rather than here. sandbox = sandboxCreate(limits); if (sandbox == NULL && limits != NULL && (limits->memoryBytes > 0 || limits->wallClockMillis > 0 || limits->allowList != NULL || limits->maxContexts > 0)) { return NULL; } return contextOpenWithSandbox(broker, engine, sandbox); } // Open a context under the CALLING script's policy -- what taskSpawn/taskLoad use. An unrestricted // caller yields an unrestricted child (the ordinary case, and free). A sandboxed caller yields a // child sharing its memory pool, its deadline and its allow-list, and the spawn is REFUSED once the // tree is at maxContexts. Returns NULL on refusal, which the task natives report as an error rather // than silently minting the unlimited context this used to hand out. CalogContextT *calogContextOpenInheriting(CalogT *broker, const CalogEngineT *engine) { SandboxT *sandbox; if (currentContext == NULL || currentContext->sandbox == NULL) { return contextOpenWithSandbox(broker, engine, NULL); } sandbox = sandboxRetainForChild(currentContext->sandbox); if (sandbox == NULL) { return NULL; } return contextOpenWithSandbox(broker, engine, sandbox); } // Search the registered engines for a file ".". The first engine // (in registration order), then its first extension, that names a readable file wins: // its contents are loaded fire-and-forget into a fresh context on that engine. Reads // the file on the calling thread. Returns NULL if nothing matched or the load failed. // Body of both load entry points. `inherit` picks which open to use, so the file search itself has // one implementation rather than two that can drift. static CalogContextT *contextLoadCommon(CalogT *calog, const char *baseFileName, bool inherit) { int64_t engineIndex; for (engineIndex = 0; engineIndex < calog->engineCount; engineIndex++) { const CalogEngineT *engine; int32_t extIndex; engine = calog->engines[engineIndex]; if (engine->extensions == NULL) { continue; } for (extIndex = 0; engine->extensions[extIndex] != NULL; extIndex++) { CalogContextT *context; const char *ext; char *path; char *source; FILE *file; size_t pathSize; long fileSize; size_t readCount; ext = engine->extensions[extIndex]; pathSize = strlen(baseFileName) + strlen(ext) + 2; // '.' + '\0' path = (char *)malloc(pathSize); if (path == NULL) { return NULL; } snprintf(path, pathSize, "%s.%s", baseFileName, ext); file = fopen(path, "rb"); free(path); if (file == NULL) { continue; // not this extension -- try the next } // First existing file wins. Read it whole, open a context, load it. if (fseek(file, 0, SEEK_END) != 0 || (fileSize = ftell(file)) < 0) { fclose(file); return NULL; } rewind(file); source = (char *)malloc((size_t)fileSize + 1); if (source == NULL) { fclose(file); return NULL; } readCount = fread(source, 1, (size_t)fileSize, file); fclose(file); source[readCount] = '\0'; context = inherit ? calogContextOpenInheriting(calog, engine) : calogContextOpen(calog, engine); if (context == NULL) { free(source); return NULL; } if (calogContextEval(context, source) != calogOkE) { free(source); calogContextClose(context); return NULL; } free(source); return context; } } return NULL; } CalogContextT *calogContextLoad(CalogT *calog, const char *baseFileName) { return contextLoadCommon(calog, baseFileName, false); } // calogContextLoad for a script's own taskLoad: the loaded context inherits the calling script's // sandbox rather than starting unrestricted (see calogContextOpenInheriting). CalogContextT *calogContextLoadInheriting(CalogT *calog, const char *baseFileName) { return contextLoadCommon(calog, baseFileName, true); } CalogT *calogCurrent(void) { return currentContext != NULL ? currentContext->broker : NULL; } uint64_t calogCurrentId(void) { return currentContext != NULL ? currentContext->id : CALOG_HOST_ID; } // Ask THIS context to retire itself, requested from inside the task (which has no handle to // itself). Unlike an owner's taskClose, this does NOT shut the context down mid-eval: it sets // a deferred flag so the CURRENT native/eval finishes normally (host calls and all), and // serveLoop stops the context only once that eval returns. The thread then exits and a reaper // (calogTaskReap) joins and frees it. No-op on the host context, which has no thread to end. void calogCurrentRetire(void) { if (currentContext != NULL && currentContext->id != CALOG_HOST_ID) { atomic_store(¤tContext->retireRequested, true); } } // True once this context's owner has requested it close (calogContextClose set the flag and // is blocked joining this thread). A long-running script polls it to cooperate; the host // thread, which has no context to close, always reads false. Lock-free (the flag is atomic). bool calogCurrentShuttingDown(void) { if (currentContext == NULL) { return false; } return atomic_load(¤tContext->shuttingDown); } // Latch the runtime aborting so no context runs another line of script (see calog.h). Called from a // native: the engine raises the error returned here, which unwinds the calling script out of its // chunk at the call site, and every later call -- from this context or any other -- is refused by // calogCall/calogFnInvoke, so a script that catches the unwind still cannot do anything. // Idempotent: latching a latched runtime is a no-op. // // It deliberately does NOT retire the calling context. Teardown order is the host's (calogDestroy // releases the cross-context reference holders -- export/pubsub/timer -- while every context is // still alive, and only then joins them). A context that closed itself here would destroy its own // interpreter first, stranding the callables those registries still hold: harmless for a VM that // frees everything on close, fatal for one that asserts it owns no live objects (QuickJS). The // scripts are stopped either way -- that is what the latch is for. int32_t calogAbortAll(CalogT *calog, CalogValueT *result) { atomic_store(&calog->aborting, true); calogAbortRaise(); return calogFail(result, calogErrAbortE, CALOG_ABORT_MESSAGE); } // Record that the abort error is being raised INTO the calling context's VM. Called from the three // -- and only three -- places the abort can enter running script code: the native that latches the // runtime (calogAbortAll), the dispatch choke point that refuses every later native (calogCall), and // an engine's interrupt hook unwinding a script that calls no natives at all. Marking at the moment // of injection is what lets calogAbortRaised stay exact. No-op off a context thread. void calogAbortRaise(void) { if (currentContext == NULL || currentContext->id == CALOG_HOST_ID) { return; } atomic_store(¤tContext->abortRaised, true); } // True when the error an engine is holding is one WE put there to unwind this script, rather than // one the script earned. Engine adapters ask this instead of calogAborting when deciding whether an // eval failure deserves a diagnostic: "the runtime is aborting" is not the same question, because a // script can be handed to an engine with a syntax error in it while some other script is tearing the // runtime down, and that script really did fail -- silently swallowing it hides the one piece of // information its author needs. Exact for every engine, including the four whose eval API compiles // and runs in one call and so cannot tell the two apart by return code alone. bool calogAbortRaised(CalogT *calog) { if (currentContext == NULL || currentContext->broker != calog) { return false; } return atomic_load(¤tContext->abortRaised); } // True when the caller must stop running script code: this runtime was latched by calogAbortAll. // One query, so the dispatch choke point, every engine adapter and every interrupt hook ask the same // question. To ask instead whether a FAILURE was caused by that abort, use calogAbortRaised. bool calogAborting(CalogT *calog) { return atomic_load(&calog->aborting); } // Tear down a single context (quiescence assumed). The thread is stopped and joined, // then the slot is unlinked under the registry lock so no foreign enqueue can reach // the freed queue mutex; the freed index returns to the freelist. void calogContextClose(CalogContextT *context) { CalogT *broker; int64_t index; if (context == NULL) { return; } broker = context->broker; // Exactly one closer may stop, join and free a given context. A script thread reaches this // through taskClose, which can land while calogDestroy is tearing the runtime down -- and two // threads joining one pthread is undefined. So claim it here, under the registry lock, or leave // it to whoever holds the claim: the teardown (tearingDown is its claim on every context, and it // waits out any close already in flight) or another close already running. pthread_mutex_lock(&broker->ctxMutex); if (broker->tearingDown || context->closing) { pthread_mutex_unlock(&broker->ctxMutex); return; } context->closing = true; pthread_mutex_unlock(&broker->ctxMutex); if (context->started) { contextRequestShutdown(context); pthread_join(context->thread, NULL); } pthread_mutex_lock(&broker->ctxMutex); index = idIndex(context->id); if (index >= 0 && index < broker->ctxCount && broker->ctxSlots[index].context == context) { broker->ctxSlots[index].context = NULL; registryFreePush(broker, index); } pthread_mutex_unlock(&broker->ctxMutex); contextDrainQueue(context); sandboxRelease(context->sandbox); pthread_mutex_destroy(&context->queueMutex); pthread_cond_destroy(&context->queueCond); free(context); } // Read one registry slot with ctxMutex held. Teardown walks the registry from the host thread while // script threads can still be unlinking contexts of their own, so every slot read has to take the // lock -- an unlocked walk races an open's realloc of the whole array. calogContextOpen fills a slot // and starts that context's thread inside this same lock, so a slot found here always names a // context whose thread is running and is therefore joinable. static CalogContextT *contextAtIndex(CalogT *calog, int64_t index) { CalogContextT *context; pthread_mutex_lock(&calog->ctxMutex); context = calog->ctxSlots[index].context; pthread_mutex_unlock(&calog->ctxMutex); return context; } static int32_t contextDispatch(CalogT *calog, uint64_t targetId, CalogNativeFnT fn, void *userData, CalogValueT *args, int32_t argCount, CalogValueT *result) { MessageT *call; int32_t status; int32_t index; calogValueNil(result); call = (MessageT *)calloc(1, sizeof(*call)); if (call == NULL) { return calogFail(result, calogErrOomE, "out of memory creating call message"); } call->kind = messageCallE; call->fn = fn; call->userData = userData; call->argCount = argCount; if (argCount > 0) { call->args = (CalogValueT *)calloc((size_t)argCount, sizeof(CalogValueT)); if (call->args == NULL) { free(call); return calogFail(result, calogErrOomE, "out of memory copying call arguments"); } for (index = 0; index < argCount; index++) { status = calogValueCopy(&call->args[index], &args[index]); if (status != calogOkE) { int32_t cleanup; for (cleanup = 0; cleanup < index; cleanup++) { calogValueFree(&call->args[cleanup]); } free(call->args); free(call); return calogFail(result, status, "failed to copy call arguments"); } } } return contextSendBlocking(calog, targetId, call, result); } static void contextDispatchCall(CalogContextT *context, MessageT *message) { CalogValueT result; int32_t status; int32_t index; calogValueNil(&result); status = message->fn(message->args, message->argCount, &result, message->userData); if (message->args != NULL) { for (index = 0; index < message->argCount; index++) { calogValueFree(&message->args[index]); } free(message->args); message->args = NULL; } contextReply(context->broker, message, status, &result); } static void contextDispatchError(CalogT *calog, MessageT *message) { // Runs on the host thread (calogPump / a nested host pump): deliver a fire-and- // forget script error to the handler, or log it if none is set. if (calog->errorHandler != NULL) { calog->errorHandler(message->replyToId, message->source != NULL ? message->source : "", calog->errorUserData); } else { fprintf(stderr, "calog: context %llu script error: %s\n", (unsigned long long)message->replyToId, message->source != NULL ? message->source : ""); } free(message->source); free(message); } static void contextDispatchEval(CalogContextT *context, MessageT *message) { CalogValueT result; int32_t status; // Fire-and-forget: run the script, free the message, and on failure post the // error to the host thread. No reply. calogValueNil(&result); if (context->engine == NULL || context->engine->runSource == NULL) { status = calogFail(&result, calogErrUnsupportedE, "context has no runnable engine"); } else if (context->interp == NULL) { // createInterpreter failed (threadMain ignores its status); reject cleanly. status = calogFail(&result, calogErrUnsupportedE, "engine interpreter unavailable"); } else { status = context->engine->runSource(context->interp, message->source, &result); } free(message->source); free(message); if (status != calogOkE) { calogPostError(context->broker, context->id, &result); } calogValueFree(&result); } static void contextDispatchRelease(MessageT *message) { // Runs on the callable's owner thread: perform the engine's closure release via // calogFnFinalize, then free the message shell. calogFnFinalize(message->callable); free(message); } static void contextDrainQueue(CalogContextT *context) { MessageT *message; // Called at teardown once the owning thread has stopped and the context is unregistered, // so no new messages can arrive. A release can be orphaned here if a last drop of one of // this context's callables raced its shutdown (enqueued after serveLoop stopped serving); // finalize those so the callable is not leaked, and free every other pending message. while ((message = tryDequeue(context)) != NULL) { if (message->kind == messageReleaseE) { calogFnFinalize(message->callable); free(message); } else if (message->kind == messageCallE) { CalogValueT dead; int32_t argIndex; // A BLOCKING caller is parked on this message's reply box. Freeing the message would // leave that thread waiting on a condition nothing can ever signal -- and teardown // joins that thread, so the wait becomes a process-wide hang rather than one stuck // call. Fail the call instead: the caller gets calogErrDeadE, which every caller of a // cross-context native already handles. if (message->args != NULL) { for (argIndex = 0; argIndex < message->argCount; argIndex++) { calogValueFree(&message->args[argIndex]); } free(message->args); message->args = NULL; } calogValueNil(&dead); calogFail(&dead, calogErrDeadE, "calog: the target context is gone"); contextReply(context->broker, message, calogErrDeadE, &dead); calogValueFree(&dead); } else if (message->kind == messageErrorE) { // An error a dying context posted just before its thread ended. Freeing it would throw // away the one diagnostic the run has for that failure -- and, under bin/calog, the // failing exit code with it, so a task that died during teardown made the run report // success. Delivering is safe here: every context thread has been joined by the time // this runs, so the handler is called on the host thread with nothing left to race. contextDispatchError(context->broker, message); } else { messageFree(message); } } // A nested pumpUntil frame can abandon its token during shutdown (queue-empty exit) // while a still-active outer frame stashes its late reply (token mismatch) and then // never claims it once the thread ends. tryDequeue never visits context->stash, so // free those replies here too. while (context->stash != NULL) { message = context->stash; context->stash = message->next; messageFree(message); } } static int32_t contextEnqueue(CalogT *calog, uint64_t targetId, MessageT *message) { CalogContextT *target; int64_t index; bool inRange; pthread_mutex_lock(&calog->ctxMutex); target = registryResolveLocked(calog, targetId); if (target != NULL) { bool queued; queued = enqueueRaw(target, message); pthread_mutex_unlock(&calog->ctxMutex); // A resolvable context whose queue has closed is on its way out: dead for our purposes, and // reported as such rather than accepting a message no one will serve. return queued ? calogOkE : calogErrDeadE; } // Resolve failed: an in-range index means the slot existed and has since been // freed or recycled (a dead context); out of range means the id named nothing. index = idIndex(targetId); inRange = index >= 0 && index < calog->ctxCount; pthread_mutex_unlock(&calog->ctxMutex); if (inRange) { return calogErrDeadE; } return calogErrNotFoundE; } // Fire-and-forget: post a callable finalize to its owner thread. On enqueue failure // the message is freed and actorReleaseCallable finalizes the dead callable itself. static int32_t contextPostRelease(CalogT *calog, uint64_t targetId, CalogFnT *callable) { MessageT *message; int32_t status; message = (MessageT *)calloc(1, sizeof(*message)); if (message == NULL) { return calogErrOomE; } message->kind = messageReleaseE; message->callable = callable; status = contextEnqueue(calog, targetId, message); if (status != calogOkE) { free(message); } return status; } int32_t calogContextEval(CalogContextT *context, const char *source) { MessageT *eval; char *sourceCopy; int32_t status; sourceCopy = strdup(source); if (sourceCopy == NULL) { return calogErrOomE; } eval = (MessageT *)calloc(1, sizeof(*eval)); if (eval == NULL) { free(sourceCopy); return calogErrOomE; } eval->kind = messageEvalE; eval->source = sourceCopy; // Fire-and-forget: enqueue onto the context's thread and return. The script runs // asynchronously; errors surface via the error handler. status = contextEnqueue(context->broker, context->id, eval); if (status != calogOkE) { messageFree(eval); } return status; } // True once this context's thread has fully exited (serveLoop returned, interpreter destroyed). // A reaper polls it so it only pthread_joins a context that will not block, then frees it. bool calogContextFinished(const CalogContextT *context) { return atomic_load(&context->finished); } uint64_t calogContextId(const CalogContextT *context) { return context->id; } void *calogContextInterp(CalogContextT *context) { return context->interp; } bool calogContextRegistered(CalogT *runtime, uint64_t ctxId) { bool registered; if (runtime == NULL) { return false; } pthread_mutex_lock(&runtime->ctxMutex); registered = (registryResolveLocked(runtime, ctxId) != NULL); pthread_mutex_unlock(&runtime->ctxMutex); return registered; } // List a new callable against the context that owns it, so contextReclaimCallables can release its // engine handle if that context is torn down while something else still holds it. Locks in the // runtime's established order (ctxMutex, then the context's queueMutex). An owner that no longer // resolves needs no entry: its interpreter is already gone, which calogFnFinalize handles. int32_t calogContextTrackFn(CalogT *runtime, uint64_t ownerCtxId, CalogFnT *fn) { CalogContextT *owner; void *buffer; int64_t capacity; int32_t status; if (runtime == NULL || ownerCtxId == CALOG_HOST_ID) { return calogOkE; // host-owned: no interpreter for the handle to outlive } status = calogOkE; pthread_mutex_lock(&runtime->ctxMutex); owner = registryResolveLocked(runtime, ownerCtxId); if (owner != NULL) { pthread_mutex_lock(&owner->queueMutex); buffer = owner->ownedFns; capacity = owner->ownedCap; status = calogGrow(&buffer, &capacity, owner->ownedCount + 1, sizeof(CalogFnT *)); if (status == calogOkE) { owner->ownedFns = (CalogFnT **)buffer; owner->ownedCap = capacity; owner->ownedFns[owner->ownedCount] = fn; owner->ownedCount++; } pthread_mutex_unlock(&owner->queueMutex); } pthread_mutex_unlock(&runtime->ctxMutex); return status; } // Drop a callable from its owner's list as it is finalized. Order within the list carries no // meaning, so the hole is filled from the end. A callable the owner already reclaimed is not in any // list (the sweep took the whole array), which is why this can run for one and simply find nothing. bool calogContextUntrackFn(CalogT *runtime, uint64_t ownerCtxId, CalogFnT *fn) { CalogContextT *owner; int64_t index; int64_t found; bool mayFinalize; if (runtime == NULL || ownerCtxId == CALOG_HOST_ID) { return true; } mayFinalize = true; pthread_mutex_lock(&runtime->ctxMutex); owner = registryResolveLocked(runtime, ownerCtxId); if (owner != NULL) { pthread_mutex_lock(&owner->queueMutex); found = -1; for (index = 0; index < owner->ownedCount; index++) { if (owner->ownedFns[index] == fn) { found = index; break; } } // The owner has stopped serving but has not yet reclaimed (threadMain runs the per-context // shutdown hooks in between, so this window is as long as those take). Finalizing now would // happen on the wrong thread, which means skipping the engine release -- and the handle // would then still be live inside an interpreter that is about to be destroyed. Leave the // callable TRACKED instead: the sweep is the one thread that may run that release, and it // is about to walk this very list. The finalize's shell holder travels with it. if (found >= 0 && owner->queueClosed && !owner->reclaimDone) { calogFnMarkAdopted(fn); mayFinalize = false; } else if (found >= 0) { owner->ownedCount--; owner->ownedFns[found] = owner->ownedFns[owner->ownedCount]; } pthread_mutex_unlock(&owner->queueMutex); } pthread_mutex_unlock(&runtime->ctxMutex); return mayFinalize; } void calogPump(CalogT *calog) { CalogContextT *previous; MessageT *message; if (calog->hostContext == NULL) { return; } // Present the calling thread as THIS runtime's host for the drain, so a native -- // and anything it calls -- resolves to this runtime. Restoring afterward lets one // thread pump several runtimes in turn (each drain acts as the right host). previous = currentContext; currentContext = calog->hostContext; // Non-blocking: run every pending host-thread message and return. while ((message = tryDequeue(calog->hostContext)) != NULL) { hostDispatch(calog, message); } currentContext = previous; } // Append an engine to the runtime's calogContextLoad search list (setup-time, host // thread). On OOM the engine is simply not registered -- load just won't find it. void calogRegisterEngine(CalogT *calog, const CalogEngineT *engine) { if (calog->engineCount == calog->engineCap) { int64_t newCap; const CalogEngineT **grown; newCap = (calog->engineCap == 0) ? CONTEXT_INITIAL_ENGINES : calog->engineCap * CALOG_GROWTH_FACTOR; grown = (const CalogEngineT **)realloc(calog->engines, (size_t)newCap * sizeof(*grown)); if (grown == NULL) { return; } calog->engines = grown; calog->engineCap = newCap; } calog->engines[calog->engineCount] = engine; calog->engineCount++; } void calogSetErrorHandler(CalogT *calog, CalogErrorFnT fn, void *userData) { calog->errorHandler = fn; calog->errorUserData = userData; } // The reply tail shared by CALL dispatch: hand (status, result) back to whoever is // blocked on this message -- an external caller's reply box, or a context caller via // a REPLY enqueued onto its queue (in the serving context's runtime). Consumes message. // Release the engine handle of every callable this context still owns, on the context's OWN thread, // while its interpreter is still alive. Anything else holding one of these callables -- a pubsub // subscription, an export, a value handed to another engine, an invoke in flight -- keeps a valid // (dead) shell to release later; without this the engine handle would outlive the VM that owns it, // which most VMs quietly tolerate and QuickJS aborts on. See design.md sec 26. static void contextReclaimCallables(CalogContextT *context) { CalogFnT **owned; CalogFnT **orphans; int64_t total; int64_t count; int64_t orphanCount; int64_t index; // Take the whole list first: an engine release can cascade (a VM finalizer dropping another of // this context's callables), which re-enters untrack, and must not run against a list being // walked. Holding a reference across the sweep is what keeps such a cascade from freeing an // entry we have not reached yet. // // Both the steal and the retains happen in ONE critical section, and the retain is conditional. // A foreign thread can drop the last reference to any of these at any moment; retaining after // that drop would resurrect a callable whose finalize is already committed, and the sweep's own // release would then free it a second time. calogFnRetainIfLive refuses that case, so an entry // already being finalized is simply left out -- its finalize frees it, as it was going to. And // no shell can be freed while this lock is held, because every free path untracks first and // untrack needs this same mutex, so inspecting the refcount here is safe. pthread_mutex_lock(&context->queueMutex); owned = context->ownedFns; total = context->ownedCount; count = 0; orphanCount = 0; // An entry whose count already hit zero cannot be retained -- a finalize is committed for it on // another thread. It must not be skipped either: that thread is NOT this one, so it is barred // from touching the interpreter and finalizes without running the engine release, stranding the // handle inside a VM that is about to assert it owns nothing. Those go on a second list and get // their release run here, on the thread that may. A shell holder is taken for each so the // committed finalize cannot free the memory before this sweep is done with it. orphans = (total > 0) ? (CalogFnT **)malloc((size_t)total * sizeof(*orphans)) : NULL; for (index = 0; index < total; index++) { if (calogFnRetainIfLive(owned[index])) { owned[count] = owned[index]; // compact: keep only what this sweep now owns count++; } else if (orphans != NULL) { // An adopted entry's finalize already handed its shell holder over (calogContextUntrackFn); // one that has not been adopted still has a finalize in flight that will drop its own, so // this sweep takes a holder of its own to keep the shell alive until it is done. if (!calogFnAdopted(owned[index])) { calogFnShellHold(owned[index]); } orphans[orphanCount] = owned[index]; orphanCount++; } } context->ownedFns = NULL; context->ownedCount = 0; context->ownedCap = 0; context->reclaimDone = true; // from here a finalize completes normally: no more adopting pthread_mutex_unlock(&context->queueMutex); for (index = 0; index < orphanCount; index++) { calogFnReclaimOrphan(orphans[index]); } free(orphans); if (owned == NULL) { return; } for (index = 0; index < count; index++) { calogFnReclaim(owned[index]); } for (index = 0; index < count; index++) { calogFnRelease(owned[index]); // reclaimed above, so a final drop frees the shell only } free(owned); } static void contextReply(CalogT *calog, MessageT *message, int32_t status, CalogValueT *result) { if (message->replyBox != NULL) { ReplyBoxT *box; box = message->replyBox; pthread_mutex_lock(&box->mutex); box->status = status; calogValueMove(&box->result, result); box->done = true; pthread_cond_signal(&box->cond); pthread_mutex_unlock(&box->mutex); free(message); } else { // Reuse the request message as the REPLY: it already carries the caller's // token and id, so the wakeup cannot be lost to an allocation failure here. uint64_t replyToId; replyToId = message->replyToId; message->kind = messageReplyE; message->status = status; calogValueMove(&message->result, result); if (contextEnqueue(calog, replyToId, message) != calogOkE) { messageFree(message); } } } // Ask a context's own thread to stop once its queue drains. Allocation-free (unlike an // enqueued SHUTDOWN message would be) so it cannot fail under OOM and leave the caller's // pthread_join waiting forever: it just flips the flag messageDequeue already waits on, // under the same mutex, and signals the cond so a thread parked in cond_wait wakes. static void contextRequestShutdown(CalogContextT *context) { pthread_mutex_lock(&context->queueMutex); context->shuttingDown = true; pthread_cond_signal(&context->queueCond); pthread_mutex_unlock(&context->queueMutex); } // Enqueue an already-built CALL to targetId and wait for its reply. A context caller // pumps its own queue (servicing re-entrant work) instead of sleeping; a foreign // caller blocks on a private reply box. The message is consumed either way. static int32_t contextSendBlocking(CalogT *calog, uint64_t targetId, MessageT *message, CalogValueT *result) { CalogContextT *caller; int32_t status; // A call that lands on calog's own host queue, made by the exact thread that // hosts calog, can never be serviced by anyone else -- calogPump(calog) only ever // runs on that thread, and currentContext may already name a different runtime's // context (this thread hosts several). Run the call inline instead of enqueuing // and waiting, or it hangs forever. if (targetId == CALOG_HOST_ID && pthread_equal(pthread_self(), calog->hostThread)) { int32_t index; status = message->fn(message->args, message->argCount, result, message->userData); if (message->args != NULL) { for (index = 0; index < message->argCount; index++) { calogValueFree(&message->args[index]); } free(message->args); } free(message); return status; } // The token/pump path needs the caller's queue in THIS runtime (its reply routes // by the caller's id, resolved in calog). A thread that is foreign to calog -- a // non-calog thread, or one currently hosting a different runtime -- takes the reply // box instead, so a cross-runtime call cannot misroute its reply. caller = (currentContext != NULL && currentContext->broker == calog) ? currentContext : NULL; if (caller != NULL) { uint64_t token; int32_t replyStatus; token = ++caller->nextToken; message->replyToId = caller->id; message->token = token; status = contextEnqueue(calog, targetId, message); if (status != calogOkE) { messageFree(message); return calogFail(result, status, "target context unavailable"); } status = pumpUntil(caller, token, &replyStatus, result); if (status != calogOkE) { return calogFail(result, status, "context is shutting down"); } return replyStatus; } ReplyBoxT box; pthread_mutex_init(&box.mutex, NULL); pthread_cond_init(&box.cond, NULL); box.done = false; box.status = calogOkE; calogValueNil(&box.result); message->replyBox = &box; status = contextEnqueue(calog, targetId, message); if (status != calogOkE) { messageFree(message); pthread_mutex_destroy(&box.mutex); pthread_cond_destroy(&box.cond); return calogFail(result, status, "target context unavailable"); } pthread_mutex_lock(&box.mutex); while (!box.done) { pthread_cond_wait(&box.cond, &box.mutex); } pthread_mutex_unlock(&box.mutex); calogValueMove(result, &box.result); status = box.status; pthread_mutex_destroy(&box.mutex); pthread_cond_destroy(&box.cond); return status; } // The dispatch shared by every loop that drains a queue (serveLoop, pumpUntil, // hostDispatch): the three message kinds whose handling never varies by loop. Returns // whether message->kind was one of them (and so was consumed); the caller still owns // its own loop-specific kinds (REPLY, ERROR) and the default for anything left over. static bool dispatchCommon(CalogContextT *context, MessageT *message) { switch (message->kind) { case messageCallE: contextDispatchCall(context, message); return true; case messageEvalE: contextDispatchEval(context, message); return true; case messageReleaseE: contextDispatchRelease(message); return true; default: return false; } } // Append to a context's queue, or refuse if that context has stopped serving. Returns false only for // the refusal; the caller owns the message either way. static bool enqueueRaw(CalogContextT *context, MessageT *message) { pthread_mutex_lock(&context->queueMutex); // callsClosed refuses CALLs but still accepts everything else -- notably a script error, which // is fire-and-forget and gets delivered by the drain. A CALL is the one kind whose sender WAITS, // so it is the only kind that can turn "nobody will serve this" into a hang. if (context->queueClosed || (context->callsClosed && message->kind == messageCallE)) { pthread_mutex_unlock(&context->queueMutex); return false; } message->next = NULL; if (context->tail != NULL) { context->tail->next = message; } else { context->head = message; } context->tail = message; pthread_cond_signal(&context->queueCond); pthread_mutex_unlock(&context->queueMutex); return true; } // Stop the host serving CALLs, and fail everything already queued for it. // // Once the host thread is inside calogDestroy it never calls calogPump again, so every host-directed // CALL from that moment on is guaranteed never to be served -- its sender would block forever. That // is not hypothetical: the before-contexts destroy hooks stop background threads by JOINING them, // and a timer thread parked mid-callback on a context that is itself waiting for a host reply // produces a three-way cycle (host -> timer thread -> context -> host) that hangs teardown outright. // Refusing new calls and failing the parked ones breaks it before the first join. static void hostCallsClose(CalogT *calog) { if (calog->hostContext == NULL) { return; } pthread_mutex_lock(&calog->hostContext->queueMutex); calog->hostContext->callsClosed = true; pthread_mutex_unlock(&calog->hostContext->queueMutex); contextDrainQueue(calog->hostContext); } static void hostDispatch(CalogT *calog, MessageT *message) { if (message->kind == messageErrorE) { contextDispatchError(calog, message); return; } if (!dispatchCommon(calog->hostContext, message)) { messageFree(message); } } static uint64_t idCompose(int64_t index, uint32_t generation) { return ((uint64_t)generation << CONTEXT_INDEX_BITS) | (uint64_t)(index + 1); } static uint32_t idGeneration(uint64_t id) { return (uint32_t)(id >> CONTEXT_INDEX_BITS); } static int64_t idIndex(uint64_t id) { uint64_t low; low = id & CONTEXT_INDEX_MASK; if (low == 0) { return -1; } return (int64_t)low - 1; } static MessageT *messageDequeue(CalogContextT *context) { MessageT *message; pthread_mutex_lock(&context->queueMutex); while (context->head == NULL && !context->shuttingDown) { pthread_cond_wait(&context->queueCond, &context->queueMutex); } if (context->head == NULL) { // Queue drained and shutting down: this thread is about to leave serveLoop. Close the queue // in the SAME critical section that decides to stop, so a sender can never slip a message in // between -- one that nothing would ever serve, leaving a blocking caller waiting on a reply // that cannot come. From here contextEnqueue reports the context dead, which every caller // already handles. context->queueClosed = true; pthread_mutex_unlock(&context->queueMutex); return NULL; } message = context->head; context->head = message->next; if (context->head == NULL) { context->tail = NULL; } pthread_mutex_unlock(&context->queueMutex); message->next = NULL; return message; } static void messageFree(MessageT *message) { int32_t index; if (message == NULL) { return; } if (message->kind == messageCallE && message->args != NULL) { for (index = 0; index < message->argCount; index++) { calogValueFree(&message->args[index]); } free(message->args); } if (message->kind == messageEvalE || message->kind == messageErrorE) { free(message->source); } if (message->kind == messageReplyE) { calogValueFree(&message->result); } free(message); } // Post a fire-and-forget script error to the host thread's error handler. // Deliver a script failure to the runtime's error handler (on the host thread). Also the way a // library's own background thread reports a callback that failed -- see calogInternal.h. void calogPostError(CalogT *calog, uint64_t contextId, const CalogValueT *result) { MessageT *message; const char *text; text = (result->type == calogStringE) ? result->as.s.bytes : "script error"; message = (MessageT *)calloc(1, sizeof(*message)); if (message == NULL) { return; } message->kind = messageErrorE; message->replyToId = contextId; message->source = strdup(text); if (message->source == NULL) { free(message); return; } if (contextEnqueue(calog, CALOG_HOST_ID, message) != calogOkE) { free(message->source); free(message); } } static int32_t pumpUntil(CalogContextT *context, uint64_t token, int32_t *outStatus, CalogValueT *result) { for (;;) { MessageT *message; MessageT *prev; MessageT *node; // Re-check the stash: a nested pump may have stashed this token's reply. prev = NULL; node = context->stash; while (node != NULL) { if (node->token == token) { if (prev != NULL) { prev->next = node->next; } else { context->stash = node->next; } *outStatus = node->status; calogValueMove(result, &node->result); free(node); return calogOkE; } prev = node; node = node->next; } message = messageDequeue(context); if (message == NULL) { calogValueNil(result); return calogErrNotFoundE; } if (message->kind == messageReplyE) { if (message->token == token) { *outStatus = message->status; calogValueMove(result, &message->result); free(message); return calogOkE; } message->next = context->stash; context->stash = message; continue; } if (message->kind == messageErrorE) { contextDispatchError(context->broker, message); continue; } if (!dispatchCommon(context, message)) { messageFree(message); } } } // Push a freed slot index onto the recycle freelist. Called under calog->ctxMutex. On // OOM the index is simply not recycled (a leaked slot, never a correctness fault) -- // void because neither caller has anything to act on. static void registryFreePush(CalogT *calog, int64_t index) { if (calog->ctxFreeCount == calog->ctxFreeCap) { int64_t newCap; int64_t *grown; newCap = (calog->ctxFreeCap == 0) ? CONTEXT_INITIAL_CONTEXTS : calog->ctxFreeCap * CALOG_GROWTH_FACTOR; grown = (int64_t *)realloc(calog->ctxFree, (size_t)newCap * sizeof(int64_t)); if (grown == NULL) { return; } calog->ctxFree = grown; calog->ctxFreeCap = newCap; } calog->ctxFree[calog->ctxFreeCount] = index; calog->ctxFreeCount++; } static CalogContextT *registryResolveLocked(CalogT *calog, uint64_t id) { int64_t index; if (id == CALOG_HOST_ID) { return calog->hostContext; } index = idIndex(id); if (index < 0 || index >= calog->ctxCount) { return NULL; } if (calog->ctxSlots[index].context == NULL) { return NULL; } if (calog->ctxSlots[index].generation != idGeneration(id)) { return NULL; } // Interpreter already torn down: treat as gone so late releases finalize without touching // it (the slot itself stays populated until calogContextClose/calogActorShutdown frees it). if (calog->ctxSlots[index].context->interpDead) { return NULL; } return calog->ctxSlots[index].context; } static void serveLoop(CalogContextT *context) { for (;;) { MessageT *message; // messageDequeue returns NULL once the queue is empty and contextRequestShutdown // has flipped shuttingDown -- there is no SHUTDOWN message kind to look for. message = messageDequeue(context); if (message == NULL) { return; } if (!dispatchCommon(context, message)) { messageFree(message); } if (atomic_load(&context->retireRequested)) { // A native in the eval that just finished called calogCurrentRetire (taskExit): the // eval ran to completion normally; now stop so this context's thread can exit. contextRequestShutdown(context); } } } static MessageT *tryDequeue(CalogContextT *context) { MessageT *message; pthread_mutex_lock(&context->queueMutex); message = context->head; if (message != NULL) { context->head = message->next; if (context->head == NULL) { context->tail = NULL; } } pthread_mutex_unlock(&context->queueMutex); if (message != NULL) { message->next = NULL; } return message; } static void *threadMain(void *arg) { CalogContextT *context; int64_t index; context = (CalogContextT *)arg; currentContext = context; if (context->engine != NULL && context->engine->createInterpreter != NULL) { context->engine->createInterpreter(context, &context->interp); } // Per-context library init, on this context's own thread now that the interpreter exists. for (index = 0; index < context->broker->contextHookCount; index++) { if (context->broker->contextHooks[index].init != NULL) { context->broker->contextHooks[index].init(context, context->broker->contextHooks[index].userData); } } serveLoop(context); // Per-context library shutdown (reverse order), while the context + interpreter are still valid. for (index = context->broker->contextHookCount - 1; index >= 0; index--) { if (context->broker->contextHooks[index].shutdown != NULL) { context->broker->contextHooks[index].shutdown(context, context->broker->contextHooks[index].userData); } } // Reclaim the engine handle of every callable this context still owns, while the interpreter is // alive and this is its own thread. Anything still holding one -- a subscription, an export, a // value another engine received -- is left a dead shell it can safely release later. contextReclaimCallables(context); // Mark the interpreter dead BEFORE destroying it, so registryResolveLocked stops handing // this context out: a callable this context owns that is released from now on (e.g. one // published via the export library and dropped after the context unloads) finalizes by // freeing its struct directly instead of running the engine release on a dead interpreter. pthread_mutex_lock(&context->broker->ctxMutex); context->interpDead = true; pthread_mutex_unlock(&context->broker->ctxMutex); if (context->engine != NULL && context->engine->destroyInterpreter != NULL) { context->engine->destroyInterpreter(context->interp); } currentContext = NULL; // Last thing the thread does: publish that it has exited, so a reaper (calogTaskReap on // the host) can pthread_join it without blocking and then free the context. atomic_store(&context->finished, true); return NULL; }