// 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 #include #include #include #include #include #include #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; }