calog/tests/testTeardown.c

617 lines
25 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 <pthread.h>
#include <stdatomic.h>
#include <stdbool.h>
#include <stdint.h>
#include <stdio.h>
#include <time.h>
#include <unistd.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;
// State for testDropInsideTheReclaimWindow: the host's own reference to a JS closure, plus the
// handshake that parks the dying context inside the exact window under test.
static CalogFnT *heldFn = NULL;
static uint64_t windowCtxId = 0;
static _Atomic bool inWindow = false;
static _Atomic bool dropDone = false;
// testDestroyJoinsWithCallInFlight: a regression here HANGS rather than failing a check, so a
// watchdog turns it back into a reportable failure. 15 s against a teardown that takes milliseconds.
#define DESTROY_WATCHDOG_SECONDS 15
static _Atomic bool destroyDone = false;
static void checkImpl(bool condition, const char *message, int32_t line);
static void *dropperThread(void *arg);
static int32_t nativeHold(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static int32_t nativeHostTick(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
static void windowShutdownHook(CalogContextT *context, void *userData);
static void *destroyWatchdogThread(void *arg);
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 testReclaimUnderConcurrentDrops(void);
static void testDropInsideTheReclaimWindow(void);
static void testDestroyJoinsWithCallInFlight(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);
}
}
// End THIS script, leaving the runtime and every other script running -- what taskExit does, and the
// only way a script ends itself. Inline, so it runs on the calling script's own thread. Deferred:
// the chunk finishes, then the context retires and its thread exits, which is the moment that
// matters here (an engine handle the script published must not outlive its VM).
static int32_t nativeEndSelf(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)args;
(void)argCount;
(void)userData;
calogValueNil(result);
calogCurrentRetire();
return calogOkE;
}
// Drops the host's reference from a thread that is NOT the dying context's, which is what makes the
// finalize unable to run the engine release itself.
static void *dropperThread(void *arg) {
struct timespec tick = { 0, PUMP_INTERVAL_NS };
(void)arg;
while (!atomic_load(&inWindow)) {
nanosleep(&tick, NULL);
}
calogFnRelease(heldFn); // last reference: commits a finalize on THIS thread
heldFn = NULL;
atomic_store(&dropDone, true);
return NULL;
}
// The host keeps its own reference to a closure the script hands over.
static int32_t nativeHold(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)userData;
calogValueNil(result);
if (argCount == 1 && args[0].type == calogFnE) {
heldFn = args[0].as.fn;
calogFnRetain(heldFn);
}
return calogOkE;
}
// If calogDestroy has not returned within the budget it is deadlocked and never will. Report the
// failure and end the process here: leaving it wedged would hang `make test` with no explanation,
// which is a far worse signal than a named failing check.
static void *destroyWatchdogThread(void *arg) {
struct timespec tick = { 0, PUMP_INTERVAL_NS };
int64_t waited;
(void)arg;
for (waited = 0; waited < (int64_t)DESTROY_WATCHDOG_SECONDS * 1000000000 / PUMP_INTERVAL_NS; waited++) {
if (atomic_load(&destroyDone)) {
return NULL;
}
nanosleep(&tick, NULL);
}
printf("FAIL testTeardown.c calogDestroy deadlocked with a callback in flight (waited %ds)\n",
DESTROY_WATCHDOG_SECONDS);
fflush(stdout);
_exit(1);
return NULL;
}
// Registered NON-inline on purpose: a script calling this marshals to the host thread and blocks
// waiting for the reply, which is the shape that used to deadlock teardown.
static int32_t nativeHostTick(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
(void)args;
(void)argCount;
(void)userData;
atomic_fetch_add(&reportValue, 1);
calogValueNil(result);
return calogOkE;
}
// Runs on the dying context's own thread, AFTER serveLoop has stopped serving (its queue is closed)
// and BEFORE contextReclaimCallables -- precisely the window where a foreign last-drop cannot be
// marshalled to this thread. Parking here holds that window open for as long as the test needs.
static void windowShutdownHook(CalogContextT *context, void *userData) {
struct timespec tick = { 0, PUMP_INTERVAL_NS };
(void)userData;
if (calogContextId(context) != windowCtxId) {
return;
}
atomic_store(&inWindow, true);
while (!atomic_load(&dropDone)) {
nanosleep(&tick, NULL);
}
}
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.
// The window contextReclaimCallables exists for, held open deliberately rather than raced for.
//
// A JavaScript context hands the HOST a closure. The host then drops its last reference from a
// different thread at the exact moment the context has stopped serving its queue but has not yet
// reclaimed -- the gap threadMain spends running the per-context shutdown hooks. That drop cannot be
// marshalled to the dying context's thread (its queue is closed), so the finalize doing it is not
// allowed to touch the interpreter. If it simply gave up there, the JS function would still be live
// inside a runtime that is about to assert it owns nothing, and this binary would abort.
//
// The shutdown hook itself is what makes this deterministic: it runs inside the window, on the
// context's own thread, and parks there until the drop has happened.
static void testDropInsideTheReclaimWindow(void) {
CalogContextT *ctx;
pthread_t dropper;
heldFn = NULL;
windowCtxId = 0;
atomic_store(&inWindow, false);
atomic_store(&dropDone, false);
atomic_store(&readyFlag, false);
calog = calogCreate();
if (calog == NULL) {
CHECK(false, "reclaim window: runtime create failed");
return;
}
calogSetErrorHandler(calog, onError, NULL);
calogRegisterInline(calog, "ready", nativeReady, NULL);
calogRegisterInline(calog, "hold", nativeHold, NULL);
calogAtContext(calog, NULL, windowShutdownHook, NULL);
ctx = calogContextOpen(calog, &calogJsEngine);
if (ctx == NULL) {
CHECK(false, "reclaim window: context open failed");
calogDestroy(calog);
return;
}
windowCtxId = calogContextId(ctx);
calogContextEval(ctx, "hold(function () { return 1; }); ready();");
pumpUntilReady();
CHECK(heldFn != NULL, "reclaim window: the host holds a reference to the JS closure");
if (pthread_create(&dropper, NULL, dropperThread, NULL) != 0) {
CHECK(false, "reclaim window: could not start the dropper thread");
calogContextClose(ctx);
calogDestroy(calog);
return;
}
// Blocks: stops the context serving, runs the hook (which parks until the drop lands), and only
// then reclaims and destroys the interpreter.
calogContextClose(ctx);
pthread_join(dropper, NULL);
CHECK(atomic_load(&dropDone), "reclaim window: the last reference was dropped inside the window");
calogDestroy(calog);
CHECK(true, "reclaim window: the interpreter was destroyed with no handle stranded inside it");
printf(" reclaim window: a foreign last-drop landed between queue-close and reclaim\n");
}
// calogDestroy must not hang when a background thread is mid-callback into a context.
//
// The before-contexts destroy hooks stop background threads by JOINING them, and they run on the
// host thread -- which, being inside calogDestroy, will never pump again. A timer callback in flight
// that calls a HOST-thread native therefore produced a three-way cycle: the host waits on the timer
// thread, the timer thread waits on the context serving its callback, and that context waits for a
// host reply nobody is left to deliver. Measured before the fix: 4 of 5 runs hung outright.
//
// A regression does not fail an assertion, it wedges the binary, so a watchdog turns it back into a
// named failure -- `make test` reports something actionable instead of hanging with no explanation.
// Measured with the fix bypassed: caught on 5 runs out of 5.
//
// The loop below must run to completion and nothing may come between its last pump and calogDestroy:
// the cycle needs a callback genuinely in flight, and any pause lets the outstanding call drain.
static void testDestroyJoinsWithCallInFlight(void) {
CalogContextT *ctx;
pthread_t watchdog;
struct timespec tick = { 0, PUMP_INTERVAL_NS };
int32_t index;
atomic_store(&reportValue, 0);
calog = calogCreate();
if (calog == NULL) {
CHECK(false, "destroy-with-call-in-flight: runtime create failed");
return;
}
calogSetErrorHandler(calog, onError, NULL);
calogRegister(calog, "hostTick", nativeHostTick, NULL); // host-thread native, on purpose
calogTimerRegister(calog);
ctx = calogContextOpen(calog, &calogLuaEngine);
if (ctx == NULL) {
CHECK(false, "destroy-with-call-in-flight: context open failed");
calogDestroy(calog);
return;
}
calogContextEval(ctx, "timerEvery(1, function() hostTick() end)");
// Pump the FULL budget rather than stopping at the first tick: the cycle needs a callback
// actually in flight when calogDestroy runs, so the timer has to be firing steadily into the
// host at that moment. Stopping early lands teardown in a quiet gap and the hang does not
// reproduce -- measured, which is why this loop has no early exit.
for (index = 0; index < PUMP_LIMIT; index++) {
calogPump(calog);
nanosleep(&tick, NULL);
}
CHECK(atomic_load(&reportValue) > 0, "destroy-with-call-in-flight: the timer callback reached the host");
atomic_store(&destroyDone, false);
if (pthread_create(&watchdog, NULL, destroyWatchdogThread, NULL) != 0) {
CHECK(false, "destroy-with-call-in-flight: could not start the watchdog");
}
calogDestroy(calog);
atomic_store(&destroyDone, true);
pthread_join(watchdog, NULL);
CHECK(true, "destroy-with-call-in-flight: calogDestroy returned instead of hanging on the join");
printf(" destroy with a timer callback mid-call into the host\n");
}
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 -- 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);
}
// The reclaim sweep runs on a dying context's own thread while OTHER threads still hold references
// to its callables and can drop them at any moment -- the timer thread retains a callback, invokes
// it, and releases. A drop that lands between the sweep taking the list and the sweep taking its own
// reference would have it adopt a callable whose finalize was already committed. The sweep now does
// both in one critical section with a conditional retain, so that cannot happen; this churns the
// path to keep it honest. QuickJS is deliberate again: it aborts if a handle outlives its VM.
static void testReclaimUnderConcurrentDrops(void) {
struct timespec tick = { 0, PUMP_INTERVAL_NS };
int32_t round;
int32_t i;
printf(" reclaim under concurrent drops: 20 rounds of close-while-firing\n");
fflush(stdout);
for (round = 0; round < 20; round++) {
CalogContextT *ctx;
startRuntime();
if (calog == NULL) {
return;
}
ctx = calogContextOpen(calog, &calogJsEngine);
if (ctx == NULL) {
CHECK(false, "reclaim churn: context open failed");
calogDestroy(calog);
return;
}
// A fast repeating timer plus a subscriber: the timer thread is retaining, invoking and
// releasing this context's callable continuously while the close below tears it down.
calogContextEval(ctx,
"timerEvery(1, function () {});"
"psSubscribe('t', function () {});"
"calogExport('e' + Math.random(), function () {});"
"ready();");
pumpUntilReady();
for (i = 0; i < 8; i++) {
calogPump(calog);
nanosleep(&tick, NULL);
}
calogContextClose(ctx); // joins: the reclaim sweep runs here, timer thread still live
calogPump(calog);
calogDestroy(calog);
}
CHECK(true, "closing a context while its callables are being dropped elsewhere is clean");
}
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",
"psSubscribe('t', function () {}); ready(); endSelf();", false);
testCrossEngineValueOutlivesOwner();
testReclaimUnderConcurrentDrops();
testDropInsideTheReclaimWindow();
testDestroyJoinsWithCallInFlight();
testGuardsAfterShutdown();
printf("\n%d checks, %d failed\n", testsRun, testsFailed);
fflush(stdout);
return testsFailed == 0 ? 0 : 1;
}