lumbda/wasm/app/runner.js
russell@unturf.com df154b9a56
repl: auto-pause + portal-save when leaving a tab mid-eval, resume on return
Tab switching during a long-running eval used to silently abandon
the calc — output stopped streaming, no snapshot, nothing to come
back to. Now setActiveTab pauses the outgoing tab's eval (and
optionally portal-saves the env), terminates the worker, and on
re-entry hydrates + re-fires the original input.

Pieces:

* serve-coop.py + make serve-repl — dev server that emits
  Cross-Origin-Opener-Policy: same-origin and
  Cross-Origin-Embedder-Policy: require-corp so SharedArrayBuffer
  is constructable in the browser. Same headers production needs.

* C tier eval-loop pause poll — c/eval.c grows lumbda_check_pause(),
  guarded by #ifdef LUMBDA_WASM. Called at the top of leval()'s
  while(1); masked to every 1024th iteration so the polling cost
  stays under noise floor. When the JS-library import
  js_lumbda_pause_requested returns 1, lisp_error("paused")
  longjmps out so module-global env survives intact for the
  portal-snapshot that follows.

* SAB plumbing — main thread allocates new SharedArrayBuffer(4),
  hands it through worker config → runner.setPauseFlag →
  lumbda-c.loader.setPauseFlag → globalThis._lumbdaCPauseFlag.
  Atomics.store / Atomics.load on index 0 is the signalling
  channel. Falls back to null when COOP/COEP isn't isolated, in
  which case pause degrades to a hard worker.terminate().

* autoPauseTab() — on setActiveTab away, snapshots the tier
  (C tier with SAB) or hard-cancels (other tiers / no SAB),
  stashes tab.autoPause = {tier, blob, inputSrc, savedAt},
  terminates the workers so the heap is reclaimed.

* autoResumeTab() — on setActiveTab into a tab with autoPause,
  reboots the tier, hydrates MEMFS, runs (portal-load! ...), then
  re-fires the original input via sendInput so the eval restarts
  from the saved state. Asm + Python paths re-run from scratch
  until their poll sites land.

Also closes two UX papercuts from fox: chip ⇣ export icon bumped
from 0.85em muted to 1em green so it's actually discoverable; the
scope toggle now reads "scope: this tab" / "scope: all tabs" so the
button label describes the state rather than a target.
2026-06-15 09:02:53 -04:00

72 lines
2.7 KiB
JavaScript

// wasm/app/runner.js
// Tier runner — wraps the three loaders. Exposes a per-tier API so the
// SPA can iterate, time, and render a live elapsed-ms counter between
// the eval start and finish.
import { createPythonTier } from "./python/lumbda-py.js";
import { createCTier } from "./c/lumbda-c.loader.js";
import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {};
const config = { bendUrl: null, pauseFlag: null };
export function setBendUrl(url) {
config.bendUrl = url || null;
// Propagate to already-loaded tiers.
for (const t of Object.values(cache)) {
if (t && t.setBendUrl) t.setBendUrl(config.bendUrl);
}
}
// SharedArrayBuffer that the tier's eval loop polls for a pause
// signal. Main thread writes 1 to atomic index 0; the tier's polling
// hook (c/eval.c lumbda_check_pause) raises lisp_error("paused") at
// the next K-iteration boundary so module-global env is preserved
// and (portal-snapshot!) afterward captures everything that ran
// before the pause.
export function setPauseFlag(flag) {
config.pauseFlag = flag || null;
for (const t of Object.values(cache)) {
if (t && t.setPauseFlag) t.setPauseFlag(config.pauseFlag);
}
}
export async function getTier(name, onLoad) {
if (cache[name]) return cache[name];
if (onLoad) onLoad(name);
if (name === "python") cache[name] = await createPythonTier();
else if (name === "c") cache[name] = await createCTier();
else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`);
if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl);
if (cache[name].setPauseFlag) cache[name].setPauseFlag(config.pauseFlag);
return cache[name];
}
export async function evalOnTier(name, src, onLoad, onChunk) {
const tier = await getTier(name, onLoad);
return tier.evalLisp(src, onChunk);
}
export function heapStats(name) {
const tier = cache[name];
if (!tier || !tier.heapStats) return null;
return tier.heapStats();
}
// Portal — REPL save/resume bridge. portalSave reads a snapshot blob
// from the tier (MEMFS on the C tier, similar bridges on others as
// they land). Returns null when the tier hasn't implemented portals
// yet (asm-wat today) so the caller can surface a friendly message
// instead of crashing. portalLoad is the symmetric write-in path.
export function portalSave(name, checkpointName) {
const tier = cache[name];
if (!tier || !tier.portalSave) return null;
return tier.portalSave(checkpointName);
}
export function portalLoad(name, checkpointName, blob) {
const tier = cache[name];
if (!tier || !tier.portalLoad) return false;
tier.portalLoad(checkpointName, blob);
return true;
}