// testEngineMyBasic.c -- my-basic on context threads under the actor model. Verifies a // host native is serviced on the host thread, and that several my-basic contexts run // concurrently without corruption -- my-basic keeps process-global state, so the engine // serializes my-basic work with a lock; this is the test that would race without it. // Built under ASan+UBSan (make test) and ThreadSanitizer (make tsanmb). #define _POSIX_C_SOURCE 200809L #include "calog.h" #include #include #include #define CHECK(cond, msg) checkImpl((cond), (msg), __FILE__, __LINE__) #define PUMP_LIMIT 4000 #define CONTEXT_COUNT 3 // A value well beyond 32 bits: my-basic's int_t is a 64-bit long long in calog's fork, so this // round-trips as a literal, through arithmetic, and both across the host boundary. 5e9 > 2^32. #define WIDE_VALUE 5000000000LL #define WIDE_ADDEND 1000000000LL #define WIDE_SUM (WIDE_VALUE + WIDE_ADDEND) static CalogT *calog = NULL; static _Atomic int32_t bumpCount = 0; static _Atomic uint64_t bumpCtxId = 0xFFFFu; static _Atomic int64_t reportedValue = 0; static int32_t testsRun = 0; static int32_t testsFailed = 0; static int32_t blobEcho(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t blobLen(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t bump(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static void checkImpl(bool condition, const char *message, const char *file, int32_t line); static int64_t evalAndReport(const char *script); static int32_t makeBlob(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t makeBlob2(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t nativeAdd(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t nativeGetAdder(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t nativeGetWide(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static int32_t report(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData); static void testBinaryStrings(void); static void testConcurrentContexts(void); static void testForeignFunction(void); static void testHostNative(void); static void testWideInteger(void); static int32_t blobEcho(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)userData; calogValueNil(result); if (argCount != 1 || args[0].type != calogStringE) { return calogFail(result, calogErrArgE, "blobEcho expects one string"); } // Hand the same bytes straight back; the script re-ingresses them, exercising the egress -> // ingress round-trip for a binary buffer (the httpd tcpRecv -> manipulate -> tcpSend path). return calogValueString(result, args[0].as.s.bytes, args[0].as.s.length); } static int32_t blobLen(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)userData; calogValueNil(result); if (argCount != 1 || args[0].type != calogStringE) { return calogFail(result, calogErrArgE, "blobLen expects one string"); } // Reports the egress byte length: a byte buffer must arrive as a length-3 string, not truncated // to length 1 at the embedded NUL. calogValueInt(result, args[0].as.s.length); return calogOkE; } static int32_t bump(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)args; (void)argCount; (void)userData; atomic_store(&bumpCtxId, calogCurrentId()); atomic_fetch_add(&bumpCount, 1); calogValueNil(result); return calogOkE; } 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); } } // Open a fresh my-basic context, run `script` (which must end by calling bump()), pump until it // finishes, and return whatever the script last handed to report(). Serialises the boilerplate the // foreign-function and wide-integer tests share. static int64_t evalAndReport(const char *script) { CalogContextT *ctx; struct timespec ts = { 0, 500000 }; int32_t i; ctx = calogContextOpen(calog, &calogMyBasicEngine); atomic_store(&bumpCount, 0); atomic_store(&reportedValue, 0); calogContextEval(ctx, script); for (i = 0; i < PUMP_LIMIT && atomic_load(&bumpCount) < 1; i++) { calogPump(calog); nanosleep(&ts, NULL); } calogContextClose(ctx); return atomic_load(&reportedValue); } static int32_t makeBlob(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)args; (void)argCount; (void)userData; // A 3-byte binary value with an embedded NUL ('a', 0x00, 'b'). Because it holds a NUL, it // ingresses to my-basic as a byte buffer rather than a (truncated) string. return calogValueString(result, "a\0b", 3); } static int32_t makeBlob2(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)args; (void)argCount; (void)userData; // Same length as makeBlob() but differs only AFTER the NUL, so a NUL-terminated compare would // wrongly call it equal -- the byte compare must look past the NUL. return calogValueString(result, "a\0c", 3); } static int32_t nativeAdd(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)userData; calogValueNil(result); if (argCount != 2 || args[0].type != calogIntE || args[1].type != calogIntE) { return calogFail(result, calogErrArgE, "add expects two integers"); } calogValueInt(result, args[0].as.i + args[1].as.i); return calogOkE; } static int32_t nativeGetAdder(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { CalogFnT *callable; int32_t status; (void)args; (void)argCount; (void)userData; calogValueNil(result); // Hand the script a host-owned function value; invoking it from BASIC routes back to nativeAdd. status = calogFnFromNative(&callable, calog, nativeAdd, NULL); if (status != calogOkE) { return calogFail(result, status, "getAdder could not allocate"); } calogValueFn(result, callable); return calogOkE; } static int32_t nativeGetWide(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)args; (void)argCount; (void)userData; // A 64-bit value handed into the script, to prove ingress no longer clamps at 2^31. calogValueInt(result, WIDE_VALUE); return calogOkE; } static int32_t report(CalogValueT *args, int32_t argCount, CalogValueT *result, void *userData) { (void)userData; calogValueNil(result); if (argCount != 1 || args[0].type != calogIntE) { return calogFail(result, calogErrArgE, "report expects one integer"); } atomic_store(&reportedValue, args[0].as.i); return calogOkE; } // Binary byte buffers: my-basic's faithful handling of calog strings that carry embedded NUL bytes, // via the adapter's usertype-ref BYTE type. Non-zero sentinels (42, 11) are reported on success so a // script that errored (leaving reportedValue at 0) fails the check instead of passing silently. static void testBinaryStrings(void) { int64_t got; // Egress: a byte buffer handed to a native arrives as a full length-3 string, not truncated to 1. got = evalAndReport("report(blobLen(makeBlob()))\nbump()"); CHECK(got == 3, "a byte buffer egresses to the host as a full-length binary string"); // byteLen sees the true byte count. got = evalAndReport("b = makeBlob()\nreport(byteLen(b))\nbump()"); CHECK(got == 3, "byteLen reports the full length including the embedded NUL"); // byteAt reads every byte, the embedded NUL included. got = evalAndReport( "b = makeBlob()\n" "r = 0\n" "IF byteAt(b, 0) = 97 THEN\n" "IF byteAt(b, 1) = 0 THEN\n" "IF byteAt(b, 2) = 98 THEN\n" "r = 42\n" "ENDIF\nENDIF\nENDIF\n" "report(r)\nbump()"); CHECK(got == 42, "byteAt reads each byte including the embedded NUL as 0"); // '+' concatenates byte buffers and the interior NUL survives. got = evalAndReport( "b = makeBlob()\n" "c = b + b\n" "r = 0\n" "IF byteLen(c) = 6 THEN\n" "IF byteAt(c, 4) = 0 THEN\n" "IF byteAt(c, 3) = 97 THEN\n" "r = 42\n" "ENDIF\nENDIF\nENDIF\n" "report(r)\nbump()"); CHECK(got == 42, "the + operator concatenates byte buffers and preserves the interior NUL"); // Mixed concat: a text prefix plus a binary body (the httpd response-building case). got = evalAndReport( "b = makeBlob()\n" "c = \"X\" + b\n" "r = 0\n" "IF byteLen(c) = 4 THEN\n" "IF byteAt(c, 0) = 88 THEN\n" "r = 42\n" "ENDIF\nENDIF\n" "report(r)\nbump()"); CHECK(got == 42, "a string prefix concatenates with a byte buffer (string + bytes)"); // Content equality via the fork's comparison patch: distinct buffers, identical bytes. got = evalAndReport("b = makeBlob()\nd = makeBlob()\nIF b = d THEN\nreport(11)\nELSE\nreport(22)\nENDIF\nbump()"); CHECK(got == 11, "= compares byte buffers by content (equal content compares equal)"); // ...and buffers that differ only past the NUL compare unequal. got = evalAndReport("b = makeBlob()\ne = makeBlob2()\nIF b = e THEN\nreport(11)\nELSE\nreport(22)\nENDIF\nbump()"); CHECK(got == 22, "= distinguishes byte buffers that differ only after the NUL"); // A byte buffer works as a content-addressed dict key (the hash and cmp hooks agree). got = evalAndReport( "m = DICT()\n" "b = makeBlob()\n" "e = makeBlob2()\n" "SET(m, b, 10)\n" "SET(m, e, 20)\n" "report(GET(m, makeBlob()))\nbump()"); CHECK(got == 10, "a byte buffer is a content-addressed dict key"); // byteSlice extracts a sub-range, NUL included, clamped to the buffer. got = evalAndReport( "b = makeBlob()\n" "s = byteSlice(b, 1, 2)\n" "r = 0\n" "IF byteLen(s) = 2 THEN\n" "IF byteAt(s, 0) = 0 THEN\n" "IF byteAt(s, 1) = 98 THEN\n" "r = 42\n" "ENDIF\nENDIF\nENDIF\n" "report(r)\nbump()"); CHECK(got == 42, "byteSlice extracts a byte sub-range spanning the NUL"); // byteConcat joins several parts (byte buffers and strings) into one buffer. got = evalAndReport("report(byteLen(byteConcat(makeBlob(), \"X\", makeBlob())))\nbump()"); CHECK(got == 7, "byteConcat joins byte buffers and strings (3 + 1 + 3 = 7)"); // Round-trip: egress to a native and back keeps the bytes. got = evalAndReport("report(byteLen(blobEcho(makeBlob())))\nbump()"); CHECK(got == 3, "a byte buffer round-trips out to a native and back intact"); // strToByte / byteToStr round-trip NUL-free text. got = evalAndReport("IF byteToStr(strToByte(\"hi\")) = \"hi\" THEN\nreport(11)\nELSE\nreport(22)\nENDIF\nbump()"); CHECK(got == 11, "strToByte and byteToStr round-trip NUL-free text"); } static void testConcurrentContexts(void) { CalogContextT *ctxs[CONTEXT_COUNT]; struct timespec ts = { 0, 500000 }; int32_t i; // The scripts allocate (list creation) as they run so the parallel execution paths // churn my-basic's now-atomic allocation counter concurrently -- the case that // raced before the vendored patch (see make tsanmb). atomic_store(&bumpCount, 0); for (i = 0; i < CONTEXT_COUNT; i++) { ctxs[i] = calogContextOpen(calog, &calogMyBasicEngine); calogContextEval(ctxs[i], "a = list(1, 2, 3)\nb = list(4, 5, 6)\nbump()"); } for (i = 0; i < PUMP_LIMIT && atomic_load(&bumpCount) < CONTEXT_COUNT; i++) { calogPump(calog); nanosleep(&ts, NULL); } CHECK(atomic_load(&bumpCount) == CONTEXT_COUNT, "several concurrent my-basic contexts all reached the host native"); for (i = 0; i < CONTEXT_COUNT; i++) { calogContextClose(ctxs[i]); } } static void testForeignFunction(void) { int64_t got; // getAdder() yields a host callable; calogInvoke(fn, ...) calls it from BASIC. This is the // my-basic side of function-into-script -- the fork's usertype-ref + calogInvoke native. got = evalAndReport("f = getAdder()\nreport(calogInvoke(f, 2, 3))\nbump()"); CHECK(got == 5, "my-basic invoked a host function value handed into the script"); } static void testHostNative(void) { CalogContextT *ctx; struct timespec ts = { 0, 500000 }; int32_t i; ctx = calogContextOpen(calog, &calogMyBasicEngine); CHECK(ctx != NULL, "opened a my-basic context"); atomic_store(&bumpCount, 0); calogContextEval(ctx, "bump()"); for (i = 0; i < PUMP_LIMIT && atomic_load(&bumpCount) < 1; i++) { calogPump(calog); nanosleep(&ts, NULL); } CHECK(atomic_load(&bumpCount) == 1, "the my-basic script's native call ran"); CHECK(atomic_load(&bumpCtxId) == 0, "the native ran on the host thread (id 0)"); calogContextClose(ctx); } static void testWideInteger(void) { int64_t got; // A >2^32 literal: exercises strtoll parsing and the 64-bit int_t, then egress to the host. got = evalAndReport("report(5000000000)\nbump()"); CHECK(got == WIDE_VALUE, "my-basic parsed and returned a 64-bit integer literal"); // Ingress (getWide, no clamp) + 64-bit arithmetic in BASIC + egress back to the host. got = evalAndReport("report(getWide() + 1000000000)\nbump()"); CHECK(got == WIDE_SUM, "my-basic round-tripped a 64-bit value through arithmetic"); // ABS() must use llabs, not the 32-bit C abs(), now that int_t is 64-bit. got = evalAndReport("report(ABS(0 - 5000000000))\nbump()"); CHECK(got == WIDE_VALUE, "my-basic ABS() preserves a 64-bit magnitude (no int truncation)"); } int main(void) { calog = calogCreate(); if (calog == NULL) { printf("calog create failed\n"); return 1; } calogRegister(calog, "blobEcho", blobEcho, NULL); calogRegister(calog, "blobLen", blobLen, NULL); calogRegister(calog, "bump", bump, NULL); calogRegister(calog, "getAdder", nativeGetAdder, NULL); calogRegister(calog, "getWide", nativeGetWide, NULL); calogRegister(calog, "makeBlob", makeBlob, NULL); calogRegister(calog, "makeBlob2", makeBlob2, NULL); calogRegister(calog, "report", report, NULL); testHostNative(); testConcurrentContexts(); testForeignFunction(); testWideInteger(); testBinaryStrings(); calogDestroy(calog); printf("\n%d checks, %d failed\n", testsRun, testsFailed); fflush(stdout); if (testsFailed != 0) { return 1; } return 0; }