331 lines
14 KiB
C
331 lines
14 KiB
C
// testExit.c -- calogAbortAll: one native stops every script in the runtime.
|
|
//
|
|
// This is the contract API.md states for the runner's calogExit: it DOES NOT RETURN. A native that
|
|
// hands back calogAbortAll's value must unwind the CALLING script out of its chunk right at the call
|
|
// site, on every engine -- so each script below calls stopAll() and then spins in an infinite loop.
|
|
// The loop is the evidence: if the abort unwound the script it is never entered and the context
|
|
// retires within milliseconds, while a native that merely returned would pin that context forever.
|
|
// Native calls cannot serve as evidence here, because the latch refuses them either way.
|
|
//
|
|
// The same run also proves the three properties the abort must have: an aborted script is NOT
|
|
// reported to the error handler (it did not fail, and its engine must print nothing either), the
|
|
// runtime stays latched afterwards, and every later native call is refused instead of served.
|
|
|
|
#define _POSIX_C_SOURCE 200809L
|
|
|
|
#include "calog.h"
|
|
|
|
#include <stdatomic.h>
|
|
#include <stdbool.h>
|
|
#include <stdio.h>
|
|
#include <stdint.h>
|
|
#include <string.h>
|
|
#include <time.h>
|
|
|
|
#define CHECK(cond, msg) checkImpl((cond), (msg), __LINE__)
|
|
|
|
// s7 interns a small, bounded set of "permanent" strings it never reclaims (an s7 trait, not a
|
|
// leak), so suppress exactly that site to keep the leak check meaningful. LSan calls this weak hook.
|
|
const char *__lsan_default_suppressions(void);
|
|
const char *__lsan_default_suppressions(void) {
|
|
return "leak:make_permanent_string\n";
|
|
}
|
|
|
|
// A retiring context is joined by its own thread returning, so the wait is short in the passing
|
|
// case; the budget only has to cover a loaded machine (4000 * 0.5 ms = 2 s).
|
|
#define PUMP_INTERVAL_NS 500000
|
|
#define PUMP_LIMIT 4000
|
|
|
|
// One engine's script: reach mark(), abort, then loop forever if the abort let the script continue.
|
|
typedef struct EngineCaseT {
|
|
const CalogEngineT *engine;
|
|
const char *name;
|
|
const char *source;
|
|
} EngineCaseT;
|
|
|
|
// One engine's "hand it something broken" case. `reports` is whether that engine can still produce a
|
|
// diagnostic once the runtime is latched aborting. It can when its PARSER rejects the source before
|
|
// any instruction runs; it cannot when the engine only discovers the problem by running the script,
|
|
// because the interpreter hook unwinds it first. That is a property of the engine's architecture,
|
|
// not a policy choice -- see the table in main.
|
|
typedef struct BrokenCaseT {
|
|
const CalogEngineT *engine;
|
|
const char *name;
|
|
const char *source;
|
|
bool reports;
|
|
} BrokenCaseT;
|
|
|
|
static _Atomic int32_t markCount = 0;
|
|
static _Atomic int32_t errorCount = 0;
|
|
static int32_t testsRun = 0;
|
|
static int32_t testsFailed = 0;
|
|
|
|
static void checkEngine(bool condition, const char *engineName, const char *what, int32_t line);
|
|
static void checkImpl(bool condition, const char *message, int32_t line);
|
|
static int32_t nativeMark(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
|
|
static int32_t nativeStopAll(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData);
|
|
static void onError(uint64_t contextId, const char *message, void *userData);
|
|
static void runBrokenCase(const BrokenCaseT *item);
|
|
static void runCase(const EngineCaseT *item);
|
|
static void testLatchIsPerRuntime(void);
|
|
|
|
|
|
static void checkEngine(bool condition, const char *engineName, const char *what, int32_t line) {
|
|
char message[160];
|
|
|
|
snprintf(message, sizeof(message), "%s: %s", engineName, what);
|
|
checkImpl(condition, message, line);
|
|
}
|
|
|
|
|
|
static void checkImpl(bool condition, const char *message, int32_t line) {
|
|
testsRun++;
|
|
if (!condition) {
|
|
testsFailed++;
|
|
printf("FAIL testExit.c:%d %s\n", line, message);
|
|
}
|
|
}
|
|
|
|
|
|
// Host native: records that the script got this far. Registered like the runner's calogPrint, so the
|
|
// script blocks on the host thread for it -- the ordinary path a script reaches C through.
|
|
static int32_t nativeMark(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
|
|
(void)args;
|
|
(void)argCount;
|
|
(void)userData;
|
|
atomic_fetch_add(&markCount, 1);
|
|
calogValueNil(result);
|
|
return calogOkE;
|
|
}
|
|
|
|
|
|
// Inline native: what the runner's calogExit does, minus recording an exit code. Inline is what
|
|
// makes the abort reach THIS script -- an error raised on the host thread could not unwind it.
|
|
//
|
|
// calogCurrentRetire is the test's probe, not part of what calogExit does. Retirement is serviced
|
|
// only AFTER the current eval returns, so "this context's thread exited" is precisely "the eval
|
|
// returned" -- which is the property under test, and the one thing a script cannot report itself
|
|
// once every native call is refused. A script that kept running would sit in its loop forever and
|
|
// its thread would never exit.
|
|
static int32_t nativeStopAll(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) {
|
|
(void)args;
|
|
(void)argCount;
|
|
(void)userData;
|
|
calogValueNil(result);
|
|
calogCurrentRetire();
|
|
return calogAbortAll(calogCurrent(), result);
|
|
}
|
|
|
|
|
|
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)");
|
|
}
|
|
|
|
|
|
// The other half of the contract: an abort must swallow only the errors it CAUSED. Here the runtime
|
|
// is latched aborting BEFORE a script with a syntax error is handed to the engine -- the exact
|
|
// window a real run hits when one script calls calogExit while another is still being loaded. The
|
|
// script did not fail because of the abort; it was broken to begin with, and its author needs to be
|
|
// told so. Checked on all ten engines: the four whose eval API compiles and runs in a single call
|
|
// (QuickJS, Tcl, mruby, s7) could not tell the two apart and silently ate the diagnostic, while the
|
|
// engines that split load from run were already reporting it. See BrokenCaseT for the one engine
|
|
// that cannot report here at all, and why.
|
|
static void runBrokenCase(const BrokenCaseT *item) {
|
|
CalogT *calog;
|
|
CalogContextT *ctx;
|
|
CalogValueT result;
|
|
struct timespec tick = { 0, PUMP_INTERVAL_NS };
|
|
int32_t index;
|
|
|
|
atomic_store(&errorCount, 0);
|
|
|
|
calog = calogCreate();
|
|
if (calog == NULL) {
|
|
checkEngine(false, item->name, "runtime create failed", __LINE__);
|
|
return;
|
|
}
|
|
calogSetErrorHandler(calog, onError, NULL);
|
|
|
|
ctx = calogContextOpen(calog, item->engine);
|
|
if (ctx == NULL) {
|
|
checkEngine(false, item->name, "context open failed", __LINE__);
|
|
calogDestroy(calog);
|
|
return;
|
|
}
|
|
// Latch first: everything after this runs in the teardown window where the diagnostic used to
|
|
// disappear. Nothing has run in this context, so no abort has been raised into its VM.
|
|
calogValueNil(&result);
|
|
calogAbortAll(calog, &result);
|
|
calogValueFree(&result);
|
|
|
|
calogContextEval(ctx, item->source);
|
|
for (index = 0; index < PUMP_LIMIT; index++) {
|
|
calogPump(calog);
|
|
if (atomic_load(&errorCount) > 0) {
|
|
break;
|
|
}
|
|
nanosleep(&tick, NULL);
|
|
}
|
|
calogPump(calog);
|
|
|
|
if (item->reports) {
|
|
checkEngine(atomic_load(&errorCount) > 0, item->name, "a script that is broken still reports, mid-abort", __LINE__);
|
|
} else {
|
|
checkEngine(atomic_load(&errorCount) == 0, item->name, "engine finds syntax errors only by running, so the abort wins", __LINE__);
|
|
}
|
|
|
|
calogContextClose(ctx);
|
|
calogDestroy(calog);
|
|
}
|
|
|
|
|
|
// Run one engine's script on its own runtime (the latch is one-way, so each engine needs a fresh
|
|
// one), then check what the abort did.
|
|
static void runCase(const EngineCaseT *item) {
|
|
CalogT *calog;
|
|
CalogContextT *ctx;
|
|
CalogValueT result;
|
|
struct timespec tick = { 0, PUMP_INTERVAL_NS };
|
|
int32_t index;
|
|
bool finished;
|
|
|
|
atomic_store(&markCount, 0);
|
|
atomic_store(&errorCount, 0);
|
|
|
|
calog = calogCreate();
|
|
if (calog == NULL) {
|
|
checkEngine(false, item->name, "runtime create failed", __LINE__);
|
|
return;
|
|
}
|
|
calogSetErrorHandler(calog, onError, NULL);
|
|
calogRegister(calog, "mark", nativeMark, NULL);
|
|
calogRegisterInline(calog, "stopAll", nativeStopAll, NULL);
|
|
|
|
ctx = calogContextOpen(calog, item->engine);
|
|
if (ctx == NULL) {
|
|
checkEngine(false, item->name, "context open failed", __LINE__);
|
|
calogDestroy(calog);
|
|
return;
|
|
}
|
|
calogContextEval(ctx, item->source);
|
|
finished = false;
|
|
for (index = 0; index < PUMP_LIMIT; index++) {
|
|
calogPump(calog);
|
|
if (calogContextFinished(ctx)) {
|
|
finished = true;
|
|
break;
|
|
}
|
|
nanosleep(&tick, NULL);
|
|
}
|
|
calogPump(calog);
|
|
|
|
checkEngine(atomic_load(&markCount) == 1, item->name, "the script ran up to the abort", __LINE__);
|
|
checkEngine(finished, item->name, "the abort unwound the script -- the loop after it never ran", __LINE__);
|
|
checkEngine(atomic_load(&errorCount) == 0, item->name, "an aborted script is not reported as an error", __LINE__);
|
|
checkEngine(calogAborting(calog), item->name, "the runtime stays latched aborting", __LINE__);
|
|
|
|
calogValueNil(&result);
|
|
checkEngine(calogCall(calog, "mark", NULL, 0, &result) == calogErrAbortE, item->name, "a later native call is refused", __LINE__);
|
|
calogValueFree(&result);
|
|
checkEngine(atomic_load(&markCount) == 1, item->name, "the refused call never reached the native", __LINE__);
|
|
|
|
// A context that did NOT retire is still spinning in that infinite loop, and closing it would
|
|
// join a thread that never returns -- hanging the test instead of reporting the failure. Leak it
|
|
// (the check above has already failed) and move on to the next engine.
|
|
if (finished) {
|
|
calogContextClose(ctx);
|
|
calogDestroy(calog);
|
|
}
|
|
}
|
|
|
|
|
|
// The latch belongs to ONE runtime: aborting a runtime must not stop scripts in a second one that
|
|
// happens to share the process. This also covers calogAbortAll called from the host thread, which
|
|
// has no script of its own to unwind.
|
|
static void testLatchIsPerRuntime(void) {
|
|
CalogT *first;
|
|
CalogT *second;
|
|
CalogValueT result;
|
|
|
|
first = calogCreate();
|
|
second = calogCreate();
|
|
if (first == NULL || second == NULL) {
|
|
CHECK(false, "per-runtime latch: runtime create failed");
|
|
calogDestroy(first);
|
|
calogDestroy(second);
|
|
return;
|
|
}
|
|
calogRegister(first, "mark", nativeMark, NULL);
|
|
calogRegister(second, "mark", nativeMark, NULL);
|
|
atomic_store(&markCount, 0);
|
|
|
|
calogValueNil(&result);
|
|
CHECK(calogAbortAll(first, &result) == calogErrAbortE, "calogAbortAll reports calogErrAbortE");
|
|
calogValueFree(&result);
|
|
CHECK(calogAborting(first), "the aborted runtime is latched");
|
|
CHECK(!calogAborting(second), "a second runtime is untouched");
|
|
|
|
calogValueNil(&result);
|
|
CHECK(calogCall(first, "mark", NULL, 0, &result) == calogErrAbortE, "the aborted runtime refuses a native");
|
|
calogValueFree(&result);
|
|
calogValueNil(&result);
|
|
CHECK(calogCall(second, "mark", NULL, 0, &result) == calogOkE, "the second runtime still serves natives");
|
|
calogValueFree(&result);
|
|
CHECK(atomic_load(&markCount) == 1, "exactly one of the two calls ran the native");
|
|
|
|
calogDestroy(first);
|
|
calogDestroy(second);
|
|
}
|
|
|
|
|
|
int main(void) {
|
|
// Each script: reach the host, abort, then a loop that must never be entered. The loop bodies
|
|
// differ only in each language's syntax for "forever".
|
|
static const EngineCaseT cases[] = {
|
|
{ &calogLuaEngine, "lua", "mark()\nstopAll()\nwhile true do end" },
|
|
{ &calogJsEngine, "js", "mark(); stopAll(); while (true) {}" },
|
|
{ &calogSquirrelEngine, "squirrel", "mark(); stopAll(); while(true){}" },
|
|
{ &calogMyBasicEngine, "my-basic", "mark()\nstopAll()\nWHILE 1\nWEND" },
|
|
{ &calogBerryEngine, "berry", "mark()\nstopAll()\nwhile true\nend" },
|
|
{ &calogS7Engine, "s7", "(begin (mark) (stopAll) (do () (#f)))" }, // s7 reads ONE top-level form
|
|
{ &calogWrenEngine, "wren", "Calog.call(\"mark\", [])\nCalog.call(\"stopAll\", [])\nwhile (true) {}" },
|
|
{ &calogMrubyEngine, "mruby", "mark()\nstopAll()\nwhile true do end" },
|
|
{ &calogTclEngine, "tcl", "mark\nstopAll\nwhile {1} {}" },
|
|
{ &calogJanetEngine, "janet", "(mark) (stopAll) (var i 0) (while true (set i (+ i 1)))" }
|
|
};
|
|
// The same ten engines, each handed something it can only reject.
|
|
//
|
|
// Nine of them find the problem with their PARSER, before a single instruction runs, so the
|
|
// diagnostic survives the abort. my-basic is the exception, and not by choice: mb_load_string
|
|
// accepts almost anything and the error only appears once the statement RUNS -- by which point
|
|
// the step hook has already unwound the script, exactly as it must for Ctrl-C to work on a
|
|
// runaway loop. The two cannot both hold on an engine with no separate parse step, so the
|
|
// asymmetry is asserted here rather than papered over.
|
|
static const BrokenCaseT broken[] = {
|
|
{ &calogLuaEngine, "lua", "if if if", true },
|
|
{ &calogJsEngine, "js", "function ( { )", true },
|
|
{ &calogSquirrelEngine, "squirrel", "function ( { )", true },
|
|
{ &calogMyBasicEngine, "my-basic", "IF IF IF", false },
|
|
{ &calogBerryEngine, "berry", "def def def", true },
|
|
{ &calogS7Engine, "s7", "(((", true },
|
|
{ &calogWrenEngine, "wren", "class class class", true },
|
|
{ &calogMrubyEngine, "mruby", "def def def", true },
|
|
{ &calogTclEngine, "tcl", "set x [", true },
|
|
{ &calogJanetEngine, "janet", "(((", true }
|
|
};
|
|
size_t index;
|
|
|
|
for (index = 0; index < sizeof(cases) / sizeof(cases[0]); index++) {
|
|
runCase(&cases[index]);
|
|
}
|
|
for (index = 0; index < sizeof(broken) / sizeof(broken[0]); index++) {
|
|
runBrokenCase(&broken[index]);
|
|
}
|
|
testLatchIsPerRuntime();
|
|
|
|
printf("testExit: %d checks, %d failed\n", testsRun, testsFailed);
|
|
return testsFailed == 0 ? 0 : 1;
|
|
}
|