lumbda/wasm/c/lumbda-c.loader.js
russell@unturf.com 2b46c1b79e
repl portal: overwrite-guard, rename, export/import, cross-tab restore
Three UX gaps on the portal-bar closed in one pass plus a defensive
fix on the C tier's heap probe:

* Overwrite guard — saving with an existing name asks 'overwrite?'
  with the existing entry's tier + savedAt. Rename via dbl-click on
  the chip label; same overwrite guard applies on rename.

* Export / import — a ⇣ icon on each chip downloads it as
  <name>.portal.json (opaque blob for c/python, replay-inputs for
  asm). A 📁 import button on the portal-bar accepts a .portal.json
  file via hidden <input type="file">; collisions prompt overwrite,
  decline auto-suffixes (baseName-2, -3, …) so importing a 2nd copy
  always lands somewhere.

* Cross-tab restore — a 📂 this tab / 🌐 all tabs toggle switches
  the chip strip between the active tab's checkpoints and every
  tab's. Global chips render as 'name · tabName' with a dashed
  border; click restores the snapshot into the active tab (the
  saved cp is passed through restoreCheckpoint's new sourceCp
  argument so the chip doesn't need a checkpoints[name] match on
  the active tab). Edit/delete are hidden in global mode — the
  user switches to the owning tab to manage chips.

* heapStats defensive — wasm/c/lumbda-c.loader.js now returns null
  when module.HEAPU8 isn't live yet (caught by the heap poll firing
  during the tiny window between tier reboot and Module init), so a
  restore no longer surfaces 'Cannot read properties of undefined
  (reading byteLength)' as a TypeError.

Smoke-tested headlessly: overwrite confirm fires with the expected
message; rename via dblclick swaps the label; ⇣ produces a download
named '<name>.portal.json'; toggle shows both tabs' chips with the
'· tabName' annotation; import round-trips back into the receiving
tab. Zero page errors across the full flow.
2026-06-15 08:14:53 -04:00

122 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;
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.
//
// 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;
};
})();