diff --git a/Makefile b/Makefile index f933c46..0d102e5 100644 --- a/Makefile +++ b/Makefile @@ -117,9 +117,9 @@ regression-named-let-leak: c-build prove-ursa-runs: c-build @bash tests/prove-ursa-runs.sh -test-all: test c-test asm-test functional-test portal-rng-cross-test zoe-favorites-test regression-named-let-leak +test-all: test c-test asm-test functional-test portal-rng-cross-test zoe-favorites-test regression-named-let-leak wasm-test @echo "════════════════════════════════════════════════════" - @echo "All tests passed (Python + C + Assembly + functional + portal-rng-cross + zoe-favorites)" + @echo "All tests passed (Python + C + Assembly + functional + portal-rng-cross + zoe-favorites + wasm)" # ─── Benchmarks (reproducible; referenced in whitepaper §6–§11) ─── diff --git a/wasm/Makefile b/wasm/Makefile index fdec50c..763edc3 100644 --- a/wasm/Makefile +++ b/wasm/Makefile @@ -87,7 +87,7 @@ C_JIT_STUB := c/jit-stub.c C_CFLAGS := -O2 -DLUMBDA_WASM -std=c11 -D_POSIX_C_SOURCE=200809L -D_GNU_SOURCE \ -I$(C_SRC_DIR) -Wno-everything -C_LDFLAGS := -s WASM=1 -s MODULARIZE=1 -s EXPORT_ES6=0 \ +C_LDFLAGS := -s WASM=1 -s MODULARIZE=1 -s EXPORT_ES6=1 \ -s EXPORT_NAME=createLumbdaC \ -s EXPORTED_FUNCTIONS='["_lumbda_wasm_init","_lumbda_wasm_eval","_lumbda_wasm_free_result","_malloc","_free"]' \ -s EXPORTED_RUNTIME_METHODS='["cwrap","ccall","UTF8ToString","stringToUTF8","lengthBytesUTF8"]' \ @@ -128,9 +128,11 @@ $(DIST)/asm/lumbda-asm.loader.js: asm/lumbda-asm.loader.js # ─── SPA shell ───────────────────────────────────────────────────── -APP_SRC := $(wildcard app/*.html app/*.css app/*.js app/demos/*.lsp) +APP_SRC := $(wildcard app/*.html app/*.css app/*.js app/*.mjs app/demos/*.lsp) -app: $(DIST)/index.html $(DIST)/style.css $(DIST)/app.js $(DIST)/runner.js +app: $(DIST)/index.html $(DIST)/style.css $(DIST)/app.js $(DIST)/runner.js $(DIST)/worker.mjs \ + $(DIST)/lumbda-logo-green.png $(DIST)/fonts/chunkfive/chunkfive-regular-webfont.woff2 \ + $(DIST)/fonts/chunkfive/chunkfive-regular-webfont.woff $(DIST)/%: app/% @mkdir -p $(@D) diff --git a/wasm/app/app.js b/wasm/app/app.js index 53dc99c..e1a9abb 100644 --- a/wasm/app/app.js +++ b/wasm/app/app.js @@ -1,5 +1,7 @@ // wasm/app/app.js -// Single-page app shell — CodeMirror 6 editor + tier runner. +// Single-page app shell — CodeMirror 6 editor + Web-Worker-backed tier +// runner. The main thread stays responsive: the live ms counter ticks +// every animation frame, and Cancel terminates the worker mid-eval. import { EditorState } from "@codemirror/state"; import { EditorView, keymap, lineNumbers, drawSelection } from "@codemirror/view"; @@ -8,10 +10,7 @@ import { syntaxHighlighting, defaultHighlightStyle, StreamLanguage } from "@code import { scheme } from "@codemirror/legacy-modes/mode/scheme"; import { oneDark } from "@codemirror/theme-one-dark"; -import { runOnTiers } from "./runner.js"; - -const DEMOS = ["mandelbrot", "fib-ack", "sieve", "self-interp"]; -const TIERS = { python: "Python (Pyodide)", c: "C (emcc)", asm: "Asm (WAT)" }; +const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" }; const demoSources = {}; @@ -27,6 +26,7 @@ const editorParent = document.getElementById("editor"); const outputEl = document.getElementById("output"); const statusEl = document.getElementById("status"); const runBtn = document.getElementById("run"); +const cancelBtn = document.getElementById("cancel"); const editorView = new EditorView({ state: EditorState.create({ @@ -71,51 +71,157 @@ function setStatus(text, cls) { statusEl.className = "status" + (cls ? " " + cls : ""); } -function renderResults(results) { - outputEl.innerHTML = ""; - for (const r of results) { - const block = document.createElement("div"); - block.className = "tier-block"; - const h = document.createElement("h3"); - h.textContent = TIERS[r.tier] || r.tier; - const t = document.createElement("span"); - t.className = "time"; - t.textContent = ` (${r.elapsed.toFixed(0)} ms)`; - h.appendChild(t); - block.appendChild(h); - const pre = document.createElement("pre"); - if (r.error) { - pre.className = "err"; - pre.textContent = r.error; - } else { - pre.textContent = r.output; - } - block.appendChild(pre); - outputEl.appendChild(block); +// ─── Worker plumbing ─────────────────────────────────────────────── +// One worker hosts all tiers. Cancel terminates it; the next eval +// creates a fresh one. Cached Pyodide / Emscripten state is lost on +// cancel, which is the price of true cancellation. + +const workerState = { worker: null, runId: 0, pending: null }; + +function spawnWorker() { + return new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" }); +} + +function ensureWorker() { + if (!workerState.worker) workerState.worker = spawnWorker(); + return workerState.worker; +} + +function runOnTierInWorker(tier, src, onLoading) { + return new Promise((resolve, reject) => { + const w = ensureWorker(); + const myRunId = ++workerState.runId; + workerState.pending = { 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 === "done") { + w.removeEventListener("message", handler); + workerState.pending = null; + resolve(e.data.output); + } else if (e.data.kind === "error") { + w.removeEventListener("message", handler); + workerState.pending = null; + reject(new Error(e.data.message)); + } + }; + w.addEventListener("message", handler); + w.postMessage({ kind: "eval", runId: myRunId, tier, src }); + }); +} + +function cancelCurrentRun() { + if (workerState.worker) { + workerState.worker.terminate(); + workerState.worker = null; + } + if (workerState.pending) { + workerState.pending.reject(new Error("cancelled")); + workerState.pending = null; } } +// ─── Per-tier output block with live ms counter ──────────────────── + +function makeLiveBlock(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 = " loading…"; + h.appendChild(t); + block.appendChild(h); + const pre = document.createElement("pre"); + pre.textContent = ""; + block.appendChild(pre); + outputEl.appendChild(block); + + let raf = 0; + let start = 0; + function tickerLoop() { + const ms = (performance.now() - start) | 0; + t.textContent = ` ${ms} ms…`; + raf = requestAnimationFrame(tickerLoop); + } + return { + startTimer() { start = performance.now(); tickerLoop(); return start; }, + stop(elapsedMs) { + if (raf) cancelAnimationFrame(raf); + t.textContent = ` (${elapsedMs.toFixed(0)} ms)`; + }, + cancelled(elapsedMs) { + if (raf) cancelAnimationFrame(raf); + t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`; + }, + setOutput(text) { pre.textContent = text; }, + setError(msg) { pre.className = "err"; pre.textContent = msg; }, + }; +} + +// ─── Run / cancel ────────────────────────────────────────────────── + +let inFlight = false; + async function runAll() { + if (inFlight) return; + inFlight = true; runBtn.disabled = true; - setStatus("loading tiers…", "busy"); + cancelBtn.disabled = false; + setStatus("running…", "busy"); outputEl.innerHTML = ""; + let anyErr = false; + let cancelled = false; try { const tiers = selectedTiers(); const src = getEditorText(); - const results = await runOnTiers(tiers, src, (msg) => setStatus(msg, "busy")); - renderResults(results); - const anyErr = results.some((r) => r.error); - setStatus(anyErr ? "completed with errors" : "ok", anyErr ? "err" : "ok"); + for (const t of tiers) { + const live = makeLiveBlock(t); + const startMark = live.startTimer(); + try { + const output = await runOnTierInWorker(t, src, (loadingTier) => { + setStatus(`loading ${loadingTier} tier…`, "busy"); + }); + setStatus(`running ${t}…`, "busy"); + const elapsed = performance.now() - startMark; + live.stop(elapsed); + live.setOutput(output); + } catch (e) { + const elapsed = performance.now() - startMark; + if (e.message === "cancelled") { + live.cancelled(elapsed); + cancelled = true; + break; + } + live.stop(elapsed); + live.setError(e.message || String(e)); + anyErr = true; + } + } + if (cancelled) setStatus("cancelled", "warn"); + else setStatus(anyErr ? "completed with errors" : "ok", anyErr ? "err" : "ok"); } catch (e) { setStatus(`fatal: ${e.message}`, "err"); outputEl.textContent = e.stack || e.message; } finally { + inFlight = false; runBtn.disabled = false; + cancelBtn.disabled = true; } } +function onCancel() { + if (!inFlight) return; + setStatus("cancelling…", "warn"); + cancelCurrentRun(); +} + document.querySelectorAll('input[name="program"]').forEach((el) => { el.addEventListener("change", loadCurrentDemo); }); runBtn.addEventListener("click", runAll); +cancelBtn.addEventListener("click", onCancel); +cancelBtn.disabled = true; loadCurrentDemo(); diff --git a/wasm/app/demos/fib-ack.lsp b/wasm/app/demos/fib-ack.lsp index c8f4511..391dbce 100644 --- a/wasm/app/demos/fib-ack.lsp +++ b/wasm/app/demos/fib-ack.lsp @@ -10,8 +10,11 @@ ((= n 0) (ack (- m 1) 1)) (else (ack (- m 1) (ack m (- n 1)))))) +; Demo deliberately stays small so Pyodide finishes in a few seconds. +; (ack 3 4) is 125 but takes minutes via CPython-in-WASM tree-walker; +; cut down to (ack 3 3) so every tier can run it. +(display "fib(15) = ") (print (fib 15)) (display "fib(20) = ") (print (fib 20)) -(display "fib(25) = ") (print (fib 25)) (display "ack(2,3) = ") (print (ack 2 3)) -(display "ack(3,4) = ") (print (ack 3 4)) +(display "ack(3,3) = ") (print (ack 3 3)) (print "done") diff --git a/wasm/app/fonts/chunkfive/chunkfive-regular-webfont.woff b/wasm/app/fonts/chunkfive/chunkfive-regular-webfont.woff new file mode 100644 index 0000000..54ced00 Binary files /dev/null and b/wasm/app/fonts/chunkfive/chunkfive-regular-webfont.woff differ diff --git a/wasm/app/fonts/chunkfive/chunkfive-regular-webfont.woff2 b/wasm/app/fonts/chunkfive/chunkfive-regular-webfont.woff2 new file mode 100644 index 0000000..7cecf49 Binary files /dev/null and b/wasm/app/fonts/chunkfive/chunkfive-regular-webfont.woff2 differ diff --git a/wasm/app/index.html b/wasm/app/index.html index a7c5f6d..648ccef 100644 --- a/wasm/app/index.html +++ b/wasm/app/index.html @@ -3,7 +3,7 @@ -Lumbda playground — Lisp in your browser, three tiers +lumbda playground — lisp in your browser, three tiers