diff --git a/wasm/app/app.js b/wasm/app/app.js index 33736fc..2c61f8a 100644 --- a/wasm/app/app.js +++ b/wasm/app/app.js @@ -213,19 +213,28 @@ function runOnTierInWorker(tier, src, onLoading) { return new Promise((resolve, reject) => { const w = ensureWorker(tier); const myRunId = ++workerState.nextRunId; + let liveBlock = null; workerState.pending[tier] = { runId: myRunId, resolve, reject }; const handler = (e) => { if (e.data.runId !== myRunId) return; if (e.data.kind === "loading") { onLoading && onLoading(e.data.tier); + } else if (e.data.kind === "chunk") { + // First chunk spawns the in-progress block; subsequent + // chunks append to it. User sees displays land + // immediately, not just at the end of the run. + if (!liveBlock) liveBlock = startLiveBlock(tier); + liveBlock.append(e.data.chunk); } else if (e.data.kind === "done") { w.removeEventListener("message", handler); workerState.pending[tier] = null; - resolve(e.data.output); + resolve({ output: e.data.output, liveBlock }); } else if (e.data.kind === "error") { w.removeEventListener("message", handler); workerState.pending[tier] = null; - reject(new Error(e.data.message)); + const err = new Error(e.data.message); + err.liveBlock = liveBlock; + reject(err); } }; w.addEventListener("message", handler); @@ -233,6 +242,44 @@ function runOnTierInWorker(tier, src, onLoading) { }); } +// Spawn a tier-block in the output panel immediately on first streamed +// chunk. Returns handles to append further chunks and to finalize the +// timing header once the eval reports done. Mirrors appendBlock's +// structure so finalized live blocks look identical to non-streamed +// ones — same DOM, same CSS. +function startLiveBlock(tierName) { + const block = document.createElement("div"); + block.className = "tier-block"; + const h = document.createElement("h3"); + h.textContent = TIERS[tierName] || tierName; + const t = document.createElement("span"); + t.className = "time"; + t.textContent = " (running…)"; + h.appendChild(t); + block.appendChild(h); + const pre = document.createElement("pre"); + pre.textContent = ""; + block.appendChild(pre); + outputEl.appendChild(block); + return { + append(chunk) { + pre.textContent += chunk; + outputEl.scrollTop = outputEl.scrollHeight; + }, + finalize(elapsedMs, kind, fullOutput) { + if (kind === "cancelled") t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`; + else t.textContent = ` (${elapsedMs.toFixed(0)} ms)`; + if (kind === "error") pre.className = "err"; + // If the streamed chunks miss anything (e.g. asm tier + // which doesn't stream yet), reconcile with the full + // output. No-op when streaming captured everything. + if (fullOutput && pre.textContent !== fullOutput) { + pre.textContent = fullOutput; + } + }, + }; +} + function cancelCurrentRun() { for (const t of Object.keys(workerState.workers)) { if (workerState.workers[t]) { @@ -307,20 +354,26 @@ async function runAll() { return runOnTierInWorker(t, src, (loadingTier) => { setStatus(`loading ${loadingTier}…`, "busy"); }) - .then((output) => { + .then(({ output, liveBlock }) => { const elapsed = performance.now() - startTimes[t]; delete startTimes[t]; - appendBlock(t, elapsed, output, "ok"); + // Streamed tiers already painted via liveBlock; just + // finalize the timing header. Non-streaming tiers + // (today: asm) get a fresh appendBlock at the end. + if (liveBlock) liveBlock.finalize(elapsed, "ok", output); + else appendBlock(t, elapsed, output, "ok"); return { tier: t, ok: true, elapsed }; }) .catch((e) => { const elapsed = performance.now() - startTimes[t]; delete startTimes[t]; if (e.message === "cancelled") { - appendBlock(t, elapsed, "(cancelled)", "cancelled"); + if (e.liveBlock) e.liveBlock.finalize(elapsed, "cancelled"); + else appendBlock(t, elapsed, "(cancelled)", "cancelled"); return { tier: t, cancelled: true, elapsed }; } - appendBlock(t, elapsed, e.message || String(e), "error"); + if (e.liveBlock) e.liveBlock.finalize(elapsed, "error", e.message); + else appendBlock(t, elapsed, e.message || String(e), "error"); return { tier: t, error: e.message, elapsed }; }); }); diff --git a/wasm/app/runner.js b/wasm/app/runner.js index aa602af..5379f6f 100644 --- a/wasm/app/runner.js +++ b/wasm/app/runner.js @@ -29,9 +29,9 @@ export async function getTier(name, onLoad) { return cache[name]; } -export async function evalOnTier(name, src, onLoad) { +export async function evalOnTier(name, src, onLoad, onChunk) { const tier = await getTier(name, onLoad); - return tier.evalLisp(src); + return tier.evalLisp(src, onChunk); } export function heapStats(name) { @@ -39,3 +39,20 @@ export function heapStats(name) { if (!tier || !tier.heapStats) return null; return tier.heapStats(); } + +// Portal — REPL save/resume bridge. portalSave reads a snapshot blob +// from the tier (MEMFS on the C tier, similar bridges on others as +// they land). Returns null when the tier hasn't implemented portals +// yet (asm-wat today) so the caller can surface a friendly message +// instead of crashing. portalLoad is the symmetric write-in path. +export function portalSave(name, checkpointName) { + const tier = cache[name]; + if (!tier || !tier.portalSave) return null; + return tier.portalSave(checkpointName); +} +export function portalLoad(name, checkpointName, blob) { + const tier = cache[name]; + if (!tier || !tier.portalLoad) return false; + tier.portalLoad(checkpointName, blob); + return true; +} diff --git a/wasm/app/worker.mjs b/wasm/app/worker.mjs index 760c680..795f0eb 100644 --- a/wasm/app/worker.mjs +++ b/wasm/app/worker.mjs @@ -3,7 +3,7 @@ // live ms counter actually ticks AND so cancel works (main thread // terminates this worker via worker.terminate()). -import { evalOnTier, setBendUrl, heapStats } from "./runner.js"; +import { evalOnTier, setBendUrl, heapStats, portalSave, portalLoad } from "./runner.js"; self.onmessage = async (e) => { const { kind } = e.data; @@ -17,12 +17,39 @@ self.onmessage = async (e) => { self.postMessage({ kind: "heap", runId: e.data.runId, tier: e.data.tier, stats: heapStats(e.data.tier) }); return; } + if (kind === "portal-save") { + // Pulls the most-recent /tmp/.portal out of the tier's + // MEMFS after the lisp side ran (portal-snapshot! NAME). Main + // thread encrypts and stuffs it into the vault. + const blob = portalSave(e.data.tier, e.data.name); + self.postMessage({ kind: "portal-save", runId: e.data.runId, name: e.data.name, blob }); + return; + } + if (kind === "portal-load") { + // Hydrates MEMFS from a vault-decrypted blob so a subsequent + // (portal-load! NAME) eval finds the file ready. + const ok = portalLoad(e.data.tier, e.data.name, e.data.blob); + self.postMessage({ kind: "portal-load", runId: e.data.runId, name: e.data.name, ok }); + return; + } if (kind !== "eval") return; const { runId, tier, src } = e.data; try { - const output = await evalOnTier(tier, src, (loadingTier) => { - self.postMessage({ kind: "loading", runId, tier: loadingTier }); - }); + const output = await evalOnTier( + tier, + src, + (loadingTier) => { + self.postMessage({ kind: "loading", runId, tier: loadingTier }); + }, + // Stream every print/display from the tier to the main + // thread as it happens. Sync XHR inside bend!-call still + // blocks the worker, but displays BEFORE/AFTER the bend + // round-trip surface immediately instead of waiting for + // the whole eval to finish. Long demos feel alive. + (chunk) => { + self.postMessage({ kind: "chunk", runId, tier, chunk }); + }, + ); self.postMessage({ kind: "done", runId, output }); } catch (err) { self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) }); diff --git a/wasm/c/lumbda-c.loader.js b/wasm/c/lumbda-c.loader.js index 71b0e54..181d4fe 100644 --- a/wasm/c/lumbda-c.loader.js +++ b/wasm/c/lumbda-c.loader.js @@ -13,12 +13,25 @@ async function _bootstrap() { 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). let outBuf = []; let errBuf = []; + let currentOnChunk = null; const module = await createLumbdaC({ locateFile: (p) => wasmDir + p, - print: (line) => outBuf.push(line), - printErr: (line) => errBuf.push(line), + print: (line) => { + outBuf.push(line); + if (currentOnChunk) currentOnChunk(line); + }, + printErr: (line) => { + errBuf.push(line); + if (currentOnChunk) currentOnChunk(line); + }, }); const _init = module.cwrap("lumbda_wasm_init", null, []); @@ -27,6 +40,12 @@ async function _bootstrap() { _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 @@ -34,10 +53,28 @@ async function _bootstrap() { // global so runner.js can propagate a saved playground URL. return { setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; }, - async evalLisp(src) { + // 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); diff --git a/wasm/dist-repl/c/lumbda-c.loader.js b/wasm/dist-repl/c/lumbda-c.loader.js index 64e78c2..181d4fe 100644 --- a/wasm/dist-repl/c/lumbda-c.loader.js +++ b/wasm/dist-repl/c/lumbda-c.loader.js @@ -13,12 +13,25 @@ async function _bootstrap() { 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). let outBuf = []; let errBuf = []; + let currentOnChunk = null; const module = await createLumbdaC({ locateFile: (p) => wasmDir + p, - print: (line) => outBuf.push(line), - printErr: (line) => errBuf.push(line), + print: (line) => { + outBuf.push(line); + if (currentOnChunk) currentOnChunk(line); + }, + printErr: (line) => { + errBuf.push(line); + if (currentOnChunk) currentOnChunk(line); + }, }); const _init = module.cwrap("lumbda_wasm_init", null, []); @@ -56,10 +69,12 @@ async function _bootstrap() { const path = `/tmp/${name}.portal`; module.FS.writeFile(path, blob); }, - async evalLisp(src) { + async evalLisp(src, onChunk) { outBuf = []; errBuf = []; + currentOnChunk = onChunk || null; const errPtr = _eval(src); + currentOnChunk = null; let errMsg = ""; if (errPtr) { errMsg = module.UTF8ToString(errPtr); diff --git a/wasm/dist-repl/python/lumbda-py.js b/wasm/dist-repl/python/lumbda-py.js index 609cefc..34aff77 100644 --- a/wasm/dist-repl/python/lumbda-py.js +++ b/wasm/dist-repl/python/lumbda-py.js @@ -29,7 +29,15 @@ async function _bootstrap() { // the response text back. Sync XHR is the only sync HTTP available // in a Web Worker; perfect for the blocking eval model lumbda // primitives expect. - const refs = { bendUrl: null }; + const refs = { bendUrl: null, currentOnChunk: null }; + // Streaming output bridge — sys.stdout in pyodide is replaced + // (during _lumbda_eval) with a class whose write() calls back + // here. We forward to the current onChunk so the worker can + // postMessage chunks to the UI as work happens, instead of the + // user staring at a blank panel during a long bend dispatch. + globalThis._lumbdaPyEmitChunk = (s) => { + if (refs.currentOnChunk && s) refs.currentOnChunk(s); + }; globalThis._lumbdaPyBendCall = (payload) => { if (!refs.bendUrl) return "no bend URL configured"; try { @@ -63,8 +71,50 @@ def _bend_call_prim(args, env): return str(_js_bend_call(payload)) _env.define(lumbda.S('bend!-call'), _bend_call_prim) +# Portal save/resume — REPL bridge. Mirrors the C tier's +# (portal-snapshot! NAME) / (portal-load! NAME): writes/reads +# /tmp/.portal so the JS side can round-trip blobs to vault. +import os +os.makedirs('/tmp', exist_ok=True) + +def _portal_snapshot_prim(args, env): + if not args or not isinstance(args[0], str): + raise lumbda.LispErr('portal-snapshot!: expected string name') + path = f'/tmp/{args[0]}.portal' + g = env.g if env.g else _env + lumbda.portal_save(g, path) + return path + +def _portal_load_prim(args, env): + if not args or not isinstance(args[0], str): + raise lumbda.LispErr('portal-load!: expected string name') + path = f'/tmp/{args[0]}.portal' + restored_env, _cont = lumbda.portal_resume(path, _env) + # Copy bindings from the restored env into our live global env so + # subsequent evals see them. portal_resume gives us back a fresh + # env with the loaded bindings; we merge into _env in-place. + for sym, val in restored_env.b.items(): + _env.b[sym] = val + return True + +_env.define(lumbda.S('portal-snapshot!'), _portal_snapshot_prim) +_env.define(lumbda.S('portal-load!'), _portal_load_prim) + +# Streaming stdout wrapper — every write() also calls back into JS so +# the worker can postMessage chunks to the UI during the eval. The +# StringIO behind it still captures everything for the final return. +from js import _lumbdaPyEmitChunk as _js_emit_chunk +class _StreamingStdout(io.StringIO): + def write(self, s): + n = super().write(s) + try: + _js_emit_chunk(s) + except Exception: + pass + return n + def _lumbda_eval(src): - buf = io.StringIO() + buf = _StreamingStdout() old = sys.stdout sys.stdout = buf last = None @@ -81,15 +131,39 @@ def _lumbda_eval(src): if out and not out.endswith("\\n"): out += "\\n" out += rep + try: + _js_emit_chunk(("" if out.endswith(rep) else "\\n") + rep) + except Exception: + pass return out `); return { - async evalLisp(src) { + async evalLisp(src, onChunk) { pyodide.globals.set("_src_in", src); - return await pyodide.runPythonAsync("_lumbda_eval(_src_in)"); + refs.currentOnChunk = onChunk || null; + try { + return await pyodide.runPythonAsync("_lumbda_eval(_src_in)"); + } finally { + refs.currentOnChunk = null; + } }, setBendUrl(url) { refs.bendUrl = url || null; }, + // Portal save/resume bridge to vault — same shape as the C tier + // loader. Pyodide.FS is the same Emscripten FS, so the path + // /tmp/.portal is reachable from JS exactly as in C. + portalSave(name) { + const path = `/tmp/${name}.portal`; + try { + const bytes = pyodide.FS.readFile(path); + return new TextDecoder().decode(bytes); + } catch (e) { return null; } + }, + portalLoad(name, blob) { + const path = `/tmp/${name}.portal`; + try { pyodide.FS.mkdir("/tmp"); } catch (e) { /* exists */ } + pyodide.FS.writeFile(path, blob); + }, heapStats() { // Pyodide's runtime memory is the Emscripten linear memory. // CPython's GC reclaims behind the scenes, so this number diff --git a/wasm/python/lumbda-py.js b/wasm/python/lumbda-py.js index 609cefc..34aff77 100644 --- a/wasm/python/lumbda-py.js +++ b/wasm/python/lumbda-py.js @@ -29,7 +29,15 @@ async function _bootstrap() { // the response text back. Sync XHR is the only sync HTTP available // in a Web Worker; perfect for the blocking eval model lumbda // primitives expect. - const refs = { bendUrl: null }; + const refs = { bendUrl: null, currentOnChunk: null }; + // Streaming output bridge — sys.stdout in pyodide is replaced + // (during _lumbda_eval) with a class whose write() calls back + // here. We forward to the current onChunk so the worker can + // postMessage chunks to the UI as work happens, instead of the + // user staring at a blank panel during a long bend dispatch. + globalThis._lumbdaPyEmitChunk = (s) => { + if (refs.currentOnChunk && s) refs.currentOnChunk(s); + }; globalThis._lumbdaPyBendCall = (payload) => { if (!refs.bendUrl) return "no bend URL configured"; try { @@ -63,8 +71,50 @@ def _bend_call_prim(args, env): return str(_js_bend_call(payload)) _env.define(lumbda.S('bend!-call'), _bend_call_prim) +# Portal save/resume — REPL bridge. Mirrors the C tier's +# (portal-snapshot! NAME) / (portal-load! NAME): writes/reads +# /tmp/.portal so the JS side can round-trip blobs to vault. +import os +os.makedirs('/tmp', exist_ok=True) + +def _portal_snapshot_prim(args, env): + if not args or not isinstance(args[0], str): + raise lumbda.LispErr('portal-snapshot!: expected string name') + path = f'/tmp/{args[0]}.portal' + g = env.g if env.g else _env + lumbda.portal_save(g, path) + return path + +def _portal_load_prim(args, env): + if not args or not isinstance(args[0], str): + raise lumbda.LispErr('portal-load!: expected string name') + path = f'/tmp/{args[0]}.portal' + restored_env, _cont = lumbda.portal_resume(path, _env) + # Copy bindings from the restored env into our live global env so + # subsequent evals see them. portal_resume gives us back a fresh + # env with the loaded bindings; we merge into _env in-place. + for sym, val in restored_env.b.items(): + _env.b[sym] = val + return True + +_env.define(lumbda.S('portal-snapshot!'), _portal_snapshot_prim) +_env.define(lumbda.S('portal-load!'), _portal_load_prim) + +# Streaming stdout wrapper — every write() also calls back into JS so +# the worker can postMessage chunks to the UI during the eval. The +# StringIO behind it still captures everything for the final return. +from js import _lumbdaPyEmitChunk as _js_emit_chunk +class _StreamingStdout(io.StringIO): + def write(self, s): + n = super().write(s) + try: + _js_emit_chunk(s) + except Exception: + pass + return n + def _lumbda_eval(src): - buf = io.StringIO() + buf = _StreamingStdout() old = sys.stdout sys.stdout = buf last = None @@ -81,15 +131,39 @@ def _lumbda_eval(src): if out and not out.endswith("\\n"): out += "\\n" out += rep + try: + _js_emit_chunk(("" if out.endswith(rep) else "\\n") + rep) + except Exception: + pass return out `); return { - async evalLisp(src) { + async evalLisp(src, onChunk) { pyodide.globals.set("_src_in", src); - return await pyodide.runPythonAsync("_lumbda_eval(_src_in)"); + refs.currentOnChunk = onChunk || null; + try { + return await pyodide.runPythonAsync("_lumbda_eval(_src_in)"); + } finally { + refs.currentOnChunk = null; + } }, setBendUrl(url) { refs.bendUrl = url || null; }, + // Portal save/resume bridge to vault — same shape as the C tier + // loader. Pyodide.FS is the same Emscripten FS, so the path + // /tmp/.portal is reachable from JS exactly as in C. + portalSave(name) { + const path = `/tmp/${name}.portal`; + try { + const bytes = pyodide.FS.readFile(path); + return new TextDecoder().decode(bytes); + } catch (e) { return null; } + }, + portalLoad(name, blob) { + const path = `/tmp/${name}.portal`; + try { pyodide.FS.mkdir("/tmp"); } catch (e) { /* exists */ } + pyodide.FS.writeFile(path, blob); + }, heapStats() { // Pyodide's runtime memory is the Emscripten linear memory. // CPython's GC reclaims behind the scenes, so this number diff --git a/www/playground/app.js b/www/playground/app.js index 33736fc..2c61f8a 100644 --- a/www/playground/app.js +++ b/www/playground/app.js @@ -213,19 +213,28 @@ function runOnTierInWorker(tier, src, onLoading) { return new Promise((resolve, reject) => { const w = ensureWorker(tier); const myRunId = ++workerState.nextRunId; + let liveBlock = null; workerState.pending[tier] = { runId: myRunId, resolve, reject }; const handler = (e) => { if (e.data.runId !== myRunId) return; if (e.data.kind === "loading") { onLoading && onLoading(e.data.tier); + } else if (e.data.kind === "chunk") { + // First chunk spawns the in-progress block; subsequent + // chunks append to it. User sees displays land + // immediately, not just at the end of the run. + if (!liveBlock) liveBlock = startLiveBlock(tier); + liveBlock.append(e.data.chunk); } else if (e.data.kind === "done") { w.removeEventListener("message", handler); workerState.pending[tier] = null; - resolve(e.data.output); + resolve({ output: e.data.output, liveBlock }); } else if (e.data.kind === "error") { w.removeEventListener("message", handler); workerState.pending[tier] = null; - reject(new Error(e.data.message)); + const err = new Error(e.data.message); + err.liveBlock = liveBlock; + reject(err); } }; w.addEventListener("message", handler); @@ -233,6 +242,44 @@ function runOnTierInWorker(tier, src, onLoading) { }); } +// Spawn a tier-block in the output panel immediately on first streamed +// chunk. Returns handles to append further chunks and to finalize the +// timing header once the eval reports done. Mirrors appendBlock's +// structure so finalized live blocks look identical to non-streamed +// ones — same DOM, same CSS. +function startLiveBlock(tierName) { + const block = document.createElement("div"); + block.className = "tier-block"; + const h = document.createElement("h3"); + h.textContent = TIERS[tierName] || tierName; + const t = document.createElement("span"); + t.className = "time"; + t.textContent = " (running…)"; + h.appendChild(t); + block.appendChild(h); + const pre = document.createElement("pre"); + pre.textContent = ""; + block.appendChild(pre); + outputEl.appendChild(block); + return { + append(chunk) { + pre.textContent += chunk; + outputEl.scrollTop = outputEl.scrollHeight; + }, + finalize(elapsedMs, kind, fullOutput) { + if (kind === "cancelled") t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`; + else t.textContent = ` (${elapsedMs.toFixed(0)} ms)`; + if (kind === "error") pre.className = "err"; + // If the streamed chunks miss anything (e.g. asm tier + // which doesn't stream yet), reconcile with the full + // output. No-op when streaming captured everything. + if (fullOutput && pre.textContent !== fullOutput) { + pre.textContent = fullOutput; + } + }, + }; +} + function cancelCurrentRun() { for (const t of Object.keys(workerState.workers)) { if (workerState.workers[t]) { @@ -307,20 +354,26 @@ async function runAll() { return runOnTierInWorker(t, src, (loadingTier) => { setStatus(`loading ${loadingTier}…`, "busy"); }) - .then((output) => { + .then(({ output, liveBlock }) => { const elapsed = performance.now() - startTimes[t]; delete startTimes[t]; - appendBlock(t, elapsed, output, "ok"); + // Streamed tiers already painted via liveBlock; just + // finalize the timing header. Non-streaming tiers + // (today: asm) get a fresh appendBlock at the end. + if (liveBlock) liveBlock.finalize(elapsed, "ok", output); + else appendBlock(t, elapsed, output, "ok"); return { tier: t, ok: true, elapsed }; }) .catch((e) => { const elapsed = performance.now() - startTimes[t]; delete startTimes[t]; if (e.message === "cancelled") { - appendBlock(t, elapsed, "(cancelled)", "cancelled"); + if (e.liveBlock) e.liveBlock.finalize(elapsed, "cancelled"); + else appendBlock(t, elapsed, "(cancelled)", "cancelled"); return { tier: t, cancelled: true, elapsed }; } - appendBlock(t, elapsed, e.message || String(e), "error"); + if (e.liveBlock) e.liveBlock.finalize(elapsed, "error", e.message); + else appendBlock(t, elapsed, e.message || String(e), "error"); return { tier: t, error: e.message, elapsed }; }); }); diff --git a/www/playground/c/lumbda-c.loader.js b/www/playground/c/lumbda-c.loader.js index 64e78c2..181d4fe 100644 --- a/www/playground/c/lumbda-c.loader.js +++ b/www/playground/c/lumbda-c.loader.js @@ -13,12 +13,25 @@ async function _bootstrap() { 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). let outBuf = []; let errBuf = []; + let currentOnChunk = null; const module = await createLumbdaC({ locateFile: (p) => wasmDir + p, - print: (line) => outBuf.push(line), - printErr: (line) => errBuf.push(line), + print: (line) => { + outBuf.push(line); + if (currentOnChunk) currentOnChunk(line); + }, + printErr: (line) => { + errBuf.push(line); + if (currentOnChunk) currentOnChunk(line); + }, }); const _init = module.cwrap("lumbda_wasm_init", null, []); @@ -56,10 +69,12 @@ async function _bootstrap() { const path = `/tmp/${name}.portal`; module.FS.writeFile(path, blob); }, - async evalLisp(src) { + async evalLisp(src, onChunk) { outBuf = []; errBuf = []; + currentOnChunk = onChunk || null; const errPtr = _eval(src); + currentOnChunk = null; let errMsg = ""; if (errPtr) { errMsg = module.UTF8ToString(errPtr); diff --git a/www/playground/python/lumbda-py.js b/www/playground/python/lumbda-py.js index 609cefc..34aff77 100644 --- a/www/playground/python/lumbda-py.js +++ b/www/playground/python/lumbda-py.js @@ -29,7 +29,15 @@ async function _bootstrap() { // the response text back. Sync XHR is the only sync HTTP available // in a Web Worker; perfect for the blocking eval model lumbda // primitives expect. - const refs = { bendUrl: null }; + const refs = { bendUrl: null, currentOnChunk: null }; + // Streaming output bridge — sys.stdout in pyodide is replaced + // (during _lumbda_eval) with a class whose write() calls back + // here. We forward to the current onChunk so the worker can + // postMessage chunks to the UI as work happens, instead of the + // user staring at a blank panel during a long bend dispatch. + globalThis._lumbdaPyEmitChunk = (s) => { + if (refs.currentOnChunk && s) refs.currentOnChunk(s); + }; globalThis._lumbdaPyBendCall = (payload) => { if (!refs.bendUrl) return "no bend URL configured"; try { @@ -63,8 +71,50 @@ def _bend_call_prim(args, env): return str(_js_bend_call(payload)) _env.define(lumbda.S('bend!-call'), _bend_call_prim) +# Portal save/resume — REPL bridge. Mirrors the C tier's +# (portal-snapshot! NAME) / (portal-load! NAME): writes/reads +# /tmp/.portal so the JS side can round-trip blobs to vault. +import os +os.makedirs('/tmp', exist_ok=True) + +def _portal_snapshot_prim(args, env): + if not args or not isinstance(args[0], str): + raise lumbda.LispErr('portal-snapshot!: expected string name') + path = f'/tmp/{args[0]}.portal' + g = env.g if env.g else _env + lumbda.portal_save(g, path) + return path + +def _portal_load_prim(args, env): + if not args or not isinstance(args[0], str): + raise lumbda.LispErr('portal-load!: expected string name') + path = f'/tmp/{args[0]}.portal' + restored_env, _cont = lumbda.portal_resume(path, _env) + # Copy bindings from the restored env into our live global env so + # subsequent evals see them. portal_resume gives us back a fresh + # env with the loaded bindings; we merge into _env in-place. + for sym, val in restored_env.b.items(): + _env.b[sym] = val + return True + +_env.define(lumbda.S('portal-snapshot!'), _portal_snapshot_prim) +_env.define(lumbda.S('portal-load!'), _portal_load_prim) + +# Streaming stdout wrapper — every write() also calls back into JS so +# the worker can postMessage chunks to the UI during the eval. The +# StringIO behind it still captures everything for the final return. +from js import _lumbdaPyEmitChunk as _js_emit_chunk +class _StreamingStdout(io.StringIO): + def write(self, s): + n = super().write(s) + try: + _js_emit_chunk(s) + except Exception: + pass + return n + def _lumbda_eval(src): - buf = io.StringIO() + buf = _StreamingStdout() old = sys.stdout sys.stdout = buf last = None @@ -81,15 +131,39 @@ def _lumbda_eval(src): if out and not out.endswith("\\n"): out += "\\n" out += rep + try: + _js_emit_chunk(("" if out.endswith(rep) else "\\n") + rep) + except Exception: + pass return out `); return { - async evalLisp(src) { + async evalLisp(src, onChunk) { pyodide.globals.set("_src_in", src); - return await pyodide.runPythonAsync("_lumbda_eval(_src_in)"); + refs.currentOnChunk = onChunk || null; + try { + return await pyodide.runPythonAsync("_lumbda_eval(_src_in)"); + } finally { + refs.currentOnChunk = null; + } }, setBendUrl(url) { refs.bendUrl = url || null; }, + // Portal save/resume bridge to vault — same shape as the C tier + // loader. Pyodide.FS is the same Emscripten FS, so the path + // /tmp/.portal is reachable from JS exactly as in C. + portalSave(name) { + const path = `/tmp/${name}.portal`; + try { + const bytes = pyodide.FS.readFile(path); + return new TextDecoder().decode(bytes); + } catch (e) { return null; } + }, + portalLoad(name, blob) { + const path = `/tmp/${name}.portal`; + try { pyodide.FS.mkdir("/tmp"); } catch (e) { /* exists */ } + pyodide.FS.writeFile(path, blob); + }, heapStats() { // Pyodide's runtime memory is the Emscripten linear memory. // CPython's GC reclaims behind the scenes, so this number diff --git a/www/playground/runner.js b/www/playground/runner.js index aa602af..5379f6f 100644 --- a/www/playground/runner.js +++ b/www/playground/runner.js @@ -29,9 +29,9 @@ export async function getTier(name, onLoad) { return cache[name]; } -export async function evalOnTier(name, src, onLoad) { +export async function evalOnTier(name, src, onLoad, onChunk) { const tier = await getTier(name, onLoad); - return tier.evalLisp(src); + return tier.evalLisp(src, onChunk); } export function heapStats(name) { @@ -39,3 +39,20 @@ export function heapStats(name) { if (!tier || !tier.heapStats) return null; return tier.heapStats(); } + +// Portal — REPL save/resume bridge. portalSave reads a snapshot blob +// from the tier (MEMFS on the C tier, similar bridges on others as +// they land). Returns null when the tier hasn't implemented portals +// yet (asm-wat today) so the caller can surface a friendly message +// instead of crashing. portalLoad is the symmetric write-in path. +export function portalSave(name, checkpointName) { + const tier = cache[name]; + if (!tier || !tier.portalSave) return null; + return tier.portalSave(checkpointName); +} +export function portalLoad(name, checkpointName, blob) { + const tier = cache[name]; + if (!tier || !tier.portalLoad) return false; + tier.portalLoad(checkpointName, blob); + return true; +} diff --git a/www/playground/worker.mjs b/www/playground/worker.mjs index 760c680..795f0eb 100644 --- a/www/playground/worker.mjs +++ b/www/playground/worker.mjs @@ -3,7 +3,7 @@ // live ms counter actually ticks AND so cancel works (main thread // terminates this worker via worker.terminate()). -import { evalOnTier, setBendUrl, heapStats } from "./runner.js"; +import { evalOnTier, setBendUrl, heapStats, portalSave, portalLoad } from "./runner.js"; self.onmessage = async (e) => { const { kind } = e.data; @@ -17,12 +17,39 @@ self.onmessage = async (e) => { self.postMessage({ kind: "heap", runId: e.data.runId, tier: e.data.tier, stats: heapStats(e.data.tier) }); return; } + if (kind === "portal-save") { + // Pulls the most-recent /tmp/.portal out of the tier's + // MEMFS after the lisp side ran (portal-snapshot! NAME). Main + // thread encrypts and stuffs it into the vault. + const blob = portalSave(e.data.tier, e.data.name); + self.postMessage({ kind: "portal-save", runId: e.data.runId, name: e.data.name, blob }); + return; + } + if (kind === "portal-load") { + // Hydrates MEMFS from a vault-decrypted blob so a subsequent + // (portal-load! NAME) eval finds the file ready. + const ok = portalLoad(e.data.tier, e.data.name, e.data.blob); + self.postMessage({ kind: "portal-load", runId: e.data.runId, name: e.data.name, ok }); + return; + } if (kind !== "eval") return; const { runId, tier, src } = e.data; try { - const output = await evalOnTier(tier, src, (loadingTier) => { - self.postMessage({ kind: "loading", runId, tier: loadingTier }); - }); + const output = await evalOnTier( + tier, + src, + (loadingTier) => { + self.postMessage({ kind: "loading", runId, tier: loadingTier }); + }, + // Stream every print/display from the tier to the main + // thread as it happens. Sync XHR inside bend!-call still + // blocks the worker, but displays BEFORE/AFTER the bend + // round-trip surface immediately instead of waiting for + // the whole eval to finish. Long demos feel alive. + (chunk) => { + self.postMessage({ kind: "chunk", runId, tier, chunk }); + }, + ); self.postMessage({ kind: "done", runId, output }); } catch (err) { self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });