lumbda/wasm/app/app.js
russell@unturf.com 89d4877ee9
playground streaming: split chunks on \n in the worker, send eol flag
After (4) rounds of newline-debugging where node tests said chunks
contained \n but fox's Firefox tab still rendered horizontal text
for the c tier (python+asm rendered vertical with the same code
path), giving up on trying to guess where the \n was being eaten
and instead getting rid of \n bytes on the wire entirely.

Worker now splits each onChunk slice on \n at the source and posts
one message per line — { kind:'chunk', tier, chunk:'tick 0', eol:true }
— so the line break is carried as a boolean flag rather than a byte.
Main thread re-attaches the '\n' before handing to liveBlock.append,
which still walks bytes for charCodeAt 10 and stacks per line.

Effectively: the loader's currentOnChunk(line + '\n') feeds the
worker which immediately splits back on the \n, both halves still
arrive on the main thread, the main thread reconstitutes them with
a fresh \n that we now know our DOM split honors (proved by the
python tier which uses the same liveBlock.append). C-tier-specific
\n loss between Module.print and postMessage drops out of the
picture.
2026-06-15 06:24:57 -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") {
// 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.
// Worker splits chunks on \n at the source and sends
// each line with an eol flag — re-attach the newline
// here so liveBlock.append still walks bytes looking
// for charCodeAt 10 and stacks per line. Avoids any
// unknown-quantity newline loss between Module.print
// and the main thread.
if (!liveBlock) liveBlock = startLiveBlock(tier);
liveBlock.append(e.data.chunk + (e.data.eol ? "\n" : ""));
} 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);
// Each chunk gets split on \n: every completed line becomes its
// own block-level <div>, the trailing fragment without a newline
// accumulates in a pending div that's updated in place until the
// next \n closes it. display:block + white-space:pre set INLINE
// so a stale cached stylesheet can't suppress the fix — we kept
// chasing what turned out to be CSS not reloading.
const makeLine = () => {
const d = document.createElement("div");
d.style.display = "block";
d.style.whiteSpace = "pre";
return d;
};
let pendingLine = makeLine();
pre.appendChild(pendingLine);
let pendingText = "";
return {
append(chunk) {
if (!chunk) return;
let start = 0;
for (let i = 0; i < chunk.length; i++) {
if (chunk.charCodeAt(i) === 10) {
pendingText += chunk.slice(start, i);
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
pre.appendChild(pendingLine);
start = i + 1;
}
}
if (start < chunk.length) {
pendingText += chunk.slice(start);
pendingLine.textContent = pendingText;
}
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";
// Reconcile against the full output if the streamed
// per-line divs don't add up — e.g. asm-tier fallback
// where streaming isn't wired yet. Otherwise the joined
// per-line text should match the eval's full output.
if (fullOutput) {
const got = Array.from(pre.children).map((d) =>
d.textContent === " " ? "" : d.textContent).join("\n");
if (got !== fullOutput.replace(/\n$/, "")) {
pre.textContent = "";
const lines = fullOutput.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
pre.appendChild(d);
}
}
}
},
};
}
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();