lumbda/wasm/app/app.js
russell@unturf.com 049a1e1935
playground streaming: single text node — fixes 5 functional test regressions
Apply the REPL's per-line-div → single Text node refactor to the
playground too. Same perf win on high-volume streams plus a bonus:
the 5 functional tests that started failing after the per-line-div
era (mandelbrot/fib-ack/sieve/self-interp on C, fib-ack on asm)
all pass again. They were reading pre.textContent which silently
joined sibling divs without the \n separators they expected; a
single text node with embedded \n round-trips the assertion
exactly.

11/11 functional tests green.
2026-06-15 14:04:16 -04:00

467 lines
18 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";
import { openVault } from "./crypto.js";
const TIERS = { python: "python (pyodide)", c: "c (emcc)", asm: "asm (wat)" };
const FREE_FORM_DEFAULT = `; free-form mode — unlock the vault below to persist this code
; encrypted in localStorage with your password
(print "67")
(/ 42 6)
`;
const demoSources = {};
// Vault state. When unlocked, free-form code auto-saves on every edit.
const vaultState = { vault: null, freeForm: null, saveTimer: null };
async function loadDemoSource(name) {
if (name === "free-form") {
return vaultState.freeForm != null ? vaultState.freeForm : FREE_FORM_DEFAULT;
}
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.updateListener.of((u) => {
if (u.docChanged) scheduleFreeFormSave();
}),
],
}),
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 vaultBar = document.getElementById("vault-bar");
vaultBar.hidden = sel !== "free-form";
const src = await loadDemoSource(sel);
setEditorText(src);
}
// ─── Free-form vault ───────────────────────────────────────────────
const vaultBarEl = document.getElementById("vault-bar");
const vaultPwEl = document.getElementById("vault-pw");
const vaultUnlockBtn = document.getElementById("vault-unlock");
const vaultLockBtn = document.getElementById("vault-lock");
const vaultStateEl = document.getElementById("vault-state");
const vaultNoteEl = document.getElementById("vault-note");
function setVaultNote(text, isErr) {
vaultNoteEl.textContent = text || "";
vaultNoteEl.className = "vault-note" + (isErr ? " err" : "");
}
async function unlockVault() {
const pw = vaultPwEl.value;
if (!pw) { setVaultNote("password required", true); return; }
setVaultNote("");
try {
vaultState.vault = await openVault(pw);
const data = await vaultState.vault.read();
if (data && data.__decryptionFailed) {
setVaultNote("vault exists but password is wrong", true);
vaultState.vault = null;
return;
}
vaultState.freeForm = (data && typeof data.freeForm === "string")
? data.freeForm
: FREE_FORM_DEFAULT;
vaultStateEl.textContent = "unlocked";
vaultStateEl.classList.add("unlocked");
vaultUnlockBtn.hidden = true;
vaultLockBtn.hidden = false;
vaultPwEl.value = "";
vaultPwEl.disabled = true;
// If free-form is the current program, swap the editor in.
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel === "free-form") setEditorText(vaultState.freeForm);
setVaultNote("ok — edits auto-save");
} catch (e) {
setVaultNote("unlock failed: " + e.message, true);
}
}
function lockVault() {
vaultState.vault = null;
vaultState.freeForm = null;
if (vaultState.saveTimer) { clearTimeout(vaultState.saveTimer); vaultState.saveTimer = null; }
vaultStateEl.textContent = "locked";
vaultStateEl.classList.remove("unlocked");
vaultUnlockBtn.hidden = false;
vaultLockBtn.hidden = true;
vaultPwEl.disabled = false;
vaultPwEl.value = "";
setVaultNote("");
// If free-form is current program, swap to the placeholder.
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel === "free-form") setEditorText(FREE_FORM_DEFAULT);
}
function scheduleFreeFormSave() {
if (!vaultState.vault) return;
const sel = document.querySelector('input[name="program"]:checked').value;
if (sel !== "free-form") return;
if (vaultState.saveTimer) clearTimeout(vaultState.saveTimer);
vaultState.saveTimer = setTimeout(async () => {
const text = getEditorText();
vaultState.freeForm = text;
try { await vaultState.vault.write({ freeForm: text, savedAt: Date.now() }); }
catch (e) { setVaultNote("save failed: " + e.message, true); }
}, 350);
}
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 per tier so the three tiers run on independent threads —
// in "All three" mode they race, and a slow tier never blocks a fast one.
// Cancel terminates every active worker; next eval recreates them.
const workerState = {
workers: { python: null, c: null, asm: null },
pending: { python: null, c: null, asm: null },
nextRunId: 0,
};
function spawnWorker() {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
if (bendState.url) w.postMessage({ kind: "config", bendUrl: bendState.url });
return w;
}
function ensureWorker(tier) {
if (!workerState.workers[tier]) workerState.workers[tier] = spawnWorker();
return workerState.workers[tier];
}
// ─── Bend URL ──────────────────────────────────────────────────────
const bendState = { url: localStorage.getItem("lumbda_bend_url") || "" };
const bendUrlEl = document.getElementById("bend-url");
const bendSaveBtn = document.getElementById("bend-save");
const bendStateEl = document.getElementById("bend-state");
if (bendState.url) {
bendUrlEl.value = bendState.url;
bendStateEl.textContent = "saved";
}
function saveBendUrl() {
bendState.url = bendUrlEl.value.trim();
if (bendState.url) {
localStorage.setItem("lumbda_bend_url", bendState.url);
bendStateEl.textContent = "saved";
} else {
localStorage.removeItem("lumbda_bend_url");
bendStateEl.textContent = "";
}
// Push config to any already-spawned workers.
for (const w of Object.values(workerState.workers)) {
if (w) w.postMessage({ kind: "config", bendUrl: bendState.url });
}
}
bendSaveBtn.addEventListener("click", saveBendUrl);
bendUrlEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); saveBendUrl(); }
});
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-batch") {
// Worker batches tight-loop output into 4KB/64-line
// postMessages. Split back into individual text+eol
// calls on the playground side too.
if (!liveBlock) liveBlock = startLiveBlock(tier);
const t = e.data.text;
let start = 0;
for (let i = 0; i < t.length; i++) {
if (t.charCodeAt(i) === 10) {
if (i > start) liveBlock.appendText(t.slice(start, i));
liveBlock.appendNewline();
start = i + 1;
}
}
if (start < t.length) liveBlock.appendText(t.slice(start));
} else if (e.data.kind === "chunk-text") {
if (!liveBlock) liveBlock = startLiveBlock(tier);
liveBlock.appendText(e.data.text);
} else if (e.data.kind === "chunk-eol") {
if (!liveBlock) liveBlock = startLiveBlock(tier);
liveBlock.appendNewline();
} else if (e.data.kind === "done") {
w.removeEventListener("message", handler);
workerState.pending[tier] = null;
resolve({ output: e.data.output, liveBlock });
} else if (e.data.kind === "error") {
w.removeEventListener("message", handler);
workerState.pending[tier] = null;
const err = new Error(e.data.message);
err.liveBlock = liveBlock;
reject(err);
}
};
w.addEventListener("message", handler);
w.postMessage({ kind: "eval", runId: myRunId, tier, src });
});
}
// 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);
// Single Text node accumulator — appendData is ~1000x faster
// than appendChild(div) per line on high-volume streams. The
// <pre> wrapper already preserves \n as a real line break, so
// we don't need per-line elements. RAF coalesces chunk pile-ups
// so a tight (display) loop can't drown the main thread.
pre.style.whiteSpace = "pre";
const textNode = document.createTextNode("");
pre.appendChild(textNode);
let buffered = "";
let rafPending = false;
function flushBuffered() {
rafPending = false;
if (!buffered) return;
textNode.appendData(buffered);
buffered = "";
outputEl.scrollTop = outputEl.scrollHeight;
}
function scheduleFlush() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(flushBuffered);
}
return {
appendText(t) {
if (!t) return;
buffered += t;
scheduleFlush();
},
appendNewline() {
buffered += "\n";
scheduleFlush();
},
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";
// Drain any RAF-buffered chunks then reconcile against the
// full eval output — covers the asm-tier fallback where
// streaming isn't wired and the live text node stays empty
// until finalize lands the canonical value.
if (rafPending) flushBuffered();
if (fullOutput) {
const got = textNode.nodeValue.replace(/\n$/, "");
if (got !== fullOutput.replace(/\n$/, "")) {
textNode.nodeValue = fullOutput;
}
}
},
};
}
function cancelCurrentRun() {
for (const t of Object.keys(workerState.workers)) {
if (workerState.workers[t]) {
workerState.workers[t].terminate();
workerState.workers[t] = null;
}
if (workerState.pending[t]) {
workerState.pending[t].reject(new Error("cancelled"));
workerState.pending[t] = null;
}
}
}
// Append a finished tier-block to the output. Order = finish order, so
// the fastest tier appears first naturally.
function appendBlock(tierName, elapsedMs, text, kind) {
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";
if (kind === "cancelled") t.textContent = ` (cancelled @ ${elapsedMs.toFixed(0)} ms)`;
else t.textContent = ` (${elapsedMs.toFixed(0)} ms)`;
h.appendChild(t);
block.appendChild(h);
const pre = document.createElement("pre");
if (kind === "error") pre.className = "err";
pre.textContent = text;
block.appendChild(pre);
outputEl.appendChild(block);
}
// ─── Run / cancel ──────────────────────────────────────────────────
let inFlight = false;
async function runAll() {
if (inFlight) return;
// bend demo guard: refuse to run locally without a configured bend URL.
// A CPU-impossible workload would lock the customer's tab.
const program = document.querySelector('input[name="program"]:checked').value;
if (program === "bend-gpu" && !bendState.url) {
setStatus("set a bend URL first — this demo is GPU-only by design", "err");
return;
}
inFlight = true;
runBtn.disabled = true;
cancelBtn.disabled = false;
setStatus("running…", "busy");
outputEl.innerHTML = "";
const tiers = selectedTiers();
const src = getEditorText();
// Each tier in its own worker. Append-only output: as each tier finishes,
// we append its block — so the fastest tier shows up first.
const startTimes = {};
const tickStatus = () => {
const parts = [];
for (const t of tiers) {
if (startTimes[t] !== undefined) {
parts.push(`${t} ${((performance.now() - startTimes[t]) | 0)}ms`);
}
}
setStatus(parts.join(" · "), "busy");
};
let raf = requestAnimationFrame(function loop() {
tickStatus();
raf = requestAnimationFrame(loop);
});
const tierPromises = tiers.map((t) => {
startTimes[t] = performance.now();
return runOnTierInWorker(t, src, (loadingTier) => {
setStatus(`loading ${loadingTier}`, "busy");
})
.then(({ output, liveBlock }) => {
const elapsed = performance.now() - startTimes[t];
delete startTimes[t];
// 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") {
if (e.liveBlock) e.liveBlock.finalize(elapsed, "cancelled");
else appendBlock(t, elapsed, "(cancelled)", "cancelled");
return { tier: t, cancelled: true, elapsed };
}
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 };
});
});
try {
const results = await Promise.all(tierPromises);
cancelAnimationFrame(raf);
const cancelled = results.some((r) => r.cancelled);
const anyErr = results.some((r) => r.error);
if (cancelled) setStatus("cancelled", "warn");
else if (anyErr) setStatus("completed with errors", "err");
else {
const fastest = results.reduce((a, b) => (a.elapsed < b.elapsed ? a : b));
setStatus(`ok — ${fastest.tier} won in ${fastest.elapsed.toFixed(0)} ms`, "ok");
}
} catch (e) {
cancelAnimationFrame(raf);
setStatus(`fatal: ${e.message}`, "err");
} 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;
vaultUnlockBtn.addEventListener("click", unlockVault);
vaultLockBtn.addEventListener("click", lockVault);
vaultPwEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") { e.preventDefault(); unlockVault(); }
});
loadCurrentDemo();