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.
This commit is contained in:
russell@unturf.com 2026-06-14 12:13:20 -04:00
parent 9c25d46e13
commit 1b7de2c9c6
No known key found for this signature in database
31 changed files with 866 additions and 435 deletions

View file

@ -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) ───

View file

@ -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)

View file

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

View file

@ -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")

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lumbda playground — Lisp in your browser, three tiers</title>
<title>lumbda playground — lisp in your browser, three tiers</title>
<link rel="stylesheet" href="style.css">
<script type="importmap">
{
@ -25,31 +25,36 @@
</head>
<body>
<header>
<h1>Lumbda <span class="sub">— Lisp/Scheme in your browser, three tiers in parallel</span></h1>
<p class="tag">
Same Lisp source. Three implementations compiled to WebAssembly:
<strong>Python</strong> (CPython via Pyodide hosting <code>lumbda.py</code>),
<strong>C</strong> (Emscripten build of the tree-walker + bytecode VM),
<strong>Asm</strong> (hand-written WebAssembly Text format — parallel to <code>asm/lumbda.s</code>).
<a class="brand" href="../" aria-label="lumbda home">
<img class="logo" src="lumbda-logo-green.png" alt="" aria-hidden="true">
<h1 aria-label="lumbda.">lumbda<span class="period" aria-hidden="true">.</span></h1>
</a>
<p class="tagline">lisp/scheme in your browser, three tiers in parallel</p>
<p class="lede">
same lisp source. three implementations compiled to webassembly:
<strong>python</strong> (cpython via pyodide hosting <code>lumbda.py</code>),
<strong>c</strong> (emscripten build of the tree-walker + bytecode vm),
<strong>asm</strong> (hand-written webassembly text format &mdash; parallel to <code>asm/lumbda.s</code>).
</p>
</header>
<section class="controls">
<fieldset class="program">
<legend>demo program</legend>
<label><input type="radio" name="program" value="mandelbrot" checked> Mandelbrot</label>
<label><input type="radio" name="program" value="fib-ack"> Fib + Ackermann</label>
<label><input type="radio" name="program" value="sieve"> Sieve of Eratosthenes</label>
<label><input type="radio" name="program" value="self-interp"> Lisp-in-Lisp meta-eval</label>
<label><input type="radio" name="program" value="mandelbrot" checked> mandelbrot</label>
<label><input type="radio" name="program" value="fib-ack"> fib + ackermann</label>
<label><input type="radio" name="program" value="sieve"> sieve of eratosthenes</label>
<label><input type="radio" name="program" value="self-interp"> lisp-in-lisp meta-eval</label>
</fieldset>
<fieldset class="tier">
<legend>tier</legend>
<label><input type="radio" name="tier" value="python"> Python (Pyodide)</label>
<label><input type="radio" name="tier" value="c" checked> C (emcc)</label>
<label><input type="radio" name="tier" value="asm"> Asm (WAT)</label>
<label><input type="radio" name="tier" value="all"> All three</label>
<label><input type="radio" name="tier" value="python"> python (pyodide)</label>
<label><input type="radio" name="tier" value="c" checked> c (emcc)</label>
<label><input type="radio" name="tier" value="asm"> asm (wat)</label>
<label><input type="radio" name="tier" value="all"> all three</label>
</fieldset>
<button id="run">Run</button>
<button id="run" class="primary">run</button>
<button id="cancel" class="secondary">cancel</button>
<span id="status" class="status"></span>
</section>
@ -66,11 +71,11 @@
<footer>
<p>
<strong>Asm tier note:</strong> the WAT implementation ships a minimal Lisp
subset (special forms, arithmetic, list ops, recursion) enough for the
four demos above. Symbol lookup is linear; would be MOAD-0001 at scale,
documented in <code>asm/lumbda.wat</code>. See
<a href="https://lumbda.com">lumbda.com</a>.
<strong>asm tier note:</strong> the wat implementation ships a minimal lisp
subset (special forms, arithmetic, list ops, recursion) &mdash; enough for the
four demos above. symbol lookup is linear; would be moad-0001 at scale,
documented in <code>asm/lumbda.wat</code>.
see <a href="../">lumbda.</a>
</p>
</footer>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1,6 +1,7 @@
// wasm/app/runner.js
// Tier runner — wraps the three loaders, runs a Lisp source on selected
// tiers, returns {tier, output, error, elapsed} for each.
// Tier runner — wraps the three loaders. Exposes a per-tier API so the
// SPA can iterate, time, and render a live elapsed-ms counter between
// the eval start and finish.
import { createPythonTier } from "./python/lumbda-py.js";
import { createCTier } from "./c/lumbda-c.loader.js";
@ -8,28 +9,17 @@ import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {};
async function getTier(name, progress) {
export async function getTier(name, onLoad) {
if (cache[name]) return cache[name];
progress(`loading ${name} tier…`);
if (name === "python") cache[name] = await createPythonTier("./python/");
else if (name === "c") cache[name] = await createCTier({ baseURL: "./c/" });
else if (name === "asm") cache[name] = await createAsmTier({ baseURL: "./asm/" });
if (onLoad) onLoad(name);
if (name === "python") cache[name] = await createPythonTier();
else if (name === "c") cache[name] = await createCTier();
else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`);
return cache[name];
}
export async function runOnTiers(tiers, src, progress) {
const results = [];
for (const t of tiers) {
const start = performance.now();
try {
const tier = await getTier(t, progress);
progress(`running on ${t}`);
const output = await tier.evalLisp(src);
results.push({ tier: t, output, error: null, elapsed: performance.now() - start });
} catch (e) {
results.push({ tier: t, output: "", error: e.message || String(e), elapsed: performance.now() - start });
}
}
return results;
export async function evalOnTier(name, src, onLoad) {
const tier = await getTier(name, onLoad);
return tier.evalLisp(src);
}

View file

@ -1,15 +1,43 @@
/* Lumbda playground SPA — vanilla CSS, mono terminal aesthetic */
/* lumbda playground styled to match lumbda.com homepage.
* palette + typography mirror www/style.css.
*/
@font-face {
font-family: 'chunkfive';
src: url('fonts/chunkfive/chunkfive-regular-webfont.woff2') format('woff2'),
url('fonts/chunkfive/chunkfive-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
:root {
--bg: #0f1115;
--fg: #d6dadf;
--dim: #8b9099;
--accent: #6ee7b7;
--warn: #fbbf24;
--err: #fb7185;
--pane: #15181f;
--border: #262a33;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace;
--fg: #1a1a1a;
--bg: #fafaf7;
--muted: #666;
--accent: #227842; /* single brand green — matches homepage */
--green: #227842;
--rule: #d4d4d0;
--code-bg: #f0ede4;
--pane-bg: #ffffff;
--busy: #b15a00;
--err: #b1262b;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "DejaVu Sans Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e8e6e0;
--bg: #161613;
--muted: #9a9892;
--accent: #5ec07a; /* lightened for contrast on dark bg */
--green: #5ec07a;
--rule: #3a3a36;
--code-bg: #22221f;
--pane-bg: #1c1c19;
--busy: #e0a050;
--err: #f06070;
}
}
* { box-sizing: border-box; }
@ -19,118 +47,179 @@ html, body {
background: var(--bg);
color: var(--fg);
font-family: var(--mono);
font-size: 13px;
height: 100%;
font-size: 14px;
line-height: 1.55;
min-height: 100%;
}
/* ─── Header ────────────────────────────────────────────────────── */
header {
padding: 16px 24px 8px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--rule);
padding: 1.5rem 1.5rem 1rem;
}
.brand {
display: inline-flex;
align-items: flex-end;
gap: 0.6rem;
text-decoration: none;
color: inherit;
}
.brand:hover .period { transform: translateY(-2px); }
.brand .logo {
width: 3.2rem;
height: auto;
display: block;
}
header h1 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
color: var(--accent);
}
header h1 .sub {
color: var(--dim);
font-weight: 400;
font-size: 13px;
}
header .tag {
font-family: 'chunkfive', Georgia, serif;
font-size: 2.4rem;
margin: 0;
color: var(--dim);
font-size: 12px;
line-height: 1.5;
letter-spacing: -0.01em;
line-height: 1;
font-weight: normal;
}
header h1 .period {
color: var(--green);
display: inline-block;
transition: transform 120ms ease;
}
header .tagline {
font-family: 'chunkfive', Georgia, serif;
color: var(--muted);
margin: 0.4rem 0 0.6rem;
font-size: 1rem;
letter-spacing: 0.01em;
}
header .lede {
margin: 0;
max-width: 70rem;
color: var(--fg);
font-size: 0.85rem;
line-height: 1.55;
}
header .lede strong {
color: var(--green);
font-weight: 600;
}
header code {
color: var(--warn);
font-size: 11px;
}
header strong {
color: var(--fg);
font-weight: 600;
background: var(--code-bg);
padding: 0 0.2em;
border-radius: 2px;
font-size: 0.9em;
}
/* ─── Controls ──────────────────────────────────────────────────── */
.controls {
padding: 12px 24px;
border-bottom: 1px solid var(--border);
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--rule);
display: flex;
gap: 16px;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
background: var(--bg);
}
.controls fieldset {
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 10px 6px;
border: 1px solid var(--rule);
border-radius: 3px;
padding: 3px 10px 5px;
margin: 0;
background: var(--pane-bg);
}
.controls fieldset legend {
color: var(--dim);
font-size: 11px;
padding: 0 4px;
color: var(--muted);
font-size: 0.7rem;
padding: 0 0.4em;
text-transform: uppercase;
letter-spacing: 0.06em;
letter-spacing: 0.08em;
}
.controls label {
margin-right: 10px;
margin-right: 0.7em;
cursor: pointer;
user-select: none;
font-size: 0.85em;
}
.controls label input {
margin-right: 0.3em;
accent-color: var(--green);
}
.controls label input { margin-right: 4px; }
.controls button {
background: var(--accent);
color: #0a0c0f;
border: none;
padding: 6px 16px;
border-radius: 4px;
font-family: var(--mono);
font-size: 0.85em;
font-weight: 600;
font-size: 13px;
padding: 6px 16px;
border-radius: 3px;
cursor: pointer;
border: 1px solid var(--green);
}
.controls button.primary {
background: var(--green);
color: var(--bg);
}
.controls button.primary:hover { filter: brightness(1.08); }
.controls button.primary:disabled {
opacity: 0.4; cursor: wait;
filter: none;
}
.controls button.secondary {
background: transparent;
color: var(--green);
}
.controls button.secondary:hover {
background: var(--code-bg);
}
.controls button.secondary:disabled {
opacity: 0.35; cursor: default;
}
.controls button:hover { filter: brightness(1.1); }
.controls button:disabled { opacity: 0.4; cursor: wait; }
.controls .status {
color: var(--dim);
font-size: 12px;
color: var(--muted);
font-size: 0.8em;
margin-left: auto;
}
.controls .status.busy { color: var(--warn); }
.controls .status.busy { color: var(--busy); }
.controls .status.warn { color: var(--busy); }
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--accent); }
.controls .status.ok { color: var(--green); }
/* ─── Panes ─────────────────────────────────────────────────────── */
.panes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--border);
height: calc(100vh - 220px);
background: var(--rule);
height: calc(100vh - 260px);
min-height: 360px;
}
.pane {
background: var(--pane);
padding: 8px 12px;
background: var(--pane-bg);
padding: 0.5rem 0.75rem 0.75rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
.pane h2 {
margin: 0 0 8px;
font-size: 11px;
margin: 0 0 0.4rem;
font-size: 0.7rem;
font-weight: 500;
color: var(--dim);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.1em;
}
#editor {
flex: 1;
overflow: hidden;
border-radius: 2px;
}
.cm-editor { height: 100%; font-size: 13px; }
.cm-editor.cm-focused { outline: none; }
@ -139,45 +228,54 @@ header strong {
flex: 1;
white-space: pre;
overflow: auto;
background: #0a0c10;
border: 1px solid var(--border);
padding: 8px 10px;
background: var(--code-bg);
border-radius: 2px;
padding: 0.6rem 0.8rem;
font-size: 13px;
line-height: 1.35;
border-radius: 2px;
}
#output .tier-block {
margin-bottom: 14px;
}
#output .tier-block { margin-bottom: 1rem; }
#output .tier-block:last-child { margin-bottom: 0; }
#output .tier-block h3 {
margin: 0 0 4px;
font-size: 11px;
color: var(--accent);
margin: 0 0 0.25rem;
font-size: 0.7rem;
color: var(--green);
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.1em;
font-weight: 600;
}
#output .tier-block .time {
color: var(--dim);
font-size: 11px;
color: var(--muted);
font-size: 0.65rem;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
#output .tier-block pre {
margin: 0;
white-space: pre;
font-family: var(--mono);
}
#output .err { color: var(--err); }
#output .err {
color: var(--err);
}
/* ─── Footer ────────────────────────────────────────────────────── */
footer {
padding: 8px 24px;
border-top: 1px solid var(--border);
color: var(--dim);
font-size: 11px;
padding: 0.7rem 1.5rem;
border-top: 1px solid var(--rule);
color: var(--muted);
font-size: 0.75rem;
}
footer code {
color: var(--warn);
background: var(--code-bg);
padding: 0 0.2em;
border-radius: 2px;
}
footer a {
color: var(--accent);
color: var(--green);
text-decoration: none;
}
footer a:hover { text-decoration: underline; }
footer strong { color: var(--fg); }

19
wasm/app/worker.mjs Normal file
View file

@ -0,0 +1,19 @@
// wasm/app/worker.mjs
// Tier-evaluation Web Worker. Keeps the main thread responsive so the
// live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()).
import { evalOnTier } from "./runner.js";
self.onmessage = async (e) => {
const { kind, runId, tier, src } = e.data;
if (kind !== "eval") return;
try {
const output = await evalOnTier(tier, src, (loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
});
self.postMessage({ kind: "done", runId, output });
} catch (err) {
self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });
}
};

View file

@ -5,8 +5,9 @@
// 0x10000 output buffer (read after each call)
// 0x20000 source buffer (write before each call)
async function _bootstrap(baseURL) {
const resp = await fetch(baseURL + "lumbda-asm.wasm");
async function _bootstrap() {
const wasmURL = new URL("./lumbda-asm.wasm", import.meta.url).href;
const resp = await fetch(wasmURL);
const bytes = await resp.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes);
const exp = instance.exports;
@ -37,9 +38,8 @@ async function _bootstrap(baseURL) {
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createAsmTier = (() => {
let tier = null;
return async (opts) => {
const baseURL = (opts && opts.baseURL) || "./asm/";
if (!tier) tier = await _bootstrap(baseURL);
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

View file

@ -1176,7 +1176,14 @@
(loop $loop
(call $skip_ws)
(br_if $done (i32.ge_u (global.get $source_ptr) (global.get $source_end)))
(local.set $val (call $eval (call $read) (global.get $global_env)))
;; Top-level env = NIL. env_lookup walks env to NIL then falls back
;; to the CURRENT global_env. Closures defined at top-level capture
;; NIL too, so when re-running a demo the new top-level defines are
;; visible without older snapshots shadowing them. Without this,
;; running mandelbrot twice on a cached instance corrupts every
;; other cell because escape-count's captured global_env points
;; into a chain that no longer reflects current bindings.
(local.set $val (call $eval (call $read) (global.get $NIL)))
(br $loop)))
(if (i32.ne (local.get $val) (global.get $VOID))
(then

View file

@ -1,28 +1,22 @@
// wasm/c/lumbda-c.loader.js
// C tier loader — Emscripten module wrapper.
// C tier loader — Emscripten ES module factory.
//
// Exports createCTier({ baseURL }) -> Promise<{ evalLisp(src) -> Promise<string> }>.
//
// Output capture: Emscripten routes stdout/stderr through Module.print /
// Module.printErr callbacks. We accumulate per-eval and return joined.
async function _bootstrap(baseURL) {
// Pull in the emitted JS glue dynamically. Emscripten with EXPORT_ES6=0
// produces a UMD-ish factory script that sets globalThis.createLumbdaC.
if (typeof createLumbdaC === "undefined") {
await new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = baseURL + "lumbda-c.js";
s.onload = resolve;
s.onerror = () => reject(new Error("lumbda-c.js load failed"));
document.head.appendChild(s);
});
}
async function _bootstrap() {
// Use import.meta.url so paths resolve relative to THIS loader file —
// not the caller. Works in both window and Worker contexts because both
// have a defined import.meta.url for ES modules.
const factoryURL = new URL("./lumbda-c.js", import.meta.url).href;
const wasmDir = new URL("./", import.meta.url).href;
const { default: createLumbdaC } = await import(/* @vite-ignore */ factoryURL);
let outBuf = [];
let errBuf = [];
const module = await createLumbdaC({
locateFile: (p) => baseURL + p,
locateFile: (p) => wasmDir + p,
print: (line) => outBuf.push(line),
printErr: (line) => errBuf.push(line),
});
@ -52,14 +46,11 @@ async function _bootstrap(baseURL) {
};
}
// Closure-encapsulated singleton: no module-level mutable state. Each
// caller of createCTier() gets the same booted tier, but the cache lives
// inside the closure rather than at module scope.
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createCTier = (() => {
let tier = null;
return async (opts) => {
const baseURL = (opts && opts.baseURL) || "./c/";
if (!tier) tier = await _bootstrap(baseURL);
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

View file

@ -1,35 +1,27 @@
// wasm/python/lumbda-py.js
// Python tier loader — Pyodide (CPython-in-WASM) hosting lumbda.py.
//
// ES module form so it works in both window and Web Worker contexts.
// Exports createPythonTier() -> Promise<{ evalLisp(src) -> Promise<string> }>.
// Output is whatever the program printed (via display/print/write) plus the
// final value's printed form if non-void.
const PYODIDE_VERSION = "0.27.2";
const PYODIDE_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
async function _bootstrap(baseURL) {
// Load Pyodide loader script (sets globalThis.loadPyodide).
if (typeof loadPyodide === "undefined") {
await new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = PYODIDE_INDEX_URL + "pyodide.js";
s.onload = resolve;
s.onerror = () => reject(new Error("pyodide.js load failed"));
document.head.appendChild(s);
});
}
async function _bootstrap() {
// Dynamic ES-module import works in both window and Worker (module type)
// contexts. The CDN ships pyodide.mjs alongside pyodide.js.
const { loadPyodide } = await import(PYODIDE_INDEX_URL + "pyodide.mjs");
const pyodide = await loadPyodide({ indexURL: PYODIDE_INDEX_URL });
// Pull lumbda.py + stdlib.lsp into Pyodide's virtual FS.
const lumbdaSrc = await (await fetch(baseURL + "lumbda.py")).text();
const stdlibSrc = await (await fetch(baseURL + "stdlib.lsp")).text();
// Pull lumbda.py + stdlib.lsp into Pyodide's virtual FS. Paths resolve
// relative to THIS loader (under python/) for both window and Worker.
const pyURL = new URL("./lumbda.py", import.meta.url).href;
const stdlibURL = new URL("./stdlib.lsp", import.meta.url).href;
const lumbdaSrc = await (await fetch(pyURL)).text();
const stdlibSrc = await (await fetch(stdlibURL)).text();
pyodide.FS.writeFile("/home/pyodide/lumbda.py", lumbdaSrc);
pyodide.FS.writeFile("/home/pyodide/stdlib.lsp", stdlibSrc);
// Initialize the lumbda environment once. We swap sys.stdout to a StringIO
// buffer per eval to capture program output.
await pyodide.runPythonAsync(`
import sys, io
sys.path.insert(0, "/home/pyodide")
@ -61,10 +53,8 @@ def _lumbda_eval(src):
return {
async evalLisp(src) {
// Pass src in via globals to avoid escaping issues.
pyodide.globals.set("_src_in", src);
const result = await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
return result;
return await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
},
};
}
@ -72,9 +62,8 @@ def _lumbda_eval(src):
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createPythonTier = (() => {
let tier = null;
return async (baseURL) => {
baseURL = baseURL || "./python/";
if (!tier) tier = await _bootstrap(baseURL);
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

View file

@ -72,6 +72,7 @@ function nativePython(demoPath) {
page.on("console", (msg) => {
if (msg.type() === "error") console.log(" ⟂ console.error:", msg.text());
});
page.on("requestfailed", (req) => console.log(" ⟂ request failed:", req.url(), req.failure()?.errorText));
try {
await page.goto(baseURL, { waitUntil: "networkidle" });
@ -123,6 +124,26 @@ function nativePython(demoPath) {
const blocks = await page.locator("#output .tier-block").count();
check(`"all three" mode renders 3 tier blocks (got ${blocks})`, blocks === 3);
// State-leak regression: run mandelbrot twice on the cached asm
// instance and assert outputs match. Prior to commit 346b873's
// env-NIL fix, every-other cell of row 2+ rendered " " instead
// of the expected shade char.
await page.locator('input[name="program"][value="mandelbrot"]').check();
await page.locator('input[name="tier"][value="asm"]').check();
await page.waitForTimeout(150);
await page.locator("#run").click();
await page.waitForFunction(
() => /ok|err/i.test(document.getElementById("status").textContent),
null, { timeout: 30000 });
const asmRun1 = (await page.locator("#output pre").first().textContent()) || "";
await page.locator("#run").click();
await page.waitForFunction(
() => /ok|err/i.test(document.getElementById("status").textContent),
null, { timeout: 30000 });
const asmRun2 = (await page.locator("#output pre").first().textContent()) || "";
check("asm mandelbrot stable across two consecutive runs",
asmRun1 === asmRun2 && asmRun1.length > 100);
} catch (e) {
state.fail++;
console.log(" ✗ exception:", e.message);

View file

@ -42,9 +42,8 @@ async function runAsm(src) {
}
async function runC(src) {
const { createRequire } = await import("node:module");
const require = createRequire(import.meta.url);
const createLumbdaC = require(path.join(dist, "c", "lumbda-c.js"));
const factoryURL = "file://" + path.join(dist, "c", "lumbda-c.js");
const createLumbdaC = (await import(factoryURL)).default;
let out = [];
const m = await createLumbdaC({
locateFile: (p) => path.join(dist, "c", p),

View file

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

View file

@ -5,8 +5,9 @@
// 0x10000 output buffer (read after each call)
// 0x20000 source buffer (write before each call)
async function _bootstrap(baseURL) {
const resp = await fetch(baseURL + "lumbda-asm.wasm");
async function _bootstrap() {
const wasmURL = new URL("./lumbda-asm.wasm", import.meta.url).href;
const resp = await fetch(wasmURL);
const bytes = await resp.arrayBuffer();
const { instance } = await WebAssembly.instantiate(bytes);
const exp = instance.exports;
@ -37,9 +38,8 @@ async function _bootstrap(baseURL) {
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createAsmTier = (() => {
let tier = null;
return async (opts) => {
const baseURL = (opts && opts.baseURL) || "./asm/";
if (!tier) tier = await _bootstrap(baseURL);
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

Binary file not shown.

File diff suppressed because one or more lines are too long

View file

@ -1,28 +1,22 @@
// wasm/c/lumbda-c.loader.js
// C tier loader — Emscripten module wrapper.
// C tier loader — Emscripten ES module factory.
//
// Exports createCTier({ baseURL }) -> Promise<{ evalLisp(src) -> Promise<string> }>.
//
// Output capture: Emscripten routes stdout/stderr through Module.print /
// Module.printErr callbacks. We accumulate per-eval and return joined.
async function _bootstrap(baseURL) {
// Pull in the emitted JS glue dynamically. Emscripten with EXPORT_ES6=0
// produces a UMD-ish factory script that sets globalThis.createLumbdaC.
if (typeof createLumbdaC === "undefined") {
await new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = baseURL + "lumbda-c.js";
s.onload = resolve;
s.onerror = () => reject(new Error("lumbda-c.js load failed"));
document.head.appendChild(s);
});
}
async function _bootstrap() {
// Use import.meta.url so paths resolve relative to THIS loader file —
// not the caller. Works in both window and Worker contexts because both
// have a defined import.meta.url for ES modules.
const factoryURL = new URL("./lumbda-c.js", import.meta.url).href;
const wasmDir = new URL("./", import.meta.url).href;
const { default: createLumbdaC } = await import(/* @vite-ignore */ factoryURL);
let outBuf = [];
let errBuf = [];
const module = await createLumbdaC({
locateFile: (p) => baseURL + p,
locateFile: (p) => wasmDir + p,
print: (line) => outBuf.push(line),
printErr: (line) => errBuf.push(line),
});
@ -52,14 +46,11 @@ async function _bootstrap(baseURL) {
};
}
// Closure-encapsulated singleton: no module-level mutable state. Each
// caller of createCTier() gets the same booted tier, but the cache lives
// inside the closure rather than at module scope.
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createCTier = (() => {
let tier = null;
return async (opts) => {
const baseURL = (opts && opts.baseURL) || "./c/";
if (!tier) tier = await _bootstrap(baseURL);
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

View file

@ -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")

View file

@ -3,7 +3,7 @@
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Lumbda playground — Lisp in your browser, three tiers</title>
<title>lumbda playground — lisp in your browser, three tiers</title>
<link rel="stylesheet" href="style.css">
<script type="importmap">
{
@ -25,31 +25,36 @@
</head>
<body>
<header>
<h1>Lumbda <span class="sub">— Lisp/Scheme in your browser, three tiers in parallel</span></h1>
<p class="tag">
Same Lisp source. Three implementations compiled to WebAssembly:
<strong>Python</strong> (CPython via Pyodide hosting <code>lumbda.py</code>),
<strong>C</strong> (Emscripten build of the tree-walker + bytecode VM),
<strong>Asm</strong> (hand-written WebAssembly Text format — parallel to <code>asm/lumbda.s</code>).
<a class="brand" href="../" aria-label="lumbda home">
<img class="logo" src="lumbda-logo-green.png" alt="" aria-hidden="true">
<h1 aria-label="lumbda.">lumbda<span class="period" aria-hidden="true">.</span></h1>
</a>
<p class="tagline">lisp/scheme in your browser, three tiers in parallel</p>
<p class="lede">
same lisp source. three implementations compiled to webassembly:
<strong>python</strong> (cpython via pyodide hosting <code>lumbda.py</code>),
<strong>c</strong> (emscripten build of the tree-walker + bytecode vm),
<strong>asm</strong> (hand-written webassembly text format &mdash; parallel to <code>asm/lumbda.s</code>).
</p>
</header>
<section class="controls">
<fieldset class="program">
<legend>demo program</legend>
<label><input type="radio" name="program" value="mandelbrot" checked> Mandelbrot</label>
<label><input type="radio" name="program" value="fib-ack"> Fib + Ackermann</label>
<label><input type="radio" name="program" value="sieve"> Sieve of Eratosthenes</label>
<label><input type="radio" name="program" value="self-interp"> Lisp-in-Lisp meta-eval</label>
<label><input type="radio" name="program" value="mandelbrot" checked> mandelbrot</label>
<label><input type="radio" name="program" value="fib-ack"> fib + ackermann</label>
<label><input type="radio" name="program" value="sieve"> sieve of eratosthenes</label>
<label><input type="radio" name="program" value="self-interp"> lisp-in-lisp meta-eval</label>
</fieldset>
<fieldset class="tier">
<legend>tier</legend>
<label><input type="radio" name="tier" value="python"> Python (Pyodide)</label>
<label><input type="radio" name="tier" value="c" checked> C (emcc)</label>
<label><input type="radio" name="tier" value="asm"> Asm (WAT)</label>
<label><input type="radio" name="tier" value="all"> All three</label>
<label><input type="radio" name="tier" value="python"> python (pyodide)</label>
<label><input type="radio" name="tier" value="c" checked> c (emcc)</label>
<label><input type="radio" name="tier" value="asm"> asm (wat)</label>
<label><input type="radio" name="tier" value="all"> all three</label>
</fieldset>
<button id="run">Run</button>
<button id="run" class="primary">run</button>
<button id="cancel" class="secondary">cancel</button>
<span id="status" class="status"></span>
</section>
@ -66,11 +71,11 @@
<footer>
<p>
<strong>Asm tier note:</strong> the WAT implementation ships a minimal Lisp
subset (special forms, arithmetic, list ops, recursion) enough for the
four demos above. Symbol lookup is linear; would be MOAD-0001 at scale,
documented in <code>asm/lumbda.wat</code>. See
<a href="https://lumbda.com">lumbda.com</a>.
<strong>asm tier note:</strong> the wat implementation ships a minimal lisp
subset (special forms, arithmetic, list ops, recursion) &mdash; enough for the
four demos above. symbol lookup is linear; would be moad-0001 at scale,
documented in <code>asm/lumbda.wat</code>.
see <a href="../">lumbda.</a>
</p>
</footer>

Binary file not shown.

After

Width:  |  Height:  |  Size: 12 KiB

View file

@ -1,35 +1,27 @@
// wasm/python/lumbda-py.js
// Python tier loader — Pyodide (CPython-in-WASM) hosting lumbda.py.
//
// ES module form so it works in both window and Web Worker contexts.
// Exports createPythonTier() -> Promise<{ evalLisp(src) -> Promise<string> }>.
// Output is whatever the program printed (via display/print/write) plus the
// final value's printed form if non-void.
const PYODIDE_VERSION = "0.27.2";
const PYODIDE_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
async function _bootstrap(baseURL) {
// Load Pyodide loader script (sets globalThis.loadPyodide).
if (typeof loadPyodide === "undefined") {
await new Promise((resolve, reject) => {
const s = document.createElement("script");
s.src = PYODIDE_INDEX_URL + "pyodide.js";
s.onload = resolve;
s.onerror = () => reject(new Error("pyodide.js load failed"));
document.head.appendChild(s);
});
}
async function _bootstrap() {
// Dynamic ES-module import works in both window and Worker (module type)
// contexts. The CDN ships pyodide.mjs alongside pyodide.js.
const { loadPyodide } = await import(PYODIDE_INDEX_URL + "pyodide.mjs");
const pyodide = await loadPyodide({ indexURL: PYODIDE_INDEX_URL });
// Pull lumbda.py + stdlib.lsp into Pyodide's virtual FS.
const lumbdaSrc = await (await fetch(baseURL + "lumbda.py")).text();
const stdlibSrc = await (await fetch(baseURL + "stdlib.lsp")).text();
// Pull lumbda.py + stdlib.lsp into Pyodide's virtual FS. Paths resolve
// relative to THIS loader (under python/) for both window and Worker.
const pyURL = new URL("./lumbda.py", import.meta.url).href;
const stdlibURL = new URL("./stdlib.lsp", import.meta.url).href;
const lumbdaSrc = await (await fetch(pyURL)).text();
const stdlibSrc = await (await fetch(stdlibURL)).text();
pyodide.FS.writeFile("/home/pyodide/lumbda.py", lumbdaSrc);
pyodide.FS.writeFile("/home/pyodide/stdlib.lsp", stdlibSrc);
// Initialize the lumbda environment once. We swap sys.stdout to a StringIO
// buffer per eval to capture program output.
await pyodide.runPythonAsync(`
import sys, io
sys.path.insert(0, "/home/pyodide")
@ -61,10 +53,8 @@ def _lumbda_eval(src):
return {
async evalLisp(src) {
// Pass src in via globals to avoid escaping issues.
pyodide.globals.set("_src_in", src);
const result = await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
return result;
return await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
},
};
}
@ -72,9 +62,8 @@ def _lumbda_eval(src):
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createPythonTier = (() => {
let tier = null;
return async (baseURL) => {
baseURL = baseURL || "./python/";
if (!tier) tier = await _bootstrap(baseURL);
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();

View file

@ -1,6 +1,7 @@
// wasm/app/runner.js
// Tier runner — wraps the three loaders, runs a Lisp source on selected
// tiers, returns {tier, output, error, elapsed} for each.
// Tier runner — wraps the three loaders. Exposes a per-tier API so the
// SPA can iterate, time, and render a live elapsed-ms counter between
// the eval start and finish.
import { createPythonTier } from "./python/lumbda-py.js";
import { createCTier } from "./c/lumbda-c.loader.js";
@ -8,28 +9,17 @@ import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {};
async function getTier(name, progress) {
export async function getTier(name, onLoad) {
if (cache[name]) return cache[name];
progress(`loading ${name} tier…`);
if (name === "python") cache[name] = await createPythonTier("./python/");
else if (name === "c") cache[name] = await createCTier({ baseURL: "./c/" });
else if (name === "asm") cache[name] = await createAsmTier({ baseURL: "./asm/" });
if (onLoad) onLoad(name);
if (name === "python") cache[name] = await createPythonTier();
else if (name === "c") cache[name] = await createCTier();
else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`);
return cache[name];
}
export async function runOnTiers(tiers, src, progress) {
const results = [];
for (const t of tiers) {
const start = performance.now();
try {
const tier = await getTier(t, progress);
progress(`running on ${t}`);
const output = await tier.evalLisp(src);
results.push({ tier: t, output, error: null, elapsed: performance.now() - start });
} catch (e) {
results.push({ tier: t, output: "", error: e.message || String(e), elapsed: performance.now() - start });
}
}
return results;
export async function evalOnTier(name, src, onLoad) {
const tier = await getTier(name, onLoad);
return tier.evalLisp(src);
}

View file

@ -1,15 +1,43 @@
/* Lumbda playground SPA — vanilla CSS, mono terminal aesthetic */
/* lumbda playground styled to match lumbda.com homepage.
* palette + typography mirror www/style.css.
*/
@font-face {
font-family: 'chunkfive';
src: url('fonts/chunkfive/chunkfive-regular-webfont.woff2') format('woff2'),
url('fonts/chunkfive/chunkfive-regular-webfont.woff') format('woff');
font-weight: normal;
font-style: normal;
font-display: swap;
}
:root {
--bg: #0f1115;
--fg: #d6dadf;
--dim: #8b9099;
--accent: #6ee7b7;
--warn: #fbbf24;
--err: #fb7185;
--pane: #15181f;
--border: #262a33;
--mono: ui-monospace, "SF Mono", Menlo, Consolas, "Courier New", monospace;
--fg: #1a1a1a;
--bg: #fafaf7;
--muted: #666;
--accent: #227842; /* single brand green — matches homepage */
--green: #227842;
--rule: #d4d4d0;
--code-bg: #f0ede4;
--pane-bg: #ffffff;
--busy: #b15a00;
--err: #b1262b;
--mono: ui-monospace, SFMono-Regular, Menlo, Consolas, "DejaVu Sans Mono", monospace;
}
@media (prefers-color-scheme: dark) {
:root {
--fg: #e8e6e0;
--bg: #161613;
--muted: #9a9892;
--accent: #5ec07a; /* lightened for contrast on dark bg */
--green: #5ec07a;
--rule: #3a3a36;
--code-bg: #22221f;
--pane-bg: #1c1c19;
--busy: #e0a050;
--err: #f06070;
}
}
* { box-sizing: border-box; }
@ -19,118 +47,179 @@ html, body {
background: var(--bg);
color: var(--fg);
font-family: var(--mono);
font-size: 13px;
height: 100%;
font-size: 14px;
line-height: 1.55;
min-height: 100%;
}
/* ─── Header ────────────────────────────────────────────────────── */
header {
padding: 16px 24px 8px;
border-bottom: 1px solid var(--border);
border-bottom: 1px solid var(--rule);
padding: 1.5rem 1.5rem 1rem;
}
.brand {
display: inline-flex;
align-items: flex-end;
gap: 0.6rem;
text-decoration: none;
color: inherit;
}
.brand:hover .period { transform: translateY(-2px); }
.brand .logo {
width: 3.2rem;
height: auto;
display: block;
}
header h1 {
margin: 0 0 4px;
font-size: 18px;
font-weight: 600;
color: var(--accent);
}
header h1 .sub {
color: var(--dim);
font-weight: 400;
font-size: 13px;
}
header .tag {
font-family: 'chunkfive', Georgia, serif;
font-size: 2.4rem;
margin: 0;
color: var(--dim);
font-size: 12px;
line-height: 1.5;
letter-spacing: -0.01em;
line-height: 1;
font-weight: normal;
}
header h1 .period {
color: var(--green);
display: inline-block;
transition: transform 120ms ease;
}
header .tagline {
font-family: 'chunkfive', Georgia, serif;
color: var(--muted);
margin: 0.4rem 0 0.6rem;
font-size: 1rem;
letter-spacing: 0.01em;
}
header .lede {
margin: 0;
max-width: 70rem;
color: var(--fg);
font-size: 0.85rem;
line-height: 1.55;
}
header .lede strong {
color: var(--green);
font-weight: 600;
}
header code {
color: var(--warn);
font-size: 11px;
}
header strong {
color: var(--fg);
font-weight: 600;
background: var(--code-bg);
padding: 0 0.2em;
border-radius: 2px;
font-size: 0.9em;
}
/* ─── Controls ──────────────────────────────────────────────────── */
.controls {
padding: 12px 24px;
border-bottom: 1px solid var(--border);
padding: 0.75rem 1.5rem;
border-bottom: 1px solid var(--rule);
display: flex;
gap: 16px;
gap: 1rem;
align-items: center;
flex-wrap: wrap;
background: var(--bg);
}
.controls fieldset {
border: 1px solid var(--border);
border-radius: 4px;
padding: 4px 10px 6px;
border: 1px solid var(--rule);
border-radius: 3px;
padding: 3px 10px 5px;
margin: 0;
background: var(--pane-bg);
}
.controls fieldset legend {
color: var(--dim);
font-size: 11px;
padding: 0 4px;
color: var(--muted);
font-size: 0.7rem;
padding: 0 0.4em;
text-transform: uppercase;
letter-spacing: 0.06em;
letter-spacing: 0.08em;
}
.controls label {
margin-right: 10px;
margin-right: 0.7em;
cursor: pointer;
user-select: none;
font-size: 0.85em;
}
.controls label input {
margin-right: 0.3em;
accent-color: var(--green);
}
.controls label input { margin-right: 4px; }
.controls button {
background: var(--accent);
color: #0a0c0f;
border: none;
padding: 6px 16px;
border-radius: 4px;
font-family: var(--mono);
font-size: 0.85em;
font-weight: 600;
font-size: 13px;
padding: 6px 16px;
border-radius: 3px;
cursor: pointer;
border: 1px solid var(--green);
}
.controls button.primary {
background: var(--green);
color: var(--bg);
}
.controls button.primary:hover { filter: brightness(1.08); }
.controls button.primary:disabled {
opacity: 0.4; cursor: wait;
filter: none;
}
.controls button.secondary {
background: transparent;
color: var(--green);
}
.controls button.secondary:hover {
background: var(--code-bg);
}
.controls button.secondary:disabled {
opacity: 0.35; cursor: default;
}
.controls button:hover { filter: brightness(1.1); }
.controls button:disabled { opacity: 0.4; cursor: wait; }
.controls .status {
color: var(--dim);
font-size: 12px;
color: var(--muted);
font-size: 0.8em;
margin-left: auto;
}
.controls .status.busy { color: var(--warn); }
.controls .status.busy { color: var(--busy); }
.controls .status.warn { color: var(--busy); }
.controls .status.err { color: var(--err); }
.controls .status.ok { color: var(--accent); }
.controls .status.ok { color: var(--green); }
/* ─── Panes ─────────────────────────────────────────────────────── */
.panes {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--border);
height: calc(100vh - 220px);
background: var(--rule);
height: calc(100vh - 260px);
min-height: 360px;
}
.pane {
background: var(--pane);
padding: 8px 12px;
background: var(--pane-bg);
padding: 0.5rem 0.75rem 0.75rem;
overflow: hidden;
display: flex;
flex-direction: column;
}
.pane h2 {
margin: 0 0 8px;
font-size: 11px;
margin: 0 0 0.4rem;
font-size: 0.7rem;
font-weight: 500;
color: var(--dim);
color: var(--muted);
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.1em;
}
#editor {
flex: 1;
overflow: hidden;
border-radius: 2px;
}
.cm-editor { height: 100%; font-size: 13px; }
.cm-editor.cm-focused { outline: none; }
@ -139,45 +228,54 @@ header strong {
flex: 1;
white-space: pre;
overflow: auto;
background: #0a0c10;
border: 1px solid var(--border);
padding: 8px 10px;
background: var(--code-bg);
border-radius: 2px;
padding: 0.6rem 0.8rem;
font-size: 13px;
line-height: 1.35;
border-radius: 2px;
}
#output .tier-block {
margin-bottom: 14px;
}
#output .tier-block { margin-bottom: 1rem; }
#output .tier-block:last-child { margin-bottom: 0; }
#output .tier-block h3 {
margin: 0 0 4px;
font-size: 11px;
color: var(--accent);
margin: 0 0 0.25rem;
font-size: 0.7rem;
color: var(--green);
text-transform: uppercase;
letter-spacing: 0.08em;
letter-spacing: 0.1em;
font-weight: 600;
}
#output .tier-block .time {
color: var(--dim);
font-size: 11px;
color: var(--muted);
font-size: 0.65rem;
font-weight: 400;
letter-spacing: 0;
text-transform: none;
}
#output .tier-block pre {
margin: 0;
white-space: pre;
font-family: var(--mono);
}
#output .err { color: var(--err); }
#output .err {
color: var(--err);
}
/* ─── Footer ────────────────────────────────────────────────────── */
footer {
padding: 8px 24px;
border-top: 1px solid var(--border);
color: var(--dim);
font-size: 11px;
padding: 0.7rem 1.5rem;
border-top: 1px solid var(--rule);
color: var(--muted);
font-size: 0.75rem;
}
footer code {
color: var(--warn);
background: var(--code-bg);
padding: 0 0.2em;
border-radius: 2px;
}
footer a {
color: var(--accent);
color: var(--green);
text-decoration: none;
}
footer a:hover { text-decoration: underline; }
footer strong { color: var(--fg); }

19
www/playground/worker.mjs Normal file
View file

@ -0,0 +1,19 @@
// wasm/app/worker.mjs
// Tier-evaluation Web Worker. Keeps the main thread responsive so the
// live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()).
import { evalOnTier } from "./runner.js";
self.onmessage = async (e) => {
const { kind, runId, tier, src } = e.data;
if (kind !== "eval") return;
try {
const output = await evalOnTier(tier, src, (loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
});
self.postMessage({ kind: "done", runId, output });
} catch (err) {
self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });
}
};