lumbda/wasm/c/lumbda_wasm_entry.c
russell@unturf.com 1458ebf77a
bend: wire bend!-call into the Emscripten C tier — playground c
lumbda_wasm_entry.c — adds an EM_JS bridge js_lumbda_bend_call that
does sync XHR POST (legal inside Web Workers, the playground's tier
host) and writes the response bytes back into the wasm heap. C
wrapper bi_bend_call_wasm allocates a 256 KiB response buffer, calls
the EM_JS function, wraps the bytes as a lumbda string Value, and
gets registered as the `bend!-call` builtin in lumbda_wasm_init —
not in c/builtins.c, so the native CLI build doesn't acquire a
wasm-flavored binding it can't satisfy.

lumbda-c.loader.js — setBendUrl(url) mutates globalThis._lumbdaCBendUrl
which the EM_JS reads on every call. runner.js plumbing already
propagates a saved playground URL to every tier's setBendUrl on
each save.

C tier now matches asm + pyodide for HTTP-mode bend. End-to-end
verified in node + wasm directly: bend!-call returns the configured
URL response text; "no bend URL configured" when unset; clean
"bend error: ..." string when the XHR throws (e.g., CORS, network).
Tested against the live bend.unturf.com chain via the playground.

Three tiers in parallel now show the same (ok (HEX0 HEX1 HEX2))
result from (cuda-shake-fanout ("00" "01" "deadbeef") 32).
2026-06-14 19:04:52 -04:00

220 lines
8.8 KiB
C

/* lumbda_wasm_entry.c — Emscripten entry points for the C tier.
*
* MEMORY MODEL — c-wasm tier
* ---------------------------
* The native C tier links libgc (Boehm conservative collector) and
* everything is GC-managed. The WASM build defines LUMBDA_NO_BOEHM (no
* libgc port wired up to Emscripten), so gc.c takes its plain-malloc
* fallback path. malloc returns memory; nothing ever returns it.
*
* For browser use this manifests as monotonic linear-memory growth in
* the worker. The REPL surfaces the pressure in its tabbar ("c 48M ↑")
* and the "reboot tier" button gives users a manual reclaim path —
* terminate + respawn the worker, which destroys the heap entirely.
*
* Real fixes, in order of decreasing cost:
* 1. Build bdwgc (github.com/ivmai/bdwgc) with emcc and link the
* WASM build with USE_BOEHM_GC defined. ATTEMPTED — bdwgc
* itself compiles cleanly via `emcmake cmake .. && emmake make`
* and links into lumbda-c.js. Two blockers stopped this
* session: (a) bdwgc's docs recommend
* `-sBINARYEN_EXTRA_PASSES=--spill-pointers` so the conservative
* collector can find stack roots; the binaryen pass crashes in
* our emsdk 6.0.0 with `Fatal: getStackSpace: failed to find
* the stack pointer`. (b) Without spill-pointers, bdwgc's
* option 2 (GC_disable at init + manual GC_enable/GC_gcollect/
* GC_disable at safe points between top-level evals) compiles
* but throws `remainder by zero` on the first eval — almost
* certainly a missed conservative root somewhere in the eval
* call chain that bdwgc swept while it was still live. Forward
* path: update emsdk to a version where spill-pointers works,
* OR walk lumbda's struct types in types.c and either provide
* GC_PUSH-style explicit-root hooks or write per-type mark
* callbacks via GC_new_kind.
* 2. Write a small mark-sweep over the existing NaN-boxed heap.
* The asm WAT tier already does this with a Cheney copying GC
* (lumbda_gc, gc_collect in asm/lumbda.wat). Same algorithm
* ports here once we know each lumbda struct's pointer-field
* layout — types.c is the source of truth there.
* 3. Generational reset: between top-level evals, snapshot global
* env + interns to s-expressions, tear down everything, replay.
* Crudest of the three.
*
* ENTRY POINTS
* ------------
* The JS loader provides Module.print / Module.printErr callbacks that
* Emscripten routes stdout/stderr through, so output capture happens on
* the JS side. We only expose:
*
* lumbda_wasm_init() - set up symbols + env + stdlib
* lumbda_wasm_eval(src) - eval src, last value printed if non-void
* lumbda_wasm_free_result(p) - free a string returned to JS
*
* The env is module-global so successive eval calls preserve defines.
*/
#include "lumbda.h"
#include "jit.h"
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <emscripten.h>
static Env *g_wasm_env = NULL;
/* bend!-call — dispatch a payload string to the configured gpu-worker
* over sync XHR (legal in Web Workers, where this tier runs in the
* playground). The JS side reads `globalThis._lumbdaCBendUrl` for the
* destination; lumbda-c.loader.js's setBendUrl(url) mutates that, then
* runner.js propagates a saved playground bend-url to every tier.
*
* Layout mirrors the asm tier's bend_call import: payload bytes are
* passed by ptr+len, response is written into a heap-allocated buffer,
* length is returned. We then wrap the bytes as a lumbda string Value.
*
* The 256 KiB cap matches a typical (cuda-shake-fanout (...) 32) round
* trip plus headroom; larger responses get truncated rather than
* crashing the tier. Phase 2 (factory recipes) will replace large
* payloads with server-side bin compilation anyway. */
#define BEND_RESP_CAP (256 * 1024)
EM_JS(int, js_lumbda_bend_call, (const char *payload_ptr, int payload_len,
char *resp_buf, int resp_cap), {
var bendUrl = globalThis._lumbdaCBendUrl;
if (!bendUrl) {
var msg = "no bend URL configured";
var n = Math.min(msg.length, resp_cap - 1);
for (var i = 0; i < n; i++) HEAPU8[resp_buf + i] = msg.charCodeAt(i);
return n;
}
try {
var payload = UTF8ToString(payload_ptr, payload_len);
var xhr = new XMLHttpRequest();
xhr.open("POST", bendUrl, false); /* sync */
xhr.setRequestHeader("Content-Type", "text/plain; charset=utf-8");
xhr.send(payload);
var resp = xhr.responseText || "";
var bytes = new TextEncoder().encode(resp);
var n = Math.min(bytes.length, resp_cap);
HEAPU8.set(bytes.subarray(0, n), resp_buf);
return n;
} catch (e) {
var err = "bend error: " + (e.message || String(e));
var n = Math.min(err.length, resp_cap - 1);
for (var i = 0; i < n; i++) HEAPU8[resp_buf + i] = err.charCodeAt(i);
return n;
}
});
static Value bi_bend_call_wasm(Value *a, int n, Env *e) {
(void)e;
if (n < 1 || !IS_STRING(a[0])) return VAL_FALSE;
ULString *s = AS_STRING(a[0]);
char *resp = (char *)malloc(BEND_RESP_CAP);
if (!resp) return VAL_FALSE;
int resp_len = js_lumbda_bend_call(s->data, (int)s->len, resp, BEND_RESP_CAP);
Value out = make_string(resp, (size_t)resp_len, false);
free(resp);
return out;
}
static const char *MINI_STDLIB =
"(define (caar p) (car (car p)))\n"
"(define (cadr p) (car (cdr p)))\n"
"(define (cdar p) (cdr (car p)))\n"
"(define (cddr p) (cdr (cdr p)))\n"
"(define (caddr p) (car (cdr (cdr p))))\n"
"(define (cadddr p) (car (cdr (cdr (cdr p)))))\n"
"(define (list . xs) xs)\n"
"(define (not x) (if x #f #t))\n"
"(define (map f xs) (if (null? xs) '() (cons (f (car xs)) (map f (cdr xs)))))\n"
"(define (length xs) (if (null? xs) 0 (+ 1 (length (cdr xs)))))\n"
"(define (reverse xs) (if (null? xs) '() (append (reverse (cdr xs)) (list (car xs)))))\n"
"(define (append a b) (if (null? a) b (cons (car a) (append (cdr a) b))))\n"
"(define (zero? n) (= n 0))\n"
"(define (positive? n) (> n 0))\n"
"(define (negative? n) (< n 0))\n"
"(define (abs n) (if (< n 0) (- 0 n) n))\n";
void lumbda_wasm_init(void) {
if (g_wasm_env) return;
init_symbols();
/* Auto-compile every define to bytecode so deep recursion uses
* the VM's explicit frame stack (TCO) instead of growing the host
* C stack. Without this, (let loop ((i 0)) ... (loop (+ i 1)))
* blows out the WASM linear-memory stack around N=500. */
extern bool g_auto_compile;
g_auto_compile = true;
g_wasm_env = make_global_env();
/* Load PRELUDE (built into eval/builtins via make_global_env). */
int count = 0;
Value *exprs = read_all(PRELUDE, &count, false);
for (int i = 0; i < count; i++) leval(exprs[i], g_wasm_env);
ul_free(exprs);
/* Load a small stdlib subset embedded above. Full stdlib.lsp is too
* large to embed cleanly; demos only need the listed primitives. */
count = 0;
exprs = read_all(MINI_STDLIB, &count, false);
ErrorContext ctx_local;
ctx_local.call_stack_depth = 0;
ctx_local.error_obj = VAL_NIL;
ctx_local.source_line = 0;
g_error_ctx = &ctx_local;
if (setjmp(ctx_local.jmp) == 0) {
for (int i = 0; i < count; i++) leval(exprs[i], g_wasm_env);
}
ul_free(exprs);
/* Register bend!-call binding to the host JS XHR bridge above.
* Done here (not in c/builtins.c) so the native build doesn't
* acquire a WASM-flavored binding it can't satisfy. */
env_define(g_wasm_env, intern("bend!-call"), VAL_BUILTIN(bi_bend_call_wasm));
}
/* Eval src. Output during eval goes to stdout (captured by Module.print
* on the JS side). The final non-void value's repr is appended to stdout
* via printf("%s\n", ...).
*
* Returns NULL on success, or a malloc'd error message on failure. JS
* frees via lumbda_wasm_free_result.
*/
char *lumbda_wasm_eval(const char *src) {
if (!g_wasm_env) lumbda_wasm_init();
ErrorContext ctx_local;
ctx_local.call_stack_depth = 0;
ctx_local.error_obj = VAL_NIL;
ctx_local.source_line = 0;
g_error_ctx = &ctx_local;
if (setjmp(ctx_local.jmp) != 0) {
const char *msg = ctx_local.message[0] ? ctx_local.message : "unknown error";
size_t n = strlen(msg) + 16;
char *err = (char *)malloc(n);
snprintf(err, n, "error: %s", msg);
return err;
}
int count = 0;
Value *exprs = read_all(src, &count, false);
Value last = VAL_VOID;
for (int i = 0; i < count; i++) {
last = leval(exprs[i], g_wasm_env);
}
ul_free(exprs);
if (!IS_VOID(last)) {
char *rep = show(last, false);
printf("%s\n", rep);
fflush(stdout);
ul_free(rep);
} else {
fflush(stdout);
}
return NULL;
}
void lumbda_wasm_free_result(char *p) {
if (p) free(p);
}