// wasm/c/lumbda-c.loader.js // C tier loader — Emscripten ES module factory. // // Exports createCTier({ baseURL }) -> Promise<{ evalLisp(src) -> Promise }>. // Output capture: Emscripten routes stdout/stderr through Module.print / // Module.printErr callbacks. We accumulate per-eval and return joined. async function _bootstrap() { // Use import.meta.url so paths resolve relative to THIS loader file — // not the caller. Works in both window and Worker contexts because both // have a defined import.meta.url for ES modules. const factoryURL = new URL("./lumbda-c.js", import.meta.url).href; const wasmDir = new URL("./", import.meta.url).href; const { default: createLumbdaC } = await import(/* @vite-ignore */ factoryURL); // Streaming output: every printf in the wasm fires Module.print // synchronously. We buffer normally for the final return, but if // the current eval registered an onChunk callback we also call // through immediately so the worker can postMessage a chunk to // the UI as each line emerges (vs the user staring at a blank // panel for 18 seconds during the heavy bend call). // // Emscripten's print convention is "one call per line WITHOUT // the trailing newline" — the caller is expected to add a \n // when reassembling. Our outBuf join adds it back, but the // streaming path needs it explicitly or successive chunks // concatenate horizontally in the output panel (saw with // (let loop ((i 0)) ... (display i) (newline) ... (loop ...)) // — every tick ended up on the same line). let outBuf = []; let errBuf = []; let currentOnChunk = null; const module = await createLumbdaC({ locateFile: (p) => wasmDir + p, print: (line) => { outBuf.push(line); if (currentOnChunk) currentOnChunk(line + "\n"); }, printErr: (line) => { errBuf.push(line); if (currentOnChunk) currentOnChunk(line + "\n"); }, }); const _init = module.cwrap("lumbda_wasm_init", null, []); const _eval = module.cwrap("lumbda_wasm_eval", "number", ["string"]); const _free = module.cwrap("lumbda_wasm_free_result", null, ["number"]); _init(); // /tmp must exist before (portal-snapshot! ...) writes there — the // C builtin fopen("w")s the path directly so a missing dir is a // hard error. Emscripten's MEMFS gives us /tmp on recent versions // but make it idempotent. try { module.FS.mkdir("/tmp"); } catch (e) { /* exists */ } // bend!-call host bridge — js_lumbda_bend_call inside the wasm // (EM_JS in lumbda_wasm_entry.c) reads globalThis._lumbdaCBendUrl // for the destination, then does sync XHR POST and writes the // response bytes back into the wasm heap. setBendUrl mutates that // global so runner.js can propagate a saved playground URL. return { setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; }, // Pause hook — js_lumbda_pause_requested (the JS-library import // wired in bend-call-library.js) reads globalThis._lumbdaCPauseFlag // every K leval iterations. Setting it to an Int32Array view // over a SharedArrayBuffer lets the REPL signal a pause from // the main thread. Null disables (no COOP/COEP isolation). setPauseFlag(flag) { globalThis._lumbdaCPauseFlag = flag ? new Int32Array(flag) : null; }, // portalSave / portalLoad bridge MEMFS to the JS side so the // REPL can persist checkpoints to the encrypted vault and // restore them on a fresh worker. Names come from the worker // (REPL caller validates alphanumeric) so path traversal isn't // a concern. portalSave(name) { const path = `/tmp/${name}.portal`; try { const bytes = module.FS.readFile(path); return new TextDecoder().decode(bytes); } catch (e) { return null; } }, portalLoad(name, blob) { const path = `/tmp/${name}.portal`; module.FS.writeFile(path, blob); }, async evalLisp(src, onChunk) { outBuf = []; errBuf = []; currentOnChunk = onChunk || null; const errPtr = _eval(src); currentOnChunk = null; let errMsg = ""; if (errPtr) { errMsg = module.UTF8ToString(errPtr); _free(errPtr); } let out = outBuf.join("\n"); if (out) out += "\n"; if (errBuf.length) out += errBuf.join("\n") + "\n"; if (errMsg) out += errMsg + "\n"; return out; }, heapStats() { // Emscripten exposes the linear memory directly. There's no // free path right now (LUMBDA_NO_BOEHM), so used = total — // every malloc accumulates until reload. Documented and // surfaced in the REPL so the user sees the pressure. // // HEAPU8 isn't always live: after a worker reboots, the // heap poll can fire while the new Module is mid-init and // HEAPU8 isn't yet wired. Return null so the REPL hides // the pressure indicator for that tick instead of crashing. const heap = module.HEAPU8; if (!heap) return null; const total = heap.byteLength; return { used: total, total }; }, }; } // Closure-encapsulated singleton (see lumbda-c.loader.js for rationale). export const createCTier = (() => { let tier = null; return async () => { if (!tier) tier = await _bootstrap(); return tier; }; })();