Emscripten's Module.print fires once per line and hands the loader
just the line content — no trailing newline; the caller is expected
to add it back when joining (outBuf.join('\n') does that for the
final return). Streaming path was forwarding the line straight to
onChunk without the newline, so successive chunks concatenated
horizontally in the playground panel — the loop
(let loop ((i 0))
(cond ((= (modulo i 10000) 0) (display \"tick \") (display i) (newline)))
(loop (+ i 1)))
was running fine on c tier (each (newline) ended a printf line) but
arriving in the UI as 'tick 0tick 10000tick 20000...' with no
breaks.
Loader's print/printErr callbacks now pass `line + \"\\n\"` to
currentOnChunk. Final buffered join (which already adds the \\n)
is untouched, so the post-eval output string keeps the same shape.
Pyodide _StreamingStdout sees Python's raw write() bytes including
the \\n already; asm's emit_chunk fires AFTER output_len was
incremented past the \\n, so the slice [flush_start, output_len)
includes it. Neither needed a change.
115 lines
4.8 KiB
JavaScript
115 lines
4.8 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;
|
|
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; },
|
|
// 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;
|
|
};
|
|
})();
|