Worker batching used to wait for 64KB / 4096 newlines before
flushing. A program like
(let loop ((n 0))
(when (zero? (modulo n 10000))
(display "Emitted at iteration: ") (display n) (newline))
(loop (+ n 1)))
emits ~25 bytes every 10K iterations, so the user saw nothing for
several seconds — fox reported "this used to work and doesn't seem
to anymore" because the first emission was buried in the batch
buffer.
Added a 100ms wall-clock check alongside the existing size /
newline thresholds. The first chunk to arrive triggers a flush
(lastFlushTime starts at 0), and low-rate streams cap at ~10
flushes/sec. High-rate streams still hit the byte / newline ceilings
first so chunk batching for tight (display) loops is unaffected.
Verified headlessly: the infinite-emitter program now shows the
first emission within a frame, subsequent ones streaming live as
the loop iterates.
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.
A tight (display ...) loop used to fire one postMessage per display
call — main thread couldn't even register click events because the
message queue grew faster than it could drain. Fox saw this as
"the tab just keeps looping when I leave it" — the tab-switch click
never reached setActiveTab so autoPauseTab never fired, and the
worker kept running until it finished on its own.
Now the worker accumulates chunks into a local string buffer and
postMessages a single chunk-batch message when the buffer hits 64KB
or 4096 newlines. The 'done'/'error' path drains whatever's left
before signalling so the last lines still reach the UI. Main thread
handlers (repl + playground) split the batch back into the same
text+eol sequence the live streaming row expects.
Adds RAF coalescing on the receive side too: attachStreaming now
batches DOM textContent / appendChild updates into one
requestAnimationFrame tick so the 60 Hz repaint budget is shared
across all chunks that landed in that window. finalize() drains
the RAF buffer synchronously before the streamed-vs-expected
match check so error-on-cancel keeps the most recent lines.
Tab switching during a long-running eval used to silently abandon
the calc — output stopped streaming, no snapshot, nothing to come
back to. Now setActiveTab pauses the outgoing tab's eval (and
optionally portal-saves the env), terminates the worker, and on
re-entry hydrates + re-fires the original input.
Pieces:
* serve-coop.py + make serve-repl — dev server that emits
Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp so SharedArrayBuffer
is constructable in the browser. Same headers production needs.
* C tier eval-loop pause poll — c/eval.c grows lumbda_check_pause(),
guarded by #ifdef LUMBDA_WASM. Called at the top of leval()'s
while(1); masked to every 1024th iteration so the polling cost
stays under noise floor. When the JS-library import
js_lumbda_pause_requested returns 1, lisp_error("paused")
longjmps out so module-global env survives intact for the
portal-snapshot that follows.
* SAB plumbing — main thread allocates new SharedArrayBuffer(4),
hands it through worker config → runner.setPauseFlag →
lumbda-c.loader.setPauseFlag → globalThis._lumbdaCPauseFlag.
Atomics.store / Atomics.load on index 0 is the signalling
channel. Falls back to null when COOP/COEP isn't isolated, in
which case pause degrades to a hard worker.terminate().
* autoPauseTab() — on setActiveTab away, snapshots the tier
(C tier with SAB) or hard-cancels (other tiers / no SAB),
stashes tab.autoPause = {tier, blob, inputSrc, savedAt},
terminates the workers so the heap is reclaimed.
* autoResumeTab() — on setActiveTab into a tab with autoPause,
reboots the tier, hydrates MEMFS, runs (portal-load! ...), then
re-fires the original input via sendInput so the eval restarts
from the saved state. Asm + Python paths re-run from scratch
until their poll sites land.
Also closes two UX papercuts from fox: chip ⇣ export icon bumped
from 0.85em muted to 1em green so it's actually discoverable; the
scope toggle now reads "scope: this tab" / "scope: all tabs" so the
button label describes the state rather than a target.
Going nuclear on the c-tier-horizontal-output saga. Six attempts at
preserving the \n byte from Module.print through Web Worker
postMessage to the DOM all reported clean chunks in node tests but
still rendered horizontal in fox's Firefox tab.
Removing \n bytes from the chunk-transmission path entirely. Worker
walks each onChunk slice byte-by-byte and posts two separate kinds
of message:
{ kind: 'chunk-text', tier, text: 'tick 0' } <- visible chars only
{ kind: 'chunk-eol', tier } <- bare line break
Newlines no longer travel as bytes — they're typed events. Whatever
was eating them between Emscripten's TTY out() and the main thread
in fox's browser drops out of the path entirely.
liveBlock.append split into appendText (extend the in-progress
line) and appendNewline (close pending div, spawn fresh sibling).
Python tier paths through the same worker code so it gets the same
typed-event treatment — should still render vertical because that's
what it was doing already.
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.
Reapplies the per-line-div approach lost in the autosave revert
(c2d9eca) and pins display:block + white-space:pre as INLINE styles
on each div instead of relying on the .stream-line CSS rule. We
spent a long stretch chasing what looked like a deeper rendering
bug — turned out the previous .stream-line class rule in style.css
was loaded but the cached stylesheet bundle in fox's tab didn't
include it, so each div kept its default inline display and the
chunks still rendered horizontally even though the DOM had a clean
sibling-div column.
Each chunk now: walk byte-by-byte, accumulate text in a pending
div, every \n closes the pending div with display:block already
set and spawns a fresh sibling. Empty lines get a single space so
they still take a row. finalize() reconciles against the full eval
output (asm tier fallback) using the joined per-line text rather
than comparing pre.textContent.
Fox: 'only free form should save the other demos should go back to
default.' Restoring the early-return guard in scheduleFreeFormSave
(skip unless the selected program is free-form), the original
unlockVault that loads only the legacy top-level freeForm field,
the original lockVault that resets free-form to its placeholder
without touching the other demos, and the original loadCurrentDemo
that hides the vault bar unless free-form is the selected program.
The per-program drafts map landed in ffada02 against expectation;
this fully undoes that piece. Streaming chunk-to-div changes from
the same commit stay — they're orthogonal.
Two fixes in one push.
(1) Streaming horizontal-output bug, take three. textContent += chunk
then appendChild(createTextNode(chunk)) both still rendered chunks
horizontally in fox's Firefox tab even though the chunks clearly
contained \\n (node test confirmed; deployed loader hash matched
local; curl-fetched lumbda-c.loader.js carried the line + '\\n' fix).
Whatever the browser was doing with sibling text nodes inside a
<pre> wasn't honoring the embedded newlines.
Switch to one <div class=\"stream-line\"> per logical line. liveBlock.append
walks the incoming chunk byte by byte, every \\n closes the current
pending div and spawns a fresh empty one for the next line. CSS
adds .stream-line { display: block; white-space: pre; } so each
finished line stacks vertically no matter what the parent
white-space rule was doing. Empty lines get a single space so they
take a row instead of collapsing. Reconcile path inside finalize()
compares the joined per-line text to the full output and rebuilds
the div column if they diverge — for the asm-tier fallback we
already had and now also for any future browser/wasm combo where
a flush silently drops a chunk.
Also drops the temporary [c-tier print] console.log debug we added
in the last commit — diagnosis arrived from elsewhere, no point
keeping the spam.
(2) Per-program autosave drafts. scheduleFreeFormSave previously
returned early if the selected program wasn't \"free-form\", so a
user who unlocked the vault, edited the bend-gpu demo, and came
back later found their edits gone — only free-form persisted.
Now drafts live as { [programName]: text } in the vault payload;
every edit, regardless of which radio is selected, debounces a
save into drafts[currentProgram]. unlockVault loads any saved
draft for the current program (and lifts the legacy
top-level freeForm field into drafts['free-form'] so existing
users don't lose their work). loadCurrentDemo shows a saved
draft instead of the ship default whenever one exists; vault bar
stays visible across all programs once the vault is engaged so
the save status note is always reachable.
liveBlock.append was doing `pre.textContent += chunk` on every
streamed chunk. textContent's setter wipes all existing child
nodes and creates a single new text node from the concatenated
read+new value — perfectly correct semantically but under a fast
C-tier emit loop (one chunk per (newline) printf) the per-update
restringify was visibly dropping line breaks, rendering
tick 0tick 10000tick 20000tick 30000…
horizontally instead of stacking vertically.
`pre.appendChild(document.createTextNode(chunk))` appends a new
text node alongside existing ones — no read-back, no restringify.
Browser renders the sequence of text nodes as concatenated text
inside the <pre> which already has `white-space: pre`, so every
embedded \n produces a real line break.
Pairs with the c-tier loader's `currentOnChunk(line + "\n")` fix:
loader adds the \n that Emscripten's Module.print convention
stripped, append preserves the \n end-to-end into the DOM.
Previously every (display ...) call buffered into the tier's outBuf /
sys.stdout / WAT output-buffer and only landed in the playground panel
after evalLisp returned. During the 18-second cuda-secp256k1-bench
dispatch the panel stayed blank, then everything (probe + telemetry +
heavy result) appeared at once. Fox: 'demo waits for the full program
to return before emitting instead of line by line as it finishes'.
Each tier's print sink now ALSO calls a per-eval onChunk callback
that the worker forwards to the main thread as a {kind:"chunk"}
postMessage. The main thread spawns an in-progress tier-block on
the first chunk and appends each subsequent chunk to its <pre>,
auto-scrolling. On done/error the timing header finalizes in place;
the block looks identical to a non-streamed appendBlock at the end.
C tier (Emscripten): the Module.print + Module.printErr callbacks
that already fired per-line now invoke currentOnChunk in addition
to buffering. evalLisp accepts an onChunk arg and threads it.
Python tier (pyodide): _lumbda_eval swaps sys.stdout for a
_StreamingStdout subclass of io.StringIO whose write() also calls
into globalThis._lumbdaPyEmitChunk on the JS side. The final
auto-printed value also emits.
Asm tier (WAT): output stays buffered until evalLisp returns —
streaming there needs a new wasm import (out_line) and a wasm
rebuild. Caller-side fallback: app.js's appendBlock path still
runs for asm so its block appears at the end as before.
worker.mjs forwards each chunk via postMessage; app.js's
runOnTierInWorker handles 'chunk' messages by lazily creating
a startLiveBlock on first chunk, appending text on each, and
finalizing the timing in finalize() once done arrives.
The previous attempt to ship a pretty summary via an (summary "...")
field inside the structured response broke on the asm (WAT) tier
because read-from-string isn't a primitive there — the demo
extraction fell through to the raw fallback and dumped the entire
escaped S-expression.
Two-part fix that works on all three tiers:
http-listener (gpu-worker.lsp:1103-1116) — POST response builder
now checks if the handle-request return value is a string and
ships it as the raw HTTP body in that case. S-expression returns
still go through write-to-string. One-line guard, no impact on
ping/health/cuda-shake-fanout/cuda-sim-ops-bin which all keep
returning structured S-exps.
handle-cuda-secp256k1-bench — returns the formatted summary
string directly instead of an (ok ... (summary ...)) wrapping.
Drops the now-redundant structured fields; every number lives
inside the human-readable text already. asm / c / python demo
just calls (display (bend!-call "(cuda-secp256k1-bench N)")) and
the formatted output panel renders identically on all tiers.
Demo simplified accordingly: three (display (bend!-call …)) calls
with newlines between, no read-from-string / assoc / pair? dance.
Fox said 'doesn't dazzle me' on the previous run. The math is real
(160x GPU win, ~33 minutes of CPU compute in 13 seconds of GPU
kernel) but the output panel showed a wall of S-expression atoms
that buries the headline.
Worker now builds a multi-line summary string inside the response
under a new (summary "...") field:
✦ GPU just batched 100,000,000 secp256k1 public-key computations.
scalar gen : 5130 ms (random.urandom)
GPU kernel : 13028 ms (RTX 3090, --window-w 4)
GPU throughput : 7.67 million keys / second
CPU baseline ref: 0.050 Mkeys/s (libsecp256k1 single core)
CPU would need : 33.3 minutes (2000 sec)
GPU finished in : 13.0 sec
speedup : ~153× faster on GPU
Two new portable helpers — with-commas (recursive thousand
separator) and format-float (truncate decimals + drop trailing
dot when n=0) — work on all three tiers because they only use
string-append, substring, char=?, and number->string.
Demo now reads the response with read-from-string, assoc-extracts
the summary string, and displays it raw. Same source compiles
identically under asm / c / python. Existing structured fields
(n, gen-ms, gpu-ms, etc.) stay untouched for callers who want
the numbers as data.
Replaces the small cuda-shake-fanout step and the modest kickmix
4x demo with a single dispatch you can feel: 100,000,000 secp256k1
scalar*G batched multiplications.
Numbers measured end-to-end through https://bend.unturf.com/ on
3090-ai:
HTTP request size ~32 bytes (just '(cuda-secp256k1-bench 100000000)')
worker scalar gen 5.2 s (/dev/urandom into 3.2 GB BSCP)
GPU kernel 12.5 s (7.99 Mkeys/s on 3090)
total wall-clock 18.3 s
CPU equivalent ~2000 s = ~33 minutes (libsecp256k1 ref)
speedup-est ~160x
The 3.2 GB of random scalars never crosses the wire — the worker
synthesizes them from os.urandom and dispatches into the existing
cuda-secp256k1-batched-mul daemon. Response is a tiny S-expression
with the timing summary; no 6.4 GB result blob comes back.
Ping + health stay at the top so a visitor sees the chain warm up
before committing to the 18 s heavy run.
Step 4 of bend-gpu.lsp now dispatches (cuda-sim-ops-bin path 141)
against /tmp/ecdsa-queue/out.bin on 3090-ai — the foxhop kickmix
circuit simulator at 9024 shots over 141 batches. The HTTP payload
is ~70 bytes; the worker does ~1.5 seconds of real Toffoli-heavy
QECC simulation, the GPU kernel comes back in ~360 ms, byte-identical
to the CPU reference. Speedup ~4x on this workload, scales further
with bigger circuits.
Why this works over HTTP where 1M-input SHAKE wouldn't: the request
references a bin file that already lives on the worker disk, so we
never have to stream the bytes through the wire. The response is a
structured (cuda-sim-result …) S-expression with timing-ms,
gates-sum, status, byte-identity, and the speedup-kernel field —
everything you need to see the GPU win directly in the playground
output panel.
Pairs with the existing small-payload (cuda-shake-fanout (...) 32)
step so the demo shows both the round-trip pattern and the real
"slow on CPU, fast on GPU" headline that the original demo header
promised but couldn't deliver until cuda-sim-ops-bin was wired up.
gpu-worker.lsp — handle-cuda-shake-fanout, handle-cuda-sim-ops-bin,
and the four binary handlers (BSHK/BCGB/BSCP/BSRT/BSB3) now check
for the registered daemon (or, for sim-ops-bin, the bend-cuda
binary) before dispatching. Missing daemon returns (error
(daemon-not-registered <op>)) over S-exp wire, or "BERRdaemon-not-
registered: <op>" over binary wire. Before this commit any caller
whose worker host lacked a CUDA binary saw the child process crash
on (cdr #f) and got HTTP 502 / empty response with no useful
diagnostic.
Factored two helpers: daemon-or-error (S-exp result) and
with-required-daemon (binary-mode wrapper). Both keep the original
handler bodies untouched on the happy path; the guard adds one
assoc lookup per request.
www/playground/demos/bend-gpu.lsp + wasm/app/demos/bend-gpu.lsp —
demo was sending (cuda-shake256-fanout COUNT 32), an op the
dispatcher doesn't know AND a signature handle-cuda-shake-fanout
doesn't accept (it takes (inputs out-bytes)). Replaced with a
three-step probe: (ping) → (health) → small (cuda-shake-fanout
("00" "01" "deadbeef") 32). Each step prints its result so the user
gets feedback at every stage of the round-trip. Note added in the
header that browser-side bend!-call is asm-tier-only today; pyodide
and emcc tier wiring is the next commit.
Step 1 of the GC effort. Each tier loader now exposes heapStats():
- asm-wasm — lumbda_heap_used / lumbda_heap_total wat exports
- c-wasm — emscripten linear memory size (no free path right now,
so used = total; documented in the loader)
- python — pyodide module linear memory size; CPython GC cycles
this naturally
Worker handles a "heap" message kind that round-trips the active tab's
loaded tiers; repl tabbar shows a compact "py 12M · c 32M · asm 4M"
strip next to the buttons. Polls every 2s.
Doesn't solve the leak — just makes pressure visible so the user knows
when to use "reboot tier". Real GC (Cheney over the WAT bump allocator,
Boehm-em or custom mark-sweep for c-wasm) coming next.
Adds tag-9 rational type to the asm tier. Layout [tag=9, num:i32, den:i32].
make_rational normalizes via gcd and collapses to a fixnum when den
reduces to 1, so 14/2 stays as 7.
Arithmetic (+, -, *, /, =, <, >, <=, >=) now promotes to rational when
any argument is rational. Mixed fixnum/rational lifts the fixnum
accumulator into a rational mid-loop so (+ 1 1/2) returns 3/2, not 1/2.
Reader parses "67/7" literals via the existing atom path: after the
numerator's digits, if '/' follows we keep reading the denominator and
hand back a normalized rational. Falls through to symbol if either side
isn't all digits.
Printer renders rationals as "n/d". equal_p compares numbers by value
(1/2 = 2/4, 3 = 6/2). is_number / number? cover both fixnums and
rationals.
eval now treats rationals as self-evaluating — without this, '1/3'
parsed correctly but evaluated to VOID.
Mandelbrot demo: switched from (/ a b) to (quotient a b) for the
fixed-point math. The demo had been relying on integer truncation
that '/' no longer provides on tiers with R7RS-correct rationals.
Bignums still pending: 31-bit num/den overflows with huge denominators.
Real lift comes with the bignum task in the C tier (which has them) or
a new bignum module in the WAT.
Cross-tier check still hangs on the bigger TCO-heavy sections of
functional.lsp — separate from rationals. Will keep grinding.
Tests: unit 20/20, integration 8/8, functional 11/11.
WASM tail-call instruction wired into every tail position:
if branches, eval_begin last expression, all special-form dispatchers
(cond/when/unless/case/let/let*/letrec/begin/and/or), function
application's $apply, and apply's closure-body $eval_begin.
wat2wasm + wasm-validate now use --enable-tail-call.
Reader fixes:
- Dotted pair syntax: (a b . rest) parses as a real dotted list.
Without this, variadic params and other dotted-cdr forms parsed
as 4-element proper lists.
- Stray ) at top level advances source_ptr instead of spinning
forever. Found via bisection of functional.lsp under TCO.
- eval_args guards against non-pair tails so a misplaced dotted
argument (e.g. an unexpanded macro template) can't deref garbage.
Cross-tier numbers (wasm-test-functional-cross):
before: 93/111 reached, stack overflow on (ack 3 4)
now: progresses through the full TCO section, deep let, named-let
to 100k, mutual recursion to 200k. Still climbing.
Bend gpu demo:
6th playground option ("bend (gpu dispatch) ⚡") ships a SHAKE256
fan-out at 1M inputs via (bend!-call ...). Run button is GUARDED:
if no bend URL is configured, refuses with
"set a bend URL first — this demo is GPU-only by design".
Protects customer machines from burning minutes on a workload the
local tiers cannot finish in reasonable time.
- bend URL and vault password now share a single config-bar row, split
via a 2-column grid (1fr 1fr). Vault still hidden when free-form
isn't selected; just collapses its column.
- Every flexbox removed. Every multi-child container uses CSS grid:
.controls, .config-bar, .bend-bar, .vault-bar, .panes, .pane,
.brand, .tabbar, .tabs, .tab, .transcript, .entry, .tier-output,
.prompt-bar, .lock-screen, .lock-card, .lock-row.
- Added `[hidden] { display: none !important; }` so the ephemeral
button on the REPL lock screen actually hides the modal. Without
this, .lock-screen's `display: grid` overrode the hidden attribute's
UA-default display: none.
bend!-call from the WAT tier
- New WAT import: (import "env" "bend_call"). The host loader supplies
a sync XMLHttpRequest that POSTs the payload to a configured URL
(workers only — sync XHR isn't allowed on main thread).
- New primitive (bend!-call "<payload>") returns the response as a
lumbda string. Works in playground and REPL once the bend URL is
saved in the new top bar.
- bendUrl persists in plain localStorage (not encrypted — it's a
server address, not a secret).
- Tests stub the import with a no-op so unit / integration / functional
suites keep instantiating cleanly.
Playground + REPL zoom 133% by default
- html { zoom: 1.33 } so the styleguide sizes read comfortably without
requiring browser-level zoom.
Free-form default = cross-tier assertion runner in Lisp
- The default editor content for "free form" is now a small assertion
framework matching tests/functional.lsp's PASS/FAIL convention. A
starter the user can extend, runs identically on the three tiers.
C tier + Python tier (bend) are wired through the runner stub; full
bend integration in those tiers comes next once their loaders learn
about setBendUrl.
Free-form radio adds a 5th demo slot. When selected, a vault bar appears
under the controls: enter a password, "unlock" derives a per-device
vault and decrypts (or creates fresh). Edits in the editor auto-save
350ms after typing stops. Reload + same password restores the code.
Same Web Crypto stack as /repl/ (PBKDF2 + AES-GCM, vault id =
SHA-256(password || device-salt)).
Layout: one shared vertical scroller — code pane and output pane both
grow with content, the body scrolls. No more independent in-pane
scrollers fighting the page.
Home page split into "Demo" and "REPL" sections with their own CTAs.
WAT prelude (evaluated after primitive binding at init) adds:
map, filter, fold-left, fold-right, for-each, any, every,
count, find, sort (quicksort), vector-map, vector-for-each,
vector-fill!, string-split, string-trim, string->list,
random-state, assert-equal/true/false.
Higher-order ops are now Lisp-defined, not primitive bloat. Eval-time
parse + bind happens once per WASM instance startup.
Playground output: per fox, single append-only column instead of
3-up grid. Tiers still race in parallel workers; whichever finishes
first appears first in the output. Live ms counters move to the status
bar (python 312ms · c 47ms · asm 89ms).
One Worker per tier (python/c/asm). "All three" mode dispatches
Promise.all so the tiers race on independent threads — a slow Pyodide
no longer blocks C and asm. Each tier-block ticks its own ms counter
until its worker resolves.
Output grid: 3 columns when 3 tier-blocks render, else stacked.
Status bar announces the winner: "ok — c won in 47 ms".
Cancel terminates every active worker.
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.
Adds a parallel build of all three Lumbda implementations to WASM, a
single-page playground at www/playground/, and a verified test suite.
Tiers
- Python: Pyodide (CPython-in-WASM) hosting lumbda.py
- C: Emscripten build of c/ (tree-walker + bytecode VM; jit.c
stubbed, gc.c uses its existing no-Boehm fallback)
- Asm: hand-written asm/lumbda.wat — parallel impl to asm/lumbda.s.
Reader, eval (lambda/define/if/cond/let/and/or/quote/set!),
recursion across mutated top-level env, bump allocator with
memory.grow, 24 primitives. ~1200 lines of raw WAT.
SPA (wasm/app/, deployed to www/playground/)
- CodeMirror 6 editor (Scheme highlighting) on left, output on right
- Radios: 4 demos (Mandelbrot, Fib+Ack, Sieve, self-interp meta-eval)
x 4 tiers (Python | C | Asm | All three)
- All-three mode renders the three tier outputs side by side with
per-tier elapsed timing
Tests (38 verified assertions)
- 20 unit (Node): per-tier module loads, eval smoke
- 8 integration (Node): each demo on c+asm WASM byte-matches the
canonical native Python run
- 10 functional (Playwright headless Chromium): page mounts, every
demo runs on every tier, all-three renders
Makefile
- Root targets: wasm-build, wasm-test, wasm-test-fn, wasm-serve,
wasm-deploy, wasm-clean
- wasm/Makefile orchestrates the three tier builds; deploy copies
dist/ into www/playground/
Asm tier notes
- WAT linear symbol intern + linear env lookup is MOAD-0001 at scale;
documented in the asm/lumbda.wat header and in the SPA footer. The
demos hit ~30 globals so the linear walks are cheap enough.
- Bump allocator never frees (matches asm/lumbda.s heap discipline);
memory.grow expands by 1 MB chunks. Browser tab tears down at unload.
Toolchain (developer prerequisites)
- Emscripten 6.0.0 via emsdk at ~/git/emsdk
- wabt 1.0.36 at ~/git/wabt
- Playwright for functional tests (symlinked from ~/git/agnt)