User-visible changes
- Cancel button — terminates the running worker. Pyodide's slow mandelbrot
no longer freezes the UI; click cancel and the elapsed counter freezes
at "(cancelled @ NNNN ms)".
- Live ms counter ticks per animation frame while a tier is busy, so the
Pyodide tier's ~5-15 s wait is visible instead of looking hung.
- Restyled to match lumbda.com: chunkfive wordmark, --green: #227842
(light) / #5ec07a (dark), lumbda-logo-green.png, lowercase "lumbda"
everywhere. Pulls fonts/chunkfive locally so the playground stays
self-contained.
Architecture
- All tier evaluations now run inside a Web Worker (wasm/app/worker.mjs)
so the main thread stays responsive. Cancel = worker.terminate(); next
eval respawns a fresh worker.
- Loaders use new URL("./...", import.meta.url) so paths resolve against
the loader file's own location — works identically in window and
worker contexts, no baseURL argument needed.
- C tier Emscripten build flipped to EXPORT_ES6=1; loader uses dynamic
`import()` of the factory module. Integration test updated accordingly.
- Python loader uses `import("pyodide.mjs")` (ES module) instead of
document.createElement, which doesn't exist in workers.
Bug fixes
- Asm tier state leak: running the same demo twice on a cached WASM
instance produced corrupted output (every other cell on row 2+ rendered
as " " instead of the expected shade char). Root cause: top-level eval
passed `global_env` as the env, so closures captured stale globals;
fixed by passing NIL — env_lookup falls back to the CURRENT global_env
via its existing two-pass walk. Multi-run regression added to the
functional test suite.
- fib-ack demo: (ack 3 4) was too heavy for Pyodide (minutes). Cut to
(ack 3 3) + (fib 20) max so every tier finishes in seconds.
Test discipline
- Root `make test-all` now includes `wasm-test`. Adding a language
feature without exercising it on all six implementations is no longer
possible by accident.
- Functional suite: 11 assertions (was 10) — adds asm multi-run stability.
- Integration + unit: still 20 + 8.
227 lines
7.7 KiB
JavaScript
227 lines
7.7 KiB
JavaScript
// wasm/app/app.js
|
|
// 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";
|
|
import { defaultKeymap, history, historyKeymap } from "@codemirror/commands";
|
|
import { syntaxHighlighting, defaultHighlightStyle, StreamLanguage } from "@codemirror/language";
|
|
import { scheme } from "@codemirror/legacy-modes/mode/scheme";
|
|
import { oneDark } from "@codemirror/theme-one-dark";
|
|
|
|
const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" };
|
|
|
|
const demoSources = {};
|
|
|
|
async function loadDemoSource(name) {
|
|
if (!demoSources[name]) {
|
|
const resp = await fetch(`demos/${name}.lsp`);
|
|
demoSources[name] = await resp.text();
|
|
}
|
|
return demoSources[name];
|
|
}
|
|
|
|
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({
|
|
doc: "",
|
|
extensions: [
|
|
lineNumbers(),
|
|
history(),
|
|
drawSelection(),
|
|
syntaxHighlighting(defaultHighlightStyle),
|
|
StreamLanguage.define(scheme),
|
|
keymap.of([...defaultKeymap, ...historyKeymap]),
|
|
oneDark,
|
|
EditorView.theme({ "&": { height: "100%" } }),
|
|
],
|
|
}),
|
|
parent: editorParent,
|
|
});
|
|
|
|
function setEditorText(text) {
|
|
editorView.dispatch({
|
|
changes: { from: 0, to: editorView.state.doc.length, insert: text },
|
|
});
|
|
}
|
|
|
|
function getEditorText() {
|
|
return editorView.state.doc.toString();
|
|
}
|
|
|
|
async function loadCurrentDemo() {
|
|
const sel = document.querySelector('input[name="program"]:checked').value;
|
|
const src = await loadDemoSource(sel);
|
|
setEditorText(src);
|
|
}
|
|
|
|
function selectedTiers() {
|
|
const sel = document.querySelector('input[name="tier"]:checked').value;
|
|
return sel === "all" ? ["python", "c", "asm"] : [sel];
|
|
}
|
|
|
|
function setStatus(text, cls) {
|
|
statusEl.textContent = text || "";
|
|
statusEl.className = "status" + (cls ? " " + cls : "");
|
|
}
|
|
|
|
// ─── 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;
|
|
cancelBtn.disabled = false;
|
|
setStatus("running…", "busy");
|
|
outputEl.innerHTML = "";
|
|
let anyErr = false;
|
|
let cancelled = false;
|
|
try {
|
|
const tiers = selectedTiers();
|
|
const src = getEditorText();
|
|
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();
|