lumbda/www/playground/worker.mjs
russell@unturf.com ffdfc1df43
playground: stream tier output line-by-line during eval
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.
2026-06-14 20:25:47 -04:00

57 lines
2.4 KiB
JavaScript

// wasm/app/worker.mjs
// Tier-evaluation Web Worker. Keeps the main thread responsive so the
// live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()).
import { evalOnTier, setBendUrl, heapStats, portalSave, portalLoad } from "./runner.js";
self.onmessage = async (e) => {
const { kind } = e.data;
if (kind === "config") {
setBendUrl(e.data.bendUrl);
return;
}
if (kind === "heap") {
// Synchronous read — no eval is running because the worker is
// single-threaded and main thread only sends this between evals.
self.postMessage({ kind: "heap", runId: e.data.runId, tier: e.data.tier, stats: heapStats(e.data.tier) });
return;
}
if (kind === "portal-save") {
// Pulls the most-recent /tmp/<name>.portal out of the tier's
// MEMFS after the lisp side ran (portal-snapshot! NAME). Main
// thread encrypts and stuffs it into the vault.
const blob = portalSave(e.data.tier, e.data.name);
self.postMessage({ kind: "portal-save", runId: e.data.runId, name: e.data.name, blob });
return;
}
if (kind === "portal-load") {
// Hydrates MEMFS from a vault-decrypted blob so a subsequent
// (portal-load! NAME) eval finds the file ready.
const ok = portalLoad(e.data.tier, e.data.name, e.data.blob);
self.postMessage({ kind: "portal-load", runId: e.data.runId, name: e.data.name, ok });
return;
}
if (kind !== "eval") return;
const { runId, tier, src } = e.data;
try {
const output = await evalOnTier(
tier,
src,
(loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
},
// Stream every print/display from the tier to the main
// thread as it happens. Sync XHR inside bend!-call still
// blocks the worker, but displays BEFORE/AFTER the bend
// round-trip surface immediately instead of waiting for
// the whole eval to finish. Long demos feel alive.
(chunk) => {
self.postMessage({ kind: "chunk", runId, tier, chunk });
},
);
self.postMessage({ kind: "done", runId, output });
} catch (err) {
self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });
}
};