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.
100 lines
4.6 KiB
JavaScript
100 lines
4.6 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, setPauseFlag, heapStats, portalSave, portalLoad } from "./runner.js";
|
|
|
|
self.onmessage = async (e) => {
|
|
const { kind } = e.data;
|
|
if (kind === "config") {
|
|
// Each field is independently optional so a config message can
|
|
// tweak one knob without resetting the others — REPL spawns
|
|
// workers with just { pauseFlag }; playground updates bendUrl
|
|
// alone, etc.
|
|
if (e.data.bendUrl !== undefined) setBendUrl(e.data.bendUrl);
|
|
if (e.data.pauseFlag !== undefined) setPauseFlag(e.data.pauseFlag);
|
|
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.
|
|
//
|
|
// Defensive newline normalization: the C-tier loader
|
|
// adds the trailing \n that Emscripten's Module.print
|
|
// strips, but we kept seeing horizontal output in fox's
|
|
// Firefox tab as if the \n was lost somewhere on the
|
|
// wire. To rule out anything between here and the main
|
|
// thread, split each chunk on \n at the source and post
|
|
// one message per line — newline preserved as a flag
|
|
// rather than a byte. The main-thread receiver knows to
|
|
// re-add the line break.
|
|
(chunk) => {
|
|
if (!chunk) return;
|
|
// Split on \n at the source and post TWO separate
|
|
// message kinds: chunk-text (visible bytes, never
|
|
// containing a newline) and chunk-eol (a bare event
|
|
// marking end-of-line). Newlines no longer travel
|
|
// as bytes — they're typed messages. Whatever was
|
|
// eating the \n between Module.print and the DOM
|
|
// in fox's tab is bypassed.
|
|
let start = 0;
|
|
for (let i = 0; i < chunk.length; i++) {
|
|
if (chunk.charCodeAt(i) === 10) {
|
|
if (i > start) {
|
|
self.postMessage({
|
|
kind: "chunk-text",
|
|
runId, tier,
|
|
text: chunk.slice(start, i),
|
|
});
|
|
}
|
|
self.postMessage({ kind: "chunk-eol", runId, tier });
|
|
start = i + 1;
|
|
}
|
|
}
|
|
if (start < chunk.length) {
|
|
self.postMessage({
|
|
kind: "chunk-text",
|
|
runId, tier,
|
|
text: chunk.slice(start),
|
|
});
|
|
}
|
|
},
|
|
);
|
|
self.postMessage({ kind: "done", runId, output });
|
|
} catch (err) {
|
|
self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });
|
|
}
|
|
};
|