1997 lines
82 KiB
C
1997 lines
82 KiB
C
// 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 <pthread.h>
|
|
#include <stdatomic.h>
|
|
#include <stdio.h>
|
|
#include <stdlib.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#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 "<baseFileName>.<ext>". 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;
|
|
}
|