lumbda/www/repl/python/lumbda-py.js
russell@unturf.com d4380c64c7
repl: portal save/resume — vault-backed tier checkpoints
Adds a portal-bar to the REPL between tabbar and transcript: a save
button + chip strip showing all saved checkpoints for the active tab.
Click a chip to restore, click × to delete.

Per-tier strategy:
  * c, python — call the tier's (portal-snapshot! NAME), then read the
    JSON blob out of MEMFS (Emscripten/Pyodide FS) and stash it in the
    encrypted vault entry. Restore reverses: hydrate MEMFS, then
    (portal-load! NAME) merges the bindings into the live env.
  * asm — no portal serializer in the WAT tier yet (would need a
    Cheney-aware walk). Falls back to transcript replay: save snapshots
    every successful prior input, restore reboots the tier and re-evals
    them in order.

Plumbing:
  * Worker bridge: new portal-save / portal-load message kinds wire
    MEMFS reads/writes to the main thread.
  * runner.js exposes portalSave / portalLoad — null when a tier
    hasn't implemented portals (asm stays grey).
  * C tier: replace EM_JS with extern + --js-library for js_lumbda_bend_call
    (EM_JS-generated declaration was unreachable from wasmImports at
    instantiate time, browsers threw "import object field ... not a
    Function"). FS added to EXPORTED_RUNTIME_METHODS so JS can reach
    pyodide.FS / Module.FS for MEMFS I/O.

Smoke-tested all three tiers headlessly: save → chip render → restore
round-trips clean on c / python / asm, zero console errors.
2026-06-15 07:43:07 -04:00

184 lines
7.1 KiB
JavaScript

// 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>,
// setBendUrl(url) -> void,
// heapStats() -> {used,total} }>.
const PYODIDE_VERSION = "0.27.2";
const PYODIDE_INDEX_URL = `https://cdn.jsdelivr.net/pyodide/v${PYODIDE_VERSION}/full/`;
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. 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);
// bend dispatch closure. Browser can't open raw TCP, so we POST
// the S-expression payload to the configured worker URL and read
// the response text back. Sync XHR is the only sync HTTP available
// in a Web Worker; perfect for the blocking eval model lumbda
// primitives expect.
const refs = { bendUrl: null, currentOnChunk: null };
// Streaming output bridge — sys.stdout in pyodide is replaced
// (during _lumbda_eval) with a class whose write() calls back
// here. We forward to the current onChunk so the worker can
// postMessage chunks to the UI as work happens, instead of the
// user staring at a blank panel during a long bend dispatch.
globalThis._lumbdaPyEmitChunk = (s) => {
if (refs.currentOnChunk && s) refs.currentOnChunk(s);
};
globalThis._lumbdaPyBendCall = (payload) => {
if (!refs.bendUrl) return "no bend URL configured";
try {
const xhr = new XMLHttpRequest();
xhr.open("POST", refs.bendUrl, false); // sync
xhr.setRequestHeader("Content-Type", "text/plain; charset=utf-8");
xhr.send(payload);
return xhr.responseText || "";
} catch (e) {
return `bend error: ${e.message || String(e)}`;
}
};
await pyodide.runPythonAsync(`
import sys, io
sys.path.insert(0, "/home/pyodide")
import lumbda
_env = lumbda.make_global_env()
for _e in lumbda.read_all(lumbda.PRELUDE):
lumbda.leval(_e, _env)
# bend!-call primitive — bridges to JS XHR via globalThis._lumbdaPyBendCall.
# Argument: a string payload (the S-expression text). Returns response text.
from js import _lumbdaPyBendCall as _js_bend_call
def _bend_call_prim(args, env):
if not args:
return ""
payload = args[0]
if not isinstance(payload, str):
payload = lumbda.show(payload)
return str(_js_bend_call(payload))
_env.define(lumbda.S('bend!-call'), _bend_call_prim)
# Portal save/resume — REPL bridge. Mirrors the C tier's
# (portal-snapshot! NAME) / (portal-load! NAME): writes/reads
# /tmp/<name>.portal so the JS side can round-trip blobs to vault.
import os
os.makedirs('/tmp', exist_ok=True)
def _portal_snapshot_prim(args, env):
if not args or not isinstance(args[0], str):
raise lumbda.LispErr('portal-snapshot!: expected string name')
path = f'/tmp/{args[0]}.portal'
g = env.g if env.g else _env
lumbda.portal_save(g, path)
return path
def _portal_load_prim(args, env):
if not args or not isinstance(args[0], str):
raise lumbda.LispErr('portal-load!: expected string name')
path = f'/tmp/{args[0]}.portal'
restored_env, _cont = lumbda.portal_resume(path, _env)
# Copy bindings from the restored env into our live global env so
# subsequent evals see them. portal_resume gives us back a fresh
# env with the loaded bindings; we merge into _env in-place.
for sym, val in restored_env.b.items():
_env.b[sym] = val
return True
_env.define(lumbda.S('portal-snapshot!'), _portal_snapshot_prim)
_env.define(lumbda.S('portal-load!'), _portal_load_prim)
# Streaming stdout wrapper — every write() also calls back into JS so
# the worker can postMessage chunks to the UI during the eval. The
# StringIO behind it still captures everything for the final return.
from js import _lumbdaPyEmitChunk as _js_emit_chunk
class _StreamingStdout(io.StringIO):
def write(self, s):
n = super().write(s)
try:
_js_emit_chunk(s)
except Exception:
pass
return n
def _lumbda_eval(src):
buf = _StreamingStdout()
old = sys.stdout
sys.stdout = buf
last = None
try:
for e in lumbda.read_all(src):
last = lumbda.leval(e, _env)
except Exception as ex:
sys.stdout = old
return f"{buf.getvalue()}error: {ex}"
sys.stdout = old
out = buf.getvalue()
if last is not None and not isinstance(last, lumbda._Void):
rep = lumbda.show(last)
if out and not out.endswith("\\n"):
out += "\\n"
out += rep
try:
_js_emit_chunk(("" if out.endswith(rep) else "\\n") + rep)
except Exception:
pass
return out
`);
return {
async evalLisp(src, onChunk) {
pyodide.globals.set("_src_in", src);
refs.currentOnChunk = onChunk || null;
try {
return await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
} finally {
refs.currentOnChunk = null;
}
},
setBendUrl(url) { refs.bendUrl = url || null; },
// Portal save/resume bridge to vault — same shape as the C tier
// loader. Pyodide.FS is the same Emscripten FS, so the path
// /tmp/<name>.portal is reachable from JS exactly as in C.
portalSave(name) {
const path = `/tmp/${name}.portal`;
try {
const bytes = pyodide.FS.readFile(path);
return new TextDecoder().decode(bytes);
} catch (e) { return null; }
},
portalLoad(name, blob) {
const path = `/tmp/${name}.portal`;
try { pyodide.FS.mkdir("/tmp"); } catch (e) { /* exists */ }
pyodide.FS.writeFile(path, blob);
},
heapStats() {
// Pyodide's runtime memory is the Emscripten linear memory.
// CPython's GC reclaims behind the scenes, so this number
// rises and falls naturally as objects die.
const total = pyodide._module.HEAPU8.byteLength;
return { used: total, total };
},
};
}
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createPythonTier = (() => {
let tier = null;
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();