lumbda/www/playground/python/lumbda-py.js
russell@unturf.com 1f2ef730a2
python tier: SAB-backed pause poll — auto-pause now works on python too
leval() in lumbda.py grows a counter-gated check (every 1024th
iteration) that calls a module-level _lumbda_pause_hook. Native
Python users leave the hook None and the check short-circuits to a
single bitwise AND. The pyodide loader installs a hook that reads
_lumbdaPyPauseRequested (a JS callback over Atomics.load on the
SAB) so the REPL's auto-pause-on-tab-switch flow drops into the
same path on python that it already uses on C.

Also adds setPauseFlag to the python tier's returned object so
runner.setPauseFlag propagates the SAB through worker config.

repl.js's autoPauseTab no longer falls back to hard-cancel when
the active tier is python — the SAB-poll path covers both. Asm
remains on hard-cancel since the WAT tier has no in-eval poll
site yet.

Test suite (tests.py, 571 tests) passes — verified the no-op
hook path doesn't change native eval semantics.
2026-06-15 11:16:50 -04:00

204 lines
8.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, pauseFlag: null };
// Cooperative pause check — lumbda.py polls _lumbdaPyPauseRequested
// every ~1024 leval iterations. Returns true when the REPL has
// signalled a pause via the SharedArrayBuffer atomic (index 0).
// Falls back to false when SAB isn't available (no COOP/COEP),
// turning the poll into a no-op.
globalThis._lumbdaPyPauseRequested = () => {
if (!refs.pauseFlag) return false;
try { return Atomics.load(refs.pauseFlag, 0) ? true : false; }
catch (e) { return false; }
};
// 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.
# Wire the pause hook so leval polls SAB via the JS callback.
from js import _lumbdaPyPauseRequested as _js_pause_requested
lumbda._lumbda_pause_hook = lambda: bool(_js_pause_requested())
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; },
// Pause hook — wraps the incoming SAB in an Int32Array so the
// _lumbdaPyPauseRequested callback (installed above) can read
// the atomic. Null disables (no COOP/COEP).
setPauseFlag(flag) {
refs.pauseFlag = flag ? new Int32Array(flag) : 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;
};
})();