The deployed loader emits chunks with currentOnChunk(line + '\n'),
which my node tests confirm produces clean per-line chunks
('tick 0\n', 'tick 1\n', ...). But fox still reports horizontal
output in his playground tab — same tier, same loop, same loader
hash on the wire (verified via curl). Browser caches and module
loaders are aggressive enough that hard-refresh isn't always
enough.
Adding a temporary console.log inside Module.print so when fox
opens DevTools and re-runs the throttled loop he can see (a) is
the print callback firing at all? and (b) what does Emscripten
hand us — is it actually 'tick 0' or something stranger like
'tick 0\n' or 'tick' + ' 0' as two calls?
Will revert this once the bug is resolved.
EOF
)
121 lines
5.2 KiB
JavaScript
121 lines
5.2 KiB
JavaScript
// wasm/c/lumbda-c.loader.js
|
|
// C tier loader — Emscripten ES module factory.
|
|
//
|
|
// Exports createCTier({ baseURL }) -> Promise<{ evalLisp(src) -> Promise<string> }>.
|
|
// 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;
|
|
// Temporary debug — log every Module.print invocation so we can
|
|
// see exactly what Emscripten passes us (with/without trailing
|
|
// newline). Remove once the streaming-newline bug is resolved.
|
|
const DEBUG_STREAM = true;
|
|
const module = await createLumbdaC({
|
|
locateFile: (p) => wasmDir + p,
|
|
print: (line) => {
|
|
if (DEBUG_STREAM) console.log("[c-tier print]", JSON.stringify(line));
|
|
outBuf.push(line);
|
|
if (currentOnChunk) currentOnChunk(line + "\n");
|
|
},
|
|
printErr: (line) => {
|
|
if (DEBUG_STREAM) console.log("[c-tier printErr]", JSON.stringify(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; },
|
|
// 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.
|
|
const total = module.HEAPU8.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;
|
|
};
|
|
})();
|