lumbda/www/playground/app.js
russell@unturf.com 1b7de2c9c6
wasm/playground: cancel button, asm state-leak fix, restyle to match homepage
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.
2026-06-14 12:13:20 -04:00

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();