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