347 lines
14 KiB
C
347 lines
14 KiB
C
// testTeardown.c -- a library registry that holds a SCRIPT function must let go of it while the
|
|
// owning context is still alive.
|
|
//
|
|
// A CalogFnT is a handle to a function living inside a VM. If a process-global registry (a pubsub
|
|
// subscriber, an exported function, a timer callback) still holds one when that context's
|
|
// interpreter is destroyed, the release arrives too late to run the engine's own release hook and
|
|
// the script object is never freed inside its VM. That is what the two destroy phases are for: a
|
|
// library holding context-owned references registers calogDestroyBeforeContextsE, so calogDestroy
|
|
// drains it while every context is still serving its queue.
|
|
//
|
|
// Most VMs hide the mistake -- they free everything on close -- so this test uses JavaScript on
|
|
// purpose: QuickJS asserts it owns no live objects at JS_FreeRuntime and ABORTS the process. Every
|
|
// case here therefore checks the same thing in the end: that we are still running afterwards. A
|
|
// wrong phase does not fail a check, it kills the binary (and LeakSanitizer catches the milder
|
|
// variant, a registry resurrected after its shutdown).
|
|
|
|
#define _POSIX_C_SOURCE 200809L
|
|
|
|
#include "calog.h"
|
|
|
|
#include "calogExport.h"
|
|
#include "calogPubsub.h"
|
|
#include "calogTimer.h"
|
|
#include "calogInternal.h" // calogPubsubShutdown/calogExportShutdown: internal, driven by hand here
|
|
|
|
#include <stdatomic.h>
|
|
#include <stdbool.h>
|
|
#include <stdint.h>
|
|
#include <stdio.h>
|
|
#include <time.h>
|
|
|
|
#define CHECK(cond, msg) checkImpl((cond), (msg), __LINE__)
|
|
|
|
#define PUMP_INTERVAL_NS 500000
|
|
#define PUMP_LIMIT 4000
|
|
|
|
static CalogT *calog = NULL;
|
|
static _Atomic bool readyFlag = false;
|
|
static _Atomic int32_t reportValue = -1;
|
|
static _Atomic int32_t errorCount = 0;
|
|
static int32_t testsRun = 0;
|
|
static int32_t testsFailed = 0;
|
|
|
|
static void checkImpl(bool condition, const char *message, int32_t line);
|
|
static int32_t nativeEndSelf(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
|
|
static int32_t nativeReady(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
|
|
static int32_t nativeReport(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
|
|
static void onError(uint64_t contextId, const char *message, void *userData);
|
|
static void pumpUntilReady(void);
|
|
static void startRuntime(void);
|
|
static void testCrossEngineValueOutlivesOwner(void);
|
|
static void testGuardsAfterShutdown(void);
|
|
static void testHeldCallableSurvivesTeardown(const char *what, const char *source);
|
|
static void testOwnerDiesBeforeTheRuntime(const char *what, const char *source, bool expectError);
|
|
|
|
|
|
static void checkImpl(bool condition, const char *message, int32_t line) {
|
|
testsRun++;
|
|
if (!condition) {
|
|
testsFailed++;
|
|
printf("FAIL testTeardown.c:%d %s\n", line, message);
|
|
}
|
|
}
|
|
|
|
|
|
// What the runner's calogEnd does: end THIS script, leaving the runtime and every other script
|
|
// running. Inline, so it runs on the calling script's own thread and can unwind it.
|
|
static int32_t nativeEndSelf(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
|
|
(void)args;
|
|
(void)argCount;
|
|
(void)userData;
|
|
calogValueNil(result);
|
|
return calogAbortCurrent(result);
|
|
}
|
|
|
|
|
|
static int32_t nativeReady(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
|
|
(void)args;
|
|
(void)argCount;
|
|
(void)userData;
|
|
atomic_store(&readyFlag, true);
|
|
calogValueNil(result);
|
|
return calogOkE;
|
|
}
|
|
|
|
|
|
static int32_t nativeReport(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
|
|
(void)userData;
|
|
calogValueNil(result);
|
|
if (argCount == 1 && args[0].type == calogBoolE) {
|
|
atomic_store(&reportValue, args[0].as.b ? 1 : 0);
|
|
}
|
|
return calogOkE;
|
|
}
|
|
|
|
|
|
static void onError(uint64_t contextId, const char *message, void *userData) {
|
|
(void)contextId;
|
|
(void)userData;
|
|
atomic_fetch_add(&errorCount, 1);
|
|
printf(" (error handler saw: %s)\n", message != NULL ? message : "(null)");
|
|
}
|
|
|
|
|
|
static void pumpUntilReady(void) {
|
|
struct timespec tick = { 0, PUMP_INTERVAL_NS };
|
|
int32_t index;
|
|
|
|
for (index = 0; index < PUMP_LIMIT; index++) {
|
|
calogPump(calog);
|
|
if (atomic_load(&readyFlag)) {
|
|
calogPump(calog);
|
|
return;
|
|
}
|
|
nanosleep(&tick, NULL);
|
|
}
|
|
}
|
|
|
|
|
|
// A runtime with the three libraries that hold script functions, plus the two natives a script
|
|
// signals through.
|
|
static void startRuntime(void) {
|
|
calog = calogCreate();
|
|
if (calog == NULL) {
|
|
CHECK(false, "runtime create failed");
|
|
return;
|
|
}
|
|
calogSetErrorHandler(calog, onError, NULL);
|
|
calogRegisterInline(calog, "ready", nativeReady, NULL);
|
|
calogRegisterInline(calog, "report", nativeReport, NULL);
|
|
calogRegisterInline(calog, "endSelf", nativeEndSelf, NULL);
|
|
calogPubsubRegister(calog);
|
|
calogExportRegister(calog);
|
|
calogTimerRegister(calog);
|
|
atomic_store(&readyFlag, false);
|
|
atomic_store(&reportValue, -1);
|
|
atomic_store(&errorCount, 0);
|
|
}
|
|
|
|
|
|
// Once a library's registry has been drained, a native that would store a NEW script function in it
|
|
// has to fail: re-growing a registry that nothing will ever free again would strand the callable it
|
|
// holds (the very leak the phase fix exists to prevent). Reads stay safe, which is what the pubsub
|
|
// and export tests already rely on.
|
|
static void testGuardsAfterShutdown(void) {
|
|
CalogContextT *ctx;
|
|
|
|
startRuntime();
|
|
if (calog == NULL) {
|
|
return;
|
|
}
|
|
ctx = calogContextOpen(calog, &calogLuaEngine);
|
|
if (ctx == NULL) {
|
|
CHECK(false, "guards: context open failed");
|
|
calogDestroy(calog);
|
|
return;
|
|
}
|
|
|
|
calogPubsubShutdown();
|
|
calogExportShutdown();
|
|
|
|
// pcall yields (ok, err), so bind ok first -- report takes exactly one value.
|
|
calogContextEval(ctx, "local ok = pcall(psSubscribe, 't', function() end)\n report(ok)\n ready()");
|
|
pumpUntilReady();
|
|
CHECK(atomic_load(&reportValue) == 0, "psSubscribe after the pubsub registry is drained fails cleanly");
|
|
|
|
atomic_store(&readyFlag, false);
|
|
atomic_store(&reportValue, -1);
|
|
calogContextEval(ctx, "local ok = pcall(calogExport, 'e', function() end)\n report(ok)\n ready()");
|
|
pumpUntilReady();
|
|
CHECK(atomic_load(&reportValue) == 0, "calogExport after the export registry is drained fails cleanly");
|
|
|
|
// Reads stay safe either way: a publish simply finds nobody, and an unknown global still
|
|
// resolves to nil through the export hook.
|
|
atomic_store(&readyFlag, false);
|
|
atomic_store(&reportValue, -1);
|
|
calogContextEval(ctx, "report(psPublish('t', 1) == 0 and someUndefinedGlobalName == nil)\n ready()");
|
|
pumpUntilReady();
|
|
CHECK(atomic_load(&reportValue) == 1, "publishing and resolving after shutdown stay safe");
|
|
|
|
calogContextClose(ctx);
|
|
calogDestroy(calog);
|
|
}
|
|
|
|
|
|
// Run source on a JavaScript context, leave whatever it registered in place, and tear the runtime
|
|
// down with calogDestroy alone -- the path a real host takes. Surviving IS the check.
|
|
static void testHeldCallableSurvivesTeardown(const char *what, const char *source) {
|
|
CalogContextT *ctx;
|
|
|
|
printf(" teardown case: %s\n", what);
|
|
fflush(stdout);
|
|
startRuntime();
|
|
if (calog == NULL) {
|
|
return;
|
|
}
|
|
ctx = calogContextOpen(calog, &calogJsEngine);
|
|
if (ctx == NULL) {
|
|
checkImpl(false, what, __LINE__);
|
|
calogDestroy(calog);
|
|
return;
|
|
}
|
|
calogContextEval(ctx, source);
|
|
pumpUntilReady();
|
|
checkImpl(atomic_load(&readyFlag), what, __LINE__);
|
|
checkImpl(atomic_load(&errorCount) == 0, "the script registered its callbacks without error", __LINE__);
|
|
// No calogContextClose and no by-hand library shutdown: calogDestroy must get the order right
|
|
// on its own, which is exactly what the destroy-hook phases are for.
|
|
calogDestroy(calog);
|
|
checkImpl(true, what, __LINE__);
|
|
}
|
|
|
|
|
|
// The other half of the rule, and the one the destroy phases cannot reach: a context that dies while
|
|
// the RUNTIME lives on -- a script that errors out, or one that ends itself with calogEnd -- has to
|
|
// reclaim its handles just the same. Its interpreter is destroyed on its own thread the moment it
|
|
// stops, long before anyone tears the runtime down.
|
|
static void testOwnerDiesBeforeTheRuntime(const char *what, const char *source, bool expectError) {
|
|
CalogContextT *ctx;
|
|
struct timespec tick = { 0, PUMP_INTERVAL_NS };
|
|
int32_t index;
|
|
bool finished;
|
|
|
|
printf(" early-death case: %s\n", what);
|
|
fflush(stdout);
|
|
startRuntime();
|
|
if (calog == NULL) {
|
|
return;
|
|
}
|
|
ctx = calogContextOpen(calog, &calogJsEngine);
|
|
if (ctx == NULL) {
|
|
checkImpl(false, what, __LINE__);
|
|
calogDestroy(calog);
|
|
return;
|
|
}
|
|
calogContextEval(ctx, source);
|
|
// Wait for the context's own thread to exit: an errored script is retired by the test's error
|
|
// handler doing nothing at all -- the context ends because the script ended it (endSelf) or
|
|
// because we close it below.
|
|
finished = false;
|
|
for (index = 0; index < PUMP_LIMIT; index++) {
|
|
calogPump(calog);
|
|
if (calogContextFinished(ctx)) {
|
|
finished = true;
|
|
break;
|
|
}
|
|
if (expectError && atomic_load(&errorCount) > 0) {
|
|
break;
|
|
}
|
|
nanosleep(&tick, NULL);
|
|
}
|
|
if (expectError) {
|
|
checkImpl(atomic_load(&errorCount) > 0, "the script's failure was reported", __LINE__);
|
|
} else {
|
|
checkImpl(finished, "endSelf ended the script, so its context can be reaped", __LINE__);
|
|
checkImpl(atomic_load(&errorCount) == 0, "ending a script is not reported as a failure", __LINE__);
|
|
}
|
|
// Closing joins the thread, which is where the interpreter is destroyed -- the moment a handle
|
|
// that outlived its VM would take the process down.
|
|
calogContextClose(ctx);
|
|
calogPump(calog);
|
|
checkImpl(true, what, __LINE__);
|
|
calogDestroy(calog);
|
|
}
|
|
|
|
|
|
// A function value that crossed engines: JavaScript hands its own closure to a Lua script, which
|
|
// keeps it, and then the JavaScript context dies. No library registry holds the closure -- the other
|
|
// VM does -- so only reclaiming on the owner's own thread can save it.
|
|
static void testCrossEngineValueOutlivesOwner(void) {
|
|
CalogContextT *holder;
|
|
CalogContextT *giver;
|
|
struct timespec tick = { 0, PUMP_INTERVAL_NS };
|
|
int32_t index;
|
|
|
|
printf(" early-death case: a JS closure kept by a Lua script when JS dies\n");
|
|
fflush(stdout);
|
|
startRuntime();
|
|
if (calog == NULL) {
|
|
return;
|
|
}
|
|
holder = calogContextOpen(calog, &calogLuaEngine);
|
|
giver = calogContextOpen(calog, &calogJsEngine);
|
|
if (holder == NULL || giver == NULL) {
|
|
checkImpl(false, "cross-engine: context open failed", __LINE__);
|
|
calogDestroy(calog);
|
|
return;
|
|
}
|
|
calogContextEval(holder, "held = nil\n calogExport('keep', function(fn) held = fn return 1 end)\n ready()");
|
|
pumpUntilReady();
|
|
checkImpl(atomic_load(&readyFlag), "the Lua holder published its keep() export", __LINE__);
|
|
|
|
atomic_store(&readyFlag, false);
|
|
calogContextEval(giver, "calogCall('keep', function () { return 42; }); ready(); endSelf();");
|
|
pumpUntilReady();
|
|
for (index = 0; index < PUMP_LIMIT; index++) {
|
|
calogPump(calog);
|
|
if (calogContextFinished(giver)) {
|
|
break;
|
|
}
|
|
nanosleep(&tick, NULL);
|
|
}
|
|
checkImpl(calogContextFinished(giver), "the giving context ended itself", __LINE__);
|
|
calogContextClose(giver); // joins: the JS interpreter is destroyed here
|
|
calogPump(calog);
|
|
checkImpl(atomic_load(&errorCount) == 0, "no error while the JS owner went away", __LINE__);
|
|
|
|
// The Lua script still holds the (now dead) closure. Invoking it must fail cleanly rather than
|
|
// reach into the destroyed VM, and dropping it must not double-free anything.
|
|
atomic_store(&readyFlag, false);
|
|
atomic_store(&reportValue, -1);
|
|
calogContextEval(holder, "local ok = pcall(held)\n report(ok)\n held = nil\n ready()");
|
|
pumpUntilReady();
|
|
checkImpl(atomic_load(&reportValue) == 0, "invoking a callable whose owner is gone fails cleanly", __LINE__);
|
|
|
|
calogContextClose(holder);
|
|
calogDestroy(calog);
|
|
}
|
|
|
|
|
|
int main(void) {
|
|
testHeldCallableSurvivesTeardown("a JS subscriber still registered at teardown",
|
|
"psSubscribe('t', function () { return 1; }); ready();");
|
|
testHeldCallableSurvivesTeardown("a JS export still registered at teardown",
|
|
"calogExport('e', function () { return 1; }); ready();");
|
|
testHeldCallableSurvivesTeardown("a JS timer callback still armed at teardown",
|
|
"timerEvery(1000, function () {}); ready();");
|
|
testHeldCallableSurvivesTeardown("all three at once, several callbacks each",
|
|
"psSubscribe('a', function () {}); psSubscribe('b', function () {});"
|
|
"calogExport('x', function () {}); calogExport('y', function () {});"
|
|
"timerEvery(1000, function () {}); timerAfter(1000, function () {});"
|
|
"ready();");
|
|
testOwnerDiesBeforeTheRuntime("a JS subscriber whose script then errors out",
|
|
"psSubscribe('t', function () {}); ready(); throw new Error('boom');", true);
|
|
testOwnerDiesBeforeTheRuntime("a JS export whose script then errors out",
|
|
"calogExport('e', function () {}); ready(); throw new Error('boom');", true);
|
|
testOwnerDiesBeforeTheRuntime("a JS timer callback whose script then errors out",
|
|
"timerEvery(1000, function () {}); ready(); throw new Error('boom');", true);
|
|
testOwnerDiesBeforeTheRuntime("a JS subscriber whose script ends itself (calogEnd)",
|
|
"psSubscribe('t', function () {}); ready(); endSelf();", false);
|
|
testCrossEngineValueOutlivesOwner();
|
|
testGuardsAfterShutdown();
|
|
|
|
printf("\n%d checks, %d failed\n", testsRun, testsFailed);
|
|
fflush(stdout);
|
|
return testsFailed == 0 ? 0 : 1;
|
|
}
|