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.
237 lines
10 KiB
Makefile
237 lines
10 KiB
Makefile
# ═══════════════════════════════════════════════════════════════════
|
|
# wasm/Makefile — three-tier Lumbda to WebAssembly
|
|
# ═══════════════════════════════════════════════════════════════════
|
|
#
|
|
# Builds:
|
|
# python/ — Pyodide loader + lumbda.py (CPython-in-WASM)
|
|
# c/ — Emscripten build of c/ (tree-walker + bytecode VM, no JIT)
|
|
# asm/ — Hand-written WAT compiled to wasm (parallel "asm" tier)
|
|
# app/ — Single-page app shell (HTML + CodeMirror + JS glue)
|
|
#
|
|
# Targets:
|
|
# make build all three tiers + SPA bundle in dist/
|
|
# make test unit (node) + integration (node, cross-tier diff)
|
|
# make test-fn functional (playwright headless browser)
|
|
# make serve local dev server on :8080
|
|
# make deploy copy dist/ -> ../www/playground/
|
|
# make clean
|
|
|
|
EMSDK_DIR := $(HOME)/git/emsdk
|
|
EMSDK_ENV := $(EMSDK_DIR)/emsdk_env.sh
|
|
EMCC := $(EMSDK_DIR)/upstream/emscripten/emcc
|
|
WAT2WASM := $(HOME)/git/wabt/bin/wat2wasm
|
|
WASM_VALIDATE := $(HOME)/git/wabt/bin/wasm-validate
|
|
|
|
REPO_ROOT := $(abspath ..)
|
|
C_SRC_DIR := $(REPO_ROOT)/c
|
|
PY_SRC := $(REPO_ROOT)/lumbda.py
|
|
STDLIB := $(REPO_ROOT)/stdlib.lsp
|
|
|
|
DIST := dist
|
|
|
|
# Pyodide pinned version
|
|
PYODIDE_VER := 0.27.2
|
|
PYODIDE_CDN := https://cdn.jsdelivr.net/pyodide/v$(PYODIDE_VER)/full
|
|
|
|
# ─── Top-level ─────────────────────────────────────────────────────
|
|
|
|
.PHONY: all build test test-unit test-integration test-fn serve deploy clean \
|
|
python c asm app dist-bundle check-tools
|
|
|
|
all: build
|
|
|
|
build: check-tools python c asm app dist-bundle
|
|
|
|
check-tools:
|
|
@test -x $(EMCC) || { echo "emcc missing at $(EMCC)"; exit 1; }
|
|
@test -x $(WAT2WASM) || { echo "wat2wasm missing at $(WAT2WASM)"; exit 1; }
|
|
@command -v node >/dev/null || { echo "node missing"; exit 1; }
|
|
|
|
# ─── Python tier (Pyodide) ─────────────────────────────────────────
|
|
#
|
|
# Pyodide loads CPython into WASM. We ship a small JS loader that:
|
|
# 1. Imports Pyodide from CDN (pinned version)
|
|
# 2. Drops lumbda.py + stdlib.lsp into Pyodide's virtual FS
|
|
# 3. Exposes evalLisp(src) -> string
|
|
|
|
python: $(DIST)/python/lumbda-py.js $(DIST)/python/lumbda.py $(DIST)/python/stdlib.lsp
|
|
|
|
$(DIST)/python/lumbda-py.js: python/lumbda-py.js
|
|
@mkdir -p $(@D)
|
|
@cp $< $@
|
|
|
|
$(DIST)/python/lumbda.py: $(PY_SRC)
|
|
@mkdir -p $(@D)
|
|
@cp $< $@
|
|
|
|
$(DIST)/python/stdlib.lsp: $(STDLIB)
|
|
@mkdir -p $(@D)
|
|
@cp $< $@
|
|
|
|
# ─── C tier (Emscripten) ───────────────────────────────────────────
|
|
#
|
|
# Build the C tier under emcc. Drops:
|
|
# * jit.c — x86_64 machine-code emitter, no WASM equivalent
|
|
# * gc.c — Boehm-specific tracing (replaced with malloc-no-free)
|
|
#
|
|
# Keeps tree-walker + bytecode VM + reader + printer + portal.
|
|
#
|
|
# -DLUMBDA_WASM gates conditional code paths in main.c / eval.c.
|
|
|
|
C_SOURCES := $(addprefix $(C_SRC_DIR)/, \
|
|
reader.c printer.c eval.c vm.c types.c builtins.c bignum.c portal.c gc.c)
|
|
|
|
# jit.c is included but g_jit_enabled stays false at runtime so emit paths
|
|
# are never executed. mmap PROT_EXEC would fail in WASM anyway; harmless.
|
|
C_JIT_STUB := c/jit-stub.c
|
|
|
|
# bdwgc integration attempt — DEFERRED. See c/lumbda_wasm_entry.c
|
|
# header comment for the full status: bdwgc builds cleanly with emcc
|
|
# via CMake, but option-1 (binaryen --spill-pointers) crashes in our
|
|
# emsdk version and option-2 (GC_disable + manual gcollect at safe
|
|
# points) compiles but triggers a runtime fault on the first eval.
|
|
# Likely a missed root somewhere in the eval call chain. Leaving the
|
|
# pieces in place (BDWGC_DIR check + commented include) so the next
|
|
# session can iterate without rebuilding bdwgc.
|
|
|
|
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=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","FS"]' \
|
|
-s ALLOW_MEMORY_GROWTH=1 -s INITIAL_MEMORY=32MB \
|
|
-s STACK_SIZE=8MB \
|
|
-s ENVIRONMENT=web,worker,node \
|
|
-s SINGLE_FILE=0 \
|
|
--js-library c/bend-call-library.js
|
|
|
|
c: $(DIST)/c/lumbda-c.js $(DIST)/c/lumbda-c.wasm $(DIST)/c/lumbda-c.loader.js
|
|
|
|
$(DIST)/c/lumbda-c.js $(DIST)/c/lumbda-c.wasm: $(C_SOURCES) $(C_JIT_STUB) c/lumbda_wasm_entry.c c/bend-call-library.js
|
|
@mkdir -p $(@D)
|
|
EMSDK=$(EMSDK_DIR) EMSDK_NODE=$(EMSDK_DIR)/node/22.16.0_64bit/bin/node \
|
|
PATH=$(EMSDK_DIR)/node/22.16.0_64bit/bin:$(EMSDK_DIR)/upstream/emscripten:$$PATH \
|
|
$(EMCC) $(C_CFLAGS) $(C_SOURCES) $(C_JIT_STUB) c/lumbda_wasm_entry.c \
|
|
$(C_LDFLAGS) -o $(DIST)/c/lumbda-c.js
|
|
|
|
$(DIST)/c/lumbda-c.loader.js: c/lumbda-c.loader.js
|
|
@mkdir -p $(@D)
|
|
@cp $< $@
|
|
|
|
# ─── Asm tier (hand-written WAT) ───────────────────────────────────
|
|
#
|
|
# asm/lumbda.wat is a Lisp interpreter written directly in WebAssembly
|
|
# Text format. Stack-machine assembly, no libc, raw linear memory.
|
|
# Parallel implementation to asm/lumbda.s (x86_64). Same tier semantics.
|
|
|
|
asm: $(DIST)/asm/lumbda-asm.wasm $(DIST)/asm/lumbda-asm.loader.js
|
|
|
|
$(DIST)/asm/lumbda-asm.wasm: asm/lumbda.wat
|
|
@mkdir -p $(@D)
|
|
$(WAT2WASM) --enable-tail-call $< -o $@
|
|
$(WASM_VALIDATE) --enable-tail-call $@
|
|
|
|
$(DIST)/asm/lumbda-asm.loader.js: asm/lumbda-asm.loader.js
|
|
@mkdir -p $(@D)
|
|
@cp $< $@
|
|
|
|
# ─── SPA shell ─────────────────────────────────────────────────────
|
|
|
|
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 $(DIST)/worker.mjs \
|
|
$(DIST)/crypto.js \
|
|
$(DIST)/lumbda-logo-green.png $(DIST)/fonts/chunkfive/chunkfive-regular-webfont.woff2 \
|
|
$(DIST)/fonts/chunkfive/chunkfive-regular-webfont.woff
|
|
|
|
$(DIST)/%: app/%
|
|
@mkdir -p $(@D)
|
|
@cp $< $@
|
|
|
|
dist-bundle: app
|
|
@mkdir -p $(DIST)/demos
|
|
@cp app/demos/*.lsp $(DIST)/demos/ 2>/dev/null || true
|
|
|
|
# ─── REPL bundle (separate page sharing tier assets) ───────────────
|
|
# The REPL deploys to www/repl/ and shares /playground/'s tier loaders
|
|
# via copied symlinks so we don't ship the WASM bytes twice.
|
|
|
|
REPL_DIST := $(DIST)-repl
|
|
|
|
repl: build
|
|
@mkdir -p $(REPL_DIST)/c $(REPL_DIST)/asm $(REPL_DIST)/python $(REPL_DIST)/fonts/chunkfive
|
|
@cp $(DIST)/c/lumbda-c.js $(REPL_DIST)/c/
|
|
@cp $(DIST)/c/lumbda-c.wasm $(REPL_DIST)/c/
|
|
@cp $(DIST)/c/lumbda-c.loader.js $(REPL_DIST)/c/
|
|
@cp $(DIST)/asm/lumbda-asm.wasm $(REPL_DIST)/asm/
|
|
@cp $(DIST)/asm/lumbda-asm.loader.js $(REPL_DIST)/asm/
|
|
@cp $(DIST)/python/lumbda.py $(REPL_DIST)/python/
|
|
@cp $(DIST)/python/stdlib.lsp $(REPL_DIST)/python/
|
|
@cp $(DIST)/python/lumbda-py.js $(REPL_DIST)/python/
|
|
@cp $(DIST)/runner.js $(REPL_DIST)/
|
|
@cp $(DIST)/worker.mjs $(REPL_DIST)/
|
|
@cp repl/index.html repl/repl.css repl/repl.js repl/crypto.js $(REPL_DIST)/
|
|
@cp $(DIST)/style.css $(REPL_DIST)/style.css
|
|
@cp $(DIST)/lumbda-logo-green.png $(REPL_DIST)/lumbda-logo-green.png
|
|
@cp $(DIST)/fonts/chunkfive/*.woff* $(REPL_DIST)/fonts/chunkfive/
|
|
|
|
# ─── Tests ─────────────────────────────────────────────────────────
|
|
|
|
test: test-unit test-integration test-parity test-functional-cross
|
|
|
|
test-unit: build
|
|
@echo "── wasm unit tests ──"
|
|
node tests/unit.mjs
|
|
|
|
test-integration: build
|
|
@echo "── wasm integration tests (cross-tier diff) ──"
|
|
node tests/integration.mjs
|
|
|
|
# Cross-tier parity probe — every expression in parity-corpus.mjs runs on
|
|
# native python (reference), c-wasm, and asm-wasm. Fails on any unknown
|
|
# divergence so a regression against whitepaper § normative claims gets
|
|
# caught before merge. Known gaps (bignums on asm-wasm today) live in
|
|
# KNOWN_DIVERGE; trim that list as the gaps close.
|
|
test-parity: build
|
|
@echo "── wasm cross-tier parity probe ──"
|
|
node tests/parity-cross-tier.mjs
|
|
|
|
# Cross-tier functional.lsp runner. C-wasm reaches full parity (205/205);
|
|
# asm-wasm passes a documented growing subset and is exit-soft.
|
|
test-functional-cross: build
|
|
@echo "── wasm cross-tier functional.lsp ──"
|
|
node tests/functional-cross.mjs
|
|
|
|
test-fn: build
|
|
@echo "── wasm functional tests (headless browser) ──"
|
|
@test -d node_modules/playwright || { \
|
|
echo "SKIP: node_modules/playwright not present (symlink or npm install)"; exit 0; }
|
|
node tests/functional.mjs
|
|
|
|
# ─── Dev server ────────────────────────────────────────────────────
|
|
|
|
serve: build
|
|
@echo "Serving $(DIST) on http://localhost:8080"
|
|
@cd $(DIST) && python3 -m http.server 8080
|
|
|
|
# REPL dev server with the COOP/COEP headers SharedArrayBuffer needs.
|
|
# Pause-and-snapshot relies on a SAB-backed atomic flag the C tier's
|
|
# eval loop polls — the browser refuses to create a SAB without
|
|
# cross-origin-isolation headers in the response.
|
|
serve-repl: repl
|
|
@python3 serve-coop.py 8090 $(REPL_DIST)
|
|
|
|
# ─── Deploy ────────────────────────────────────────────────────────
|
|
|
|
deploy: build repl
|
|
@mkdir -p $(REPO_ROOT)/www/playground $(REPO_ROOT)/www/repl
|
|
@cp -r $(DIST)/* $(REPO_ROOT)/www/playground/
|
|
@cp -r $(REPL_DIST)/* $(REPO_ROOT)/www/repl/
|
|
@echo "Deployed to www/playground/ and www/repl/"
|
|
|
|
# ─── Clean ─────────────────────────────────────────────────────────
|
|
|
|
clean:
|
|
rm -rf $(DIST)
|