calog/tests/testEngineLua.c
2026-06-30 20:55:01 -05:00

222 lines
8.4 KiB
C

// testEngineLua.c -- a real Lua interpreter running on a CalogContextT thread.
//
// Proves the CalogEngineT wiring: a Lua context creates its interpreter on its own
// thread (calogLuaEngine.createInterpreter), exposes broker natives there, and runs
// scripts via calogContextEval. The script calls one thread-agnostic native (report,
// owner 0, run inline on the Lua thread) and one native owned by a DIFFERENT
// context (doubleIt, owned by ctx2) -- the exposed-native trampoline routes that
// call through the broker to ctx2's thread and back, with no callback in the
// script. Built under ASan+UBSan; the threading core is covered by testActor.
#include "calog.h"
#include <stdatomic.h>
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#define CHECK(cond, msg) checkImpl((cond), (msg), __FILE__, __LINE__)
#define DOUBLE_EXPECTED 42
#define PURE_EXPECTED 6
static CalogT *broker = NULL;
static CalogContextT *luaCtx = NULL;
static CalogContextT *workerCtx = NULL;
static _Atomic int64_t reportedValue = 0;
static _Atomic uint32_t reportedCtxId = 0;
static _Atomic uint32_t doubleItCtxId = 0;
static CalogFnT *_Atomic storedCb = NULL;
static int32_t testsRun = 0;
static int32_t testsFailed = 0;
static void checkImpl(bool condition, const char *message, const char *file, int32_t line);
static int32_t nativeDoubleIt(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static int32_t nativeReport(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static int32_t nativeSetCb(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static void testCallbackAcrossContexts(void);
static void testCrossContextFromScript(void);
static void testFailedInterpreter(void);
static void testPureScript(void);
static void testScriptError(void);
static void checkImpl(bool condition, const char *message, const char *file, int32_t line) {
testsRun++;
if (!condition) {
testsFailed++;
printf("FAIL %s:%d %s\n", file, line, message);
}
}
static int32_t nativeDoubleIt(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)userData;
// Owned by workerCtx: routing must run this on workerCtx's thread, so record
// the id it actually ran on.
atomic_store(&doubleItCtxId, calogCurrentId());
if (argCount != 1 || args[0].type != calogIntE) {
return calogFail(result, calogErrArgE, "doubleIt expects one integer");
}
calogValueInt(result, args[0].as.i * 2);
return calogOkE;
}
static int32_t nativeReport(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)userData;
if (argCount != 1 || args[0].type != calogIntE) {
return calogFail(result, calogErrArgE, "report expects one integer");
}
atomic_store(&reportedCtxId, calogCurrentId());
atomic_store(&reportedValue, args[0].as.i);
calogValueNil(result);
return calogOkE;
}
static int32_t nativeSetCb(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)userData;
calogValueNil(result);
if (argCount != 1 || args[0].type != calogFnE) {
return calogFail(result, calogErrArgE, "setCb expects one function");
}
// Runs on luaCtx's thread; the closure is owned by luaCtx. Retain it so it
// outlives this call (the trampoline frees the args afterward).
calogFnRetain(args[0].as.fn);
atomic_store(&storedCb, args[0].as.fn);
return calogOkE;
}
static void testCallbackAcrossContexts(void) {
CalogFnT *callback;
CalogValueT arg;
CalogValueT result;
int32_t status;
// A Lua closure is captured on luaCtx's thread via setCb (owned by luaCtx).
status = calogContextEval(luaCtx, "setCb(function(x) return x + 100 end)", &result);
CHECK(status == calogOkE, "captured a Lua closure as a CalogFnT on its owner thread");
calogValueFree(&result);
callback = atomic_load(&storedCb);
CHECK(callback != NULL, "callback was stored");
// Invoke it from the MAIN thread: calogFnInvoke must marshal to luaCtx's thread
// (design.md sec 10) so the closure runs on its owner, never on the main thread.
calogValueInt(&arg, 7);
status = calogFnInvoke(callback, &arg, 1, &result);
CHECK(status == calogOkE && result.type == calogIntE && result.as.i == 107, "cross-thread callable invoke routed to the owner context");
calogValueFree(&result);
calogValueFree(&arg);
// Drop the last reference from the main thread: the luaL_unref must run on
// luaCtx's thread, not here (ASan/TSan would flag an off-thread touch).
calogFnRelease(callback);
}
static void testCrossContextFromScript(void) {
CalogValueT result;
int32_t status;
// doubleIt is owned by workerCtx; report is thread-agnostic. The script reads
// as plain synchronous code -- the cross-context hop is invisible to it.
status = calogContextEval(luaCtx, "report(doubleIt(21))", &result);
CHECK(status == calogOkE, "script with a cross-context call ran without error");
CHECK(atomic_load(&reportedValue) == DOUBLE_EXPECTED, "cross-context doubleIt result flowed back into the script");
CHECK(atomic_load(&doubleItCtxId) == calogContextId(workerCtx), "doubleIt executed on the worker context's thread");
CHECK(atomic_load(&reportedCtxId) == calogContextId(luaCtx), "report executed on the Lua context's own thread");
calogValueFree(&result);
}
static void testFailedInterpreter(void) {
CalogContextT *failCtx;
const char *badNames[1];
CalogConfigT badConfig;
CalogValueT result;
int32_t status;
// A config naming a native that was never registered makes createInterpreter
// fail on the context's thread (interp stays NULL). The eval must come back as
// an error rather than dereferencing the NULL interpreter.
badNames[0] = "noSuchNative";
badConfig.exposeNames = badNames;
badConfig.exposeCount = 1;
calogContextCreate(broker, &calogLuaEngine, &badConfig, &failCtx);
calogContextStart(failCtx);
status = calogContextEval(failCtx, "report(1)", &result);
CHECK(status != calogOkE, "eval on a context whose interpreter failed to init returns an error, not a crash");
calogValueFree(&result);
calogContextDestroy(failCtx);
}
static void testPureScript(void) {
CalogValueT result;
int32_t status;
status = calogContextEval(luaCtx, "report(1 + 2 + 3)", &result);
CHECK(status == calogOkE && atomic_load(&reportedValue) == PURE_EXPECTED, "a second eval on the live interpreter ran");
calogValueFree(&result);
}
static void testScriptError(void) {
CalogValueT result;
int32_t status;
// A syntax error must come back as a failing status with the error in result
// (the single channel), and must not wedge the interpreter.
status = calogContextEval(luaCtx, "@@@ not valid lua @@@", &result);
CHECK(status != calogOkE && result.type == calogStringE, "script error surfaced through result, not a crash");
calogValueFree(&result);
}
int main(void) {
const char *exposeNames[3];
CalogConfigT luaConfig;
broker = calogCreate();
if (broker == NULL) {
printf("broker create failed\n");
return 1;
}
// Worker context hosts doubleIt; it has no interpreter (a pure native owner).
calogContextCreate(broker, NULL, NULL, &workerCtx);
exposeNames[0] = "doubleIt";
exposeNames[1] = "report";
exposeNames[2] = "setCb";
luaConfig.exposeNames = exposeNames;
luaConfig.exposeCount = 3;
calogContextCreate(broker, &calogLuaEngine, &luaConfig, &luaCtx);
// setCb is owned by luaCtx, so register it once luaCtx exists (for its id), but
// before calogContextStart -- createInterpreter exposes the names on the thread.
calogRegister(broker, "doubleIt", nativeDoubleIt, NULL, calogContextId(workerCtx));
calogRegister(broker, "report", nativeReport, NULL, 0);
calogRegister(broker, "setCb", nativeSetCb, NULL, calogContextId(luaCtx));
calogContextStart(workerCtx);
calogContextStart(luaCtx);
testCrossContextFromScript();
testPureScript();
testScriptError();
testFailedInterpreter();
testCallbackAcrossContexts();
calogDestroy(broker);
printf("\n%d checks, %d failed\n", testsRun, testsFailed);
fflush(stdout);
if (testsFailed != 0) {
return 1;
}
return 0;
}