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.
204 lines
8.1 KiB
JavaScript
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;
|
|
};
|
|
})();
|