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.
This commit is contained in:
russell@unturf.com 2026-06-15 09:02:53 -04:00
parent 84bd7cac8c
commit df154b9a56
No known key found for this signature in database
31 changed files with 703 additions and 42 deletions

View file

@ -664,8 +664,30 @@ Value syntax_transform_value(SyntaxTransformer *st, Value form) {
* Main evaluator — TCO via explicit while loop * Main evaluator — TCO via explicit while loop
* ═══════════════════════════════════════════════════════════════════════════ */ * ═══════════════════════════════════════════════════════════════════════════ */
#ifdef LUMBDA_WASM
/* Cooperative pause hook — the REPL's ⏸ pause button writes 1 into a
* SharedArrayBuffer atomic that the JS library import below reads.
* Polling every iteration of leval's while(1) is too expensive; mask
* to every 1024th call. lisp_error("paused") longjmps out cleanly so
* the C tier's module-global env retains every (define ...) that
* landed before the pause — exactly what (portal-snapshot!) needs to
* capture afterward. */
extern int js_lumbda_pause_requested(void);
static unsigned long lumbda_pause_counter = 0;
static inline void lumbda_check_pause(void) {
if ((++lumbda_pause_counter & 0x3FFul) != 0) return;
if (js_lumbda_pause_requested()) {
lumbda_pause_counter = 0;
lisp_error("paused");
}
}
#else
static inline void lumbda_check_pause(void) {}
#endif
Value leval(Value expr, Env *env) { Value leval(Value expr, Env *env) {
while (1) { while (1) {
lumbda_check_pause();
/* Self-evaluating */ /* Self-evaluating */
if (IS_NIL(expr) || IS_VOID(expr) || IS_TRUE(expr) || IS_FALSE(expr) || if (IS_NIL(expr) || IS_VOID(expr) || IS_TRUE(expr) || IS_FALSE(expr) ||
IS_EOF(expr) || IS_CHAR(expr)) return expr; IS_EOF(expr) || IS_CHAR(expr)) return expr;

View file

@ -216,6 +216,13 @@ serve: build
@echo "Serving $(DIST) on http://localhost:8080" @echo "Serving $(DIST) on http://localhost:8080"
@cd $(DIST) && python3 -m http.server 8080 @cd $(DIST) && python3 -m http.server 8080
# REPL dev server with the COOP/COEP headers SharedArrayBuffer needs.
# Pause-and-snapshot relies on a SAB-backed atomic flag the C tier's
# eval loop polls — the browser refuses to create a SAB without
# cross-origin-isolation headers in the response.
serve-repl: repl
@python3 serve-coop.py 8090 $(REPL_DIST)
# ─── Deploy ──────────────────────────────────────────────────────── # ─── Deploy ────────────────────────────────────────────────────────
deploy: build repl deploy: build repl

View file

@ -8,7 +8,7 @@ import { createCTier } from "./c/lumbda-c.loader.js";
import { createAsmTier } from "./asm/lumbda-asm.loader.js"; import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {}; const cache = {};
const config = { bendUrl: null }; const config = { bendUrl: null, pauseFlag: null };
export function setBendUrl(url) { export function setBendUrl(url) {
config.bendUrl = url || null; config.bendUrl = url || null;
@ -18,6 +18,19 @@ export function setBendUrl(url) {
} }
} }
// 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) { export async function getTier(name, onLoad) {
if (cache[name]) return cache[name]; if (cache[name]) return cache[name];
if (onLoad) onLoad(name); if (onLoad) onLoad(name);
@ -26,6 +39,7 @@ export async function getTier(name, onLoad) {
else if (name === "asm") cache[name] = await createAsmTier(); else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`); else throw new Error(`unknown tier: ${name}`);
if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl); if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl);
if (cache[name].setPauseFlag) cache[name].setPauseFlag(config.pauseFlag);
return cache[name]; return cache[name];
} }

View file

@ -3,12 +3,17 @@
// live ms counter actually ticks AND so cancel works (main thread // live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()). // terminates this worker via worker.terminate()).
import { evalOnTier, setBendUrl, heapStats, portalSave, portalLoad } from "./runner.js"; import { evalOnTier, setBendUrl, setPauseFlag, heapStats, portalSave, portalLoad } from "./runner.js";
self.onmessage = async (e) => { self.onmessage = async (e) => {
const { kind } = e.data; const { kind } = e.data;
if (kind === "config") { if (kind === "config") {
setBendUrl(e.data.bendUrl); // 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; return;
} }
if (kind === "heap") { if (kind === "heap") {

View file

@ -13,6 +13,18 @@
// setBendUrl that mutates it). // setBendUrl that mutates it).
mergeInto(LibraryManager.library, { mergeInto(LibraryManager.library, {
// Cooperative pause check — c/eval.c polls this every K iterations
// of leval(). The C tier loader sets globalThis._lumbdaCPauseFlag
// to an Int32Array view over a SharedArrayBuffer that the REPL
// main thread writes 1 to when the user clicks ⏸ pause. Without
// SAB (no COOP/COEP headers) the flag stays null and we always
// return 0 so the polling becomes a no-op.
js_lumbda_pause_requested: function() {
var flag = globalThis._lumbdaCPauseFlag;
if (!flag) return 0;
try { return Atomics.load(flag, 0) ? 1 : 0; }
catch (e) { return 0; }
},
js_lumbda_bend_call: function(payload_ptr, payload_len, resp_buf, resp_cap) { js_lumbda_bend_call: function(payload_ptr, payload_len, resp_buf, resp_cap) {
var bendUrl = globalThis._lumbdaCBendUrl; var bendUrl = globalThis._lumbdaCBendUrl;
if (!bendUrl) { if (!bendUrl) {

View file

@ -61,6 +61,14 @@ async function _bootstrap() {
// global so runner.js can propagate a saved playground URL. // global so runner.js can propagate a saved playground URL.
return { return {
setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; }, setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; },
// Pause hook — js_lumbda_pause_requested (the JS-library import
// wired in bend-call-library.js) reads globalThis._lumbdaCPauseFlag
// every K leval iterations. Setting it to an Int32Array view
// over a SharedArrayBuffer lets the REPL signal a pause from
// the main thread. Null disables (no COOP/COEP isolation).
setPauseFlag(flag) {
globalThis._lumbdaCPauseFlag = flag ? new Int32Array(flag) : null;
},
// portalSave / portalLoad bridge MEMFS to the JS side so the // portalSave / portalLoad bridge MEMFS to the JS side so the
// REPL can persist checkpoints to the encrypted vault and // REPL can persist checkpoints to the encrypted vault and
// restore them on a fresh worker. Names come from the worker // restore them on a fresh worker. Names come from the worker

File diff suppressed because one or more lines are too long

View file

@ -61,6 +61,14 @@ async function _bootstrap() {
// global so runner.js can propagate a saved playground URL. // global so runner.js can propagate a saved playground URL.
return { return {
setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; }, setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; },
// Pause hook — js_lumbda_pause_requested (the JS-library import
// wired in bend-call-library.js) reads globalThis._lumbdaCPauseFlag
// every K leval iterations. Setting it to an Int32Array view
// over a SharedArrayBuffer lets the REPL signal a pause from
// the main thread. Null disables (no COOP/COEP isolation).
setPauseFlag(flag) {
globalThis._lumbdaCPauseFlag = flag ? new Int32Array(flag) : null;
},
// portalSave / portalLoad bridge MEMFS to the JS side so the // portalSave / portalLoad bridge MEMFS to the JS side so the
// REPL can persist checkpoints to the encrypted vault and // REPL can persist checkpoints to the encrypted vault and
// restore them on a fresh worker. Names come from the worker // restore them on a fresh worker. Names come from the worker

Binary file not shown.

View file

@ -91,7 +91,7 @@
<span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span> <span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span>
<button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button> <button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button>
<button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button> <button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button>
<button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">📂 this tab</button> <button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">scope: this tab 📂</button>
<input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden> <input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden>
<div id="portal-chips" class="portal-chips"></div> <div id="portal-chips" class="portal-chips"></div>
</section> </section>

View file

@ -233,12 +233,17 @@ body.repl {
} }
.portal-chips .chip .chip-export, .portal-chips .chip .chip-export,
.portal-chips .chip .chip-close { .portal-chips .chip .chip-close {
color: var(--muted); /* Bumped from 0.85em + muted because fox couldn't find the export
font-size: 0.85em; * icon on the chip. Now sized at parity with the label and tinted
* with the chip's accent so the action affordance reads as a
* sibling of the name, not a footnote. */
font-size: 1em;
cursor: pointer; cursor: pointer;
padding: 0 0.15rem; padding: 0 0.15rem;
} }
.portal-chips .chip .chip-export:hover { color: var(--green); } .portal-chips .chip .chip-export { color: var(--green); }
.portal-chips .chip .chip-export:hover { filter: brightness(1.4); }
.portal-chips .chip .chip-close { color: var(--muted); }
.portal-chips .chip .chip-close:hover { color: var(--err); } .portal-chips .chip .chip-close:hover { color: var(--err); }
/* ─── Transcript ───────────────────────────────────────────────── */ /* ─── Transcript ───────────────────────────────────────────────── */

View file

@ -49,8 +49,22 @@ const state = {
nextRunId: 1, nextRunId: 1,
nextTabId: 1, nextTabId: 1,
saveTimer: null, saveTimer: null,
// SharedArrayBuffer the tier's eval loop polls for a cooperative
// pause signal. C tier reads via Atomics in the JS-library import;
// Python via globals + sys.settrace. Browsers refuse the SAB
// constructor without COOP/COEP cross-origin-isolation headers,
// so this stays null on a plain http.server and the ⏸ pause
// button surfaces a helpful tooltip instead of crashing.
pauseFlag: (() => {
try { return new SharedArrayBuffer(4); } catch (e) { return null; }
})(),
pausing: false, // true while a pause-and-snapshot flow is mid-air
}; };
// Eager view over the pause flag so the button handler doesn't have to
// re-wrap on every click. Null when SAB isn't available.
const pauseFlagView = state.pauseFlag ? new Int32Array(state.pauseFlag) : null;
// ─── Vault ────────────────────────────────────────────────────────── // ─── Vault ──────────────────────────────────────────────────────────
async function saveSoon() { async function saveSoon() {
if (!state.vault) return; if (!state.vault) return;
@ -159,16 +173,138 @@ function closeTab(id) {
saveSoon(); saveSoon();
} }
function setActiveTab(id) { async function setActiveTab(id) {
if (id === state.activeTabId) return;
// Capture the outgoing tab's current input as its draft before we // Capture the outgoing tab's current input as its draft before we
// swap tabs — otherwise switching away from a half-written form and // swap tabs — otherwise switching away from a half-written form and
// back loses everything between the last input event and the next // back loses everything between the last input event and the next
// saveSoon flush. // saveSoon flush.
const out = activeTab(); const out = activeTab();
if (out) out.inputDraft = inputEl.value; if (out) {
out.inputDraft = inputEl.value;
// Auto-pause + portal-save when the user leaves a tab whose
// tier is mid-eval. Without this, switching tabs during a
// long search abandons the state silently. autoPauseTab snapshots
// the tier's env (when SAB is available — C tier only today),
// stashes the input that started the eval, and terminates the
// worker so the heap is released. autoResumeTab on the way
// back hydrates and re-fires the input.
if (isEvalInFlight(out)) {
await autoPauseTab(out);
}
}
state.activeTabId = id; state.activeTabId = id;
renderAll(); renderAll();
saveSoon(); saveSoon();
const incoming = activeTab();
if (incoming && incoming.autoPause) {
await autoResumeTab(incoming);
}
}
function isEvalInFlight(tab) {
for (const k of Object.keys(state.pending)) {
if (k.startsWith(tab.id + ":")) return true;
}
return false;
}
async function waitForPending(tabId, tier, timeoutMs) {
const key = workerKey(tabId, tier);
const start = performance.now();
while (state.pending[key] && (performance.now() - start) < timeoutMs) {
await new Promise((r) => setTimeout(r, 30));
}
}
// Auto-pause hook for tab-switch-away. The tier may be deep inside a
// (let loop ...) — set the SAB pause atomic, wait for the eval-loop
// poll to raise lisp_error("paused"), then snapshot env via the
// regular portal-save path so the state survives until the user
// switches back. Without SAB we still capture the in-flight input
// so the resume can re-eval it cold.
async function autoPauseTab(tab) {
const tier = tab.tier === "all" ? "c" : tab.tier;
const activeInput = tab.activeInput || null;
let blob = null;
if (pauseFlagView && tier === "c") {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested
// and unwinds via lisp_error("paused"). Python tier needs a
// settrace-based hook before it can join (deferred). Asm tier
// has no in-eval poll site — falls through to hard cancel.
// Signal the eval loop and wait for it to settle.
Atomics.store(pauseFlagView, 0, 1);
await waitForPending(tab.id, tier, 5000);
Atomics.store(pauseFlagView, 0, 0);
// Snapshot the (now-idle) tier's env to /tmp and pull the
// blob across the worker bridge. portal-snapshot! is the
// same lisp the manual save flow uses.
const snapName = `_autopause_t${tab.id}`;
try {
await evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`);
const w = state.workers[workerKey(tab.id, tier)];
if (w) {
const runId = state.nextRunId++;
const reply = await bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save");
if (reply && reply.blob) blob = reply.blob;
}
} catch (e) { /* no blob — resume will re-eval from scratch */ }
} else if (isEvalInFlight(tab)) {
// No SAB or asm tier — hard-cancel the in-flight eval(s).
for (const k of Object.keys(state.pending)) {
if (!k.startsWith(tab.id + ":")) continue;
try { state.pending[k].reject(new Error("tab paused")); } catch {}
delete state.pending[k];
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
if (activeInput) {
tab.autoPause = { tier, blob, inputSrc: activeInput, savedAt: Date.now() };
}
// Terminate the worker(s) so the heap is reclaimed while the tab
// is dormant. autoResumeTab spawns a fresh one.
for (const k of Object.keys(state.workers)) {
if (!k.startsWith(tab.id + ":")) continue;
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
async function autoResumeTab(tab) {
const ap = tab.autoPause;
if (!ap) return;
tab.autoPause = null; // consume up-front so a re-pause picks fresh
tab.tier = ap.tier;
tierSelectEl.value = ap.tier;
if (ap.blob) {
rebootTier(tab.id, ap.tier);
// Round-trip through the tier so portal-load! has the blob
// to read. The bare 'init eval forces the tier to bootstrap
// before we push the blob into MEMFS.
try { await evalInTier(tab.id, ap.tier, "'init"); } catch (e) { /* harmless */ }
const w = state.workers[workerKey(tab.id, ap.tier)];
if (w) {
const snapName = `_autopause_t${tab.id}`;
const runId = state.nextRunId++;
await bridgeWorker(w, { kind: "portal-load", runId, tier: ap.tier, name: snapName, blob: ap.blob }, "portal-load");
try { await evalInTier(tab.id, ap.tier, `(portal-load! "${snapName}")`); }
catch (e) { /* surface as resume failure but keep going */ }
}
}
// Re-run the original input. The transcript already shows the
// prior partial run; this appears as a new entry so the user
// sees the resumption explicitly.
inputEl.value = ap.inputSrc;
autosizeInput();
await sendInput();
} }
function activeTab() { function activeTab() {
@ -181,6 +317,11 @@ function workerKey(tabId, tier) { return `${tabId}:${tier}`; }
function spawnWorker(tabId, tier) { function spawnWorker(tabId, tier) {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" }); const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
state.workers[workerKey(tabId, tier)] = w; state.workers[workerKey(tabId, tier)] = w;
// Hand the worker its pause flag. Worker stashes the SAB so the
// tier loader can install an Int32Array view when it boots. We
// skip bendUrl here so the worker's previous bend setting (if
// any) isn't clobbered to null.
w.postMessage({ kind: "config", pauseFlag: state.pauseFlag });
return w; return w;
} }
@ -624,10 +765,13 @@ function renderAll() {
// tab A into tab B. The label gains a "· tabName" suffix in // tab A into tab B. The label gains a "· tabName" suffix in
// global mode so it's clear where each chip came from. // global mode so it's clear where each chip came from.
portalChipsEl.innerHTML = ""; portalChipsEl.innerHTML = "";
portalScopeBtn.textContent = showGlobalCheckpoints ? "🌐 all tabs" : "📂 this tab"; // Always lead with "scope:" so the visible text reads as a state
// label rather than a target ("this tab" alone was ambiguous —
// is it telling me the current scope, or what I'd switch to?).
portalScopeBtn.textContent = showGlobalCheckpoints ? "scope: all tabs 🌐" : "scope: this tab 📂";
portalScopeBtn.title = showGlobalCheckpoints portalScopeBtn.title = showGlobalCheckpoints
? "showing checkpoints from every tab — click for this tab only" ? "showing checkpoints from every tab — click to show only this tab"
: "showing only this tab's checkpoints — click for every tab"; : "showing only this tab's checkpoints — click to show every tab";
const chipRows = []; const chipRows = [];
if (showGlobalCheckpoints) { if (showGlobalCheckpoints) {
for (const t of state.tabs) { for (const t of state.tabs) {
@ -801,6 +945,9 @@ async function sendInput() {
tab.transcript.push(entry); tab.transcript.push(entry);
inputEl.value = ""; inputEl.value = "";
tab.inputDraft = ""; tab.inputDraft = "";
// Remember the input string while it's evaluating so that auto-
// pause-on-tab-switch can re-fire it on resume. Cleared on completion.
tab.activeInput = src;
autosizeInput(); autosizeInput();
resetHistory(); resetHistory();
sendBtn.disabled = true; sendBtn.disabled = true;
@ -866,6 +1013,10 @@ async function sendInput() {
sendBtn.disabled = false; sendBtn.disabled = false;
cancelBtn.disabled = true; cancelBtn.disabled = true;
inputEl.focus(); inputEl.focus();
// Eval finished cleanly — drop the activeInput marker so
// a subsequent tab switch doesn't try to resume something
// that already completed.
tab.activeInput = null;
} }
}); });
} }

View file

@ -8,7 +8,7 @@ import { createCTier } from "./c/lumbda-c.loader.js";
import { createAsmTier } from "./asm/lumbda-asm.loader.js"; import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {}; const cache = {};
const config = { bendUrl: null }; const config = { bendUrl: null, pauseFlag: null };
export function setBendUrl(url) { export function setBendUrl(url) {
config.bendUrl = url || null; config.bendUrl = url || null;
@ -18,6 +18,19 @@ export function setBendUrl(url) {
} }
} }
// 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) { export async function getTier(name, onLoad) {
if (cache[name]) return cache[name]; if (cache[name]) return cache[name];
if (onLoad) onLoad(name); if (onLoad) onLoad(name);
@ -26,6 +39,7 @@ export async function getTier(name, onLoad) {
else if (name === "asm") cache[name] = await createAsmTier(); else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`); else throw new Error(`unknown tier: ${name}`);
if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl); if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl);
if (cache[name].setPauseFlag) cache[name].setPauseFlag(config.pauseFlag);
return cache[name]; return cache[name];
} }

View file

@ -3,12 +3,17 @@
// live ms counter actually ticks AND so cancel works (main thread // live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()). // terminates this worker via worker.terminate()).
import { evalOnTier, setBendUrl, heapStats, portalSave, portalLoad } from "./runner.js"; import { evalOnTier, setBendUrl, setPauseFlag, heapStats, portalSave, portalLoad } from "./runner.js";
self.onmessage = async (e) => { self.onmessage = async (e) => {
const { kind } = e.data; const { kind } = e.data;
if (kind === "config") { if (kind === "config") {
setBendUrl(e.data.bendUrl); // 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; return;
} }
if (kind === "heap") { if (kind === "heap") {

View file

@ -91,7 +91,7 @@
<span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span> <span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span>
<button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button> <button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button>
<button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button> <button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button>
<button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">📂 this tab</button> <button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">scope: this tab 📂</button>
<input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden> <input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden>
<div id="portal-chips" class="portal-chips"></div> <div id="portal-chips" class="portal-chips"></div>
</section> </section>

View file

@ -233,12 +233,17 @@ body.repl {
} }
.portal-chips .chip .chip-export, .portal-chips .chip .chip-export,
.portal-chips .chip .chip-close { .portal-chips .chip .chip-close {
color: var(--muted); /* Bumped from 0.85em + muted because fox couldn't find the export
font-size: 0.85em; * icon on the chip. Now sized at parity with the label and tinted
* with the chip's accent so the action affordance reads as a
* sibling of the name, not a footnote. */
font-size: 1em;
cursor: pointer; cursor: pointer;
padding: 0 0.15rem; padding: 0 0.15rem;
} }
.portal-chips .chip .chip-export:hover { color: var(--green); } .portal-chips .chip .chip-export { color: var(--green); }
.portal-chips .chip .chip-export:hover { filter: brightness(1.4); }
.portal-chips .chip .chip-close { color: var(--muted); }
.portal-chips .chip .chip-close:hover { color: var(--err); } .portal-chips .chip .chip-close:hover { color: var(--err); }
/* ─── Transcript ───────────────────────────────────────────────── */ /* ─── Transcript ───────────────────────────────────────────────── */

View file

@ -49,8 +49,22 @@ const state = {
nextRunId: 1, nextRunId: 1,
nextTabId: 1, nextTabId: 1,
saveTimer: null, saveTimer: null,
// SharedArrayBuffer the tier's eval loop polls for a cooperative
// pause signal. C tier reads via Atomics in the JS-library import;
// Python via globals + sys.settrace. Browsers refuse the SAB
// constructor without COOP/COEP cross-origin-isolation headers,
// so this stays null on a plain http.server and the ⏸ pause
// button surfaces a helpful tooltip instead of crashing.
pauseFlag: (() => {
try { return new SharedArrayBuffer(4); } catch (e) { return null; }
})(),
pausing: false, // true while a pause-and-snapshot flow is mid-air
}; };
// Eager view over the pause flag so the button handler doesn't have to
// re-wrap on every click. Null when SAB isn't available.
const pauseFlagView = state.pauseFlag ? new Int32Array(state.pauseFlag) : null;
// ─── Vault ────────────────────────────────────────────────────────── // ─── Vault ──────────────────────────────────────────────────────────
async function saveSoon() { async function saveSoon() {
if (!state.vault) return; if (!state.vault) return;
@ -159,16 +173,138 @@ function closeTab(id) {
saveSoon(); saveSoon();
} }
function setActiveTab(id) { async function setActiveTab(id) {
if (id === state.activeTabId) return;
// Capture the outgoing tab's current input as its draft before we // Capture the outgoing tab's current input as its draft before we
// swap tabs — otherwise switching away from a half-written form and // swap tabs — otherwise switching away from a half-written form and
// back loses everything between the last input event and the next // back loses everything between the last input event and the next
// saveSoon flush. // saveSoon flush.
const out = activeTab(); const out = activeTab();
if (out) out.inputDraft = inputEl.value; if (out) {
out.inputDraft = inputEl.value;
// Auto-pause + portal-save when the user leaves a tab whose
// tier is mid-eval. Without this, switching tabs during a
// long search abandons the state silently. autoPauseTab snapshots
// the tier's env (when SAB is available — C tier only today),
// stashes the input that started the eval, and terminates the
// worker so the heap is released. autoResumeTab on the way
// back hydrates and re-fires the input.
if (isEvalInFlight(out)) {
await autoPauseTab(out);
}
}
state.activeTabId = id; state.activeTabId = id;
renderAll(); renderAll();
saveSoon(); saveSoon();
const incoming = activeTab();
if (incoming && incoming.autoPause) {
await autoResumeTab(incoming);
}
}
function isEvalInFlight(tab) {
for (const k of Object.keys(state.pending)) {
if (k.startsWith(tab.id + ":")) return true;
}
return false;
}
async function waitForPending(tabId, tier, timeoutMs) {
const key = workerKey(tabId, tier);
const start = performance.now();
while (state.pending[key] && (performance.now() - start) < timeoutMs) {
await new Promise((r) => setTimeout(r, 30));
}
}
// Auto-pause hook for tab-switch-away. The tier may be deep inside a
// (let loop ...) — set the SAB pause atomic, wait for the eval-loop
// poll to raise lisp_error("paused"), then snapshot env via the
// regular portal-save path so the state survives until the user
// switches back. Without SAB we still capture the in-flight input
// so the resume can re-eval it cold.
async function autoPauseTab(tab) {
const tier = tab.tier === "all" ? "c" : tab.tier;
const activeInput = tab.activeInput || null;
let blob = null;
if (pauseFlagView && tier === "c") {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested
// and unwinds via lisp_error("paused"). Python tier needs a
// settrace-based hook before it can join (deferred). Asm tier
// has no in-eval poll site — falls through to hard cancel.
// Signal the eval loop and wait for it to settle.
Atomics.store(pauseFlagView, 0, 1);
await waitForPending(tab.id, tier, 5000);
Atomics.store(pauseFlagView, 0, 0);
// Snapshot the (now-idle) tier's env to /tmp and pull the
// blob across the worker bridge. portal-snapshot! is the
// same lisp the manual save flow uses.
const snapName = `_autopause_t${tab.id}`;
try {
await evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`);
const w = state.workers[workerKey(tab.id, tier)];
if (w) {
const runId = state.nextRunId++;
const reply = await bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save");
if (reply && reply.blob) blob = reply.blob;
}
} catch (e) { /* no blob — resume will re-eval from scratch */ }
} else if (isEvalInFlight(tab)) {
// No SAB or asm tier — hard-cancel the in-flight eval(s).
for (const k of Object.keys(state.pending)) {
if (!k.startsWith(tab.id + ":")) continue;
try { state.pending[k].reject(new Error("tab paused")); } catch {}
delete state.pending[k];
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
if (activeInput) {
tab.autoPause = { tier, blob, inputSrc: activeInput, savedAt: Date.now() };
}
// Terminate the worker(s) so the heap is reclaimed while the tab
// is dormant. autoResumeTab spawns a fresh one.
for (const k of Object.keys(state.workers)) {
if (!k.startsWith(tab.id + ":")) continue;
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
async function autoResumeTab(tab) {
const ap = tab.autoPause;
if (!ap) return;
tab.autoPause = null; // consume up-front so a re-pause picks fresh
tab.tier = ap.tier;
tierSelectEl.value = ap.tier;
if (ap.blob) {
rebootTier(tab.id, ap.tier);
// Round-trip through the tier so portal-load! has the blob
// to read. The bare 'init eval forces the tier to bootstrap
// before we push the blob into MEMFS.
try { await evalInTier(tab.id, ap.tier, "'init"); } catch (e) { /* harmless */ }
const w = state.workers[workerKey(tab.id, ap.tier)];
if (w) {
const snapName = `_autopause_t${tab.id}`;
const runId = state.nextRunId++;
await bridgeWorker(w, { kind: "portal-load", runId, tier: ap.tier, name: snapName, blob: ap.blob }, "portal-load");
try { await evalInTier(tab.id, ap.tier, `(portal-load! "${snapName}")`); }
catch (e) { /* surface as resume failure but keep going */ }
}
}
// Re-run the original input. The transcript already shows the
// prior partial run; this appears as a new entry so the user
// sees the resumption explicitly.
inputEl.value = ap.inputSrc;
autosizeInput();
await sendInput();
} }
function activeTab() { function activeTab() {
@ -181,6 +317,11 @@ function workerKey(tabId, tier) { return `${tabId}:${tier}`; }
function spawnWorker(tabId, tier) { function spawnWorker(tabId, tier) {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" }); const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
state.workers[workerKey(tabId, tier)] = w; state.workers[workerKey(tabId, tier)] = w;
// Hand the worker its pause flag. Worker stashes the SAB so the
// tier loader can install an Int32Array view when it boots. We
// skip bendUrl here so the worker's previous bend setting (if
// any) isn't clobbered to null.
w.postMessage({ kind: "config", pauseFlag: state.pauseFlag });
return w; return w;
} }
@ -624,10 +765,13 @@ function renderAll() {
// tab A into tab B. The label gains a "· tabName" suffix in // tab A into tab B. The label gains a "· tabName" suffix in
// global mode so it's clear where each chip came from. // global mode so it's clear where each chip came from.
portalChipsEl.innerHTML = ""; portalChipsEl.innerHTML = "";
portalScopeBtn.textContent = showGlobalCheckpoints ? "🌐 all tabs" : "📂 this tab"; // Always lead with "scope:" so the visible text reads as a state
// label rather than a target ("this tab" alone was ambiguous —
// is it telling me the current scope, or what I'd switch to?).
portalScopeBtn.textContent = showGlobalCheckpoints ? "scope: all tabs 🌐" : "scope: this tab 📂";
portalScopeBtn.title = showGlobalCheckpoints portalScopeBtn.title = showGlobalCheckpoints
? "showing checkpoints from every tab — click for this tab only" ? "showing checkpoints from every tab — click to show only this tab"
: "showing only this tab's checkpoints — click for every tab"; : "showing only this tab's checkpoints — click to show every tab";
const chipRows = []; const chipRows = [];
if (showGlobalCheckpoints) { if (showGlobalCheckpoints) {
for (const t of state.tabs) { for (const t of state.tabs) {
@ -801,6 +945,9 @@ async function sendInput() {
tab.transcript.push(entry); tab.transcript.push(entry);
inputEl.value = ""; inputEl.value = "";
tab.inputDraft = ""; tab.inputDraft = "";
// Remember the input string while it's evaluating so that auto-
// pause-on-tab-switch can re-fire it on resume. Cleared on completion.
tab.activeInput = src;
autosizeInput(); autosizeInput();
resetHistory(); resetHistory();
sendBtn.disabled = true; sendBtn.disabled = true;
@ -866,6 +1013,10 @@ async function sendInput() {
sendBtn.disabled = false; sendBtn.disabled = false;
cancelBtn.disabled = true; cancelBtn.disabled = true;
inputEl.focus(); inputEl.focus();
// Eval finished cleanly — drop the activeInput marker so
// a subsequent tab switch doesn't try to resume something
// that already completed.
tab.activeInput = null;
} }
}); });
} }

44
wasm/serve-coop.py Normal file
View file

@ -0,0 +1,44 @@
#!/usr/bin/env python3
# Dev server for the REPL that injects the COOP/COEP response headers
# required to unlock SharedArrayBuffer in the browser.
#
# Why: the REPL's pause-and-snapshot feature signals the eval loop via a
# shared-memory atomic flag. Without these headers, the browser refuses
# to create a SharedArrayBuffer at all, so the worker has no channel to
# receive a pause request while it's executing a long-running tier
# eval.
#
# Production parity: any Caddy/nginx in front of lumbda.com needs to
# emit the same two headers for the same reason. This script is the
# minimum dev-side reproduction of that environment.
#
# Usage:
# python3 serve-coop.py [port] [dir]
#
# Defaults: port 8090, dir = current working directory.
import http.server
import socketserver
import sys
import os
class CoopCoepHandler(http.server.SimpleHTTPRequestHandler):
def end_headers(self):
self.send_header("Cross-Origin-Opener-Policy", "same-origin")
self.send_header("Cross-Origin-Embedder-Policy", "require-corp")
self.send_header("Cross-Origin-Resource-Policy", "same-origin")
super().end_headers()
def main():
port = int(sys.argv[1]) if len(sys.argv) > 1 else 8090
serve_dir = sys.argv[2] if len(sys.argv) > 2 else "."
os.chdir(serve_dir)
with socketserver.TCPServer(("", port), CoopCoepHandler) as httpd:
print(f"COOP/COEP server on http://localhost:{port}/ (dir: {serve_dir})")
httpd.serve_forever()
if __name__ == "__main__":
main()

File diff suppressed because one or more lines are too long

View file

@ -61,6 +61,14 @@ async function _bootstrap() {
// global so runner.js can propagate a saved playground URL. // global so runner.js can propagate a saved playground URL.
return { return {
setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; }, setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; },
// Pause hook — js_lumbda_pause_requested (the JS-library import
// wired in bend-call-library.js) reads globalThis._lumbdaCPauseFlag
// every K leval iterations. Setting it to an Int32Array view
// over a SharedArrayBuffer lets the REPL signal a pause from
// the main thread. Null disables (no COOP/COEP isolation).
setPauseFlag(flag) {
globalThis._lumbdaCPauseFlag = flag ? new Int32Array(flag) : null;
},
// portalSave / portalLoad bridge MEMFS to the JS side so the // portalSave / portalLoad bridge MEMFS to the JS side so the
// REPL can persist checkpoints to the encrypted vault and // REPL can persist checkpoints to the encrypted vault and
// restore them on a fresh worker. Names come from the worker // restore them on a fresh worker. Names come from the worker

Binary file not shown.

View file

@ -8,7 +8,7 @@ import { createCTier } from "./c/lumbda-c.loader.js";
import { createAsmTier } from "./asm/lumbda-asm.loader.js"; import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {}; const cache = {};
const config = { bendUrl: null }; const config = { bendUrl: null, pauseFlag: null };
export function setBendUrl(url) { export function setBendUrl(url) {
config.bendUrl = url || null; config.bendUrl = url || null;
@ -18,6 +18,19 @@ export function setBendUrl(url) {
} }
} }
// 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) { export async function getTier(name, onLoad) {
if (cache[name]) return cache[name]; if (cache[name]) return cache[name];
if (onLoad) onLoad(name); if (onLoad) onLoad(name);
@ -26,6 +39,7 @@ export async function getTier(name, onLoad) {
else if (name === "asm") cache[name] = await createAsmTier(); else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`); else throw new Error(`unknown tier: ${name}`);
if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl); if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl);
if (cache[name].setPauseFlag) cache[name].setPauseFlag(config.pauseFlag);
return cache[name]; return cache[name];
} }

View file

@ -3,12 +3,17 @@
// live ms counter actually ticks AND so cancel works (main thread // live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()). // terminates this worker via worker.terminate()).
import { evalOnTier, setBendUrl, heapStats, portalSave, portalLoad } from "./runner.js"; import { evalOnTier, setBendUrl, setPauseFlag, heapStats, portalSave, portalLoad } from "./runner.js";
self.onmessage = async (e) => { self.onmessage = async (e) => {
const { kind } = e.data; const { kind } = e.data;
if (kind === "config") { if (kind === "config") {
setBendUrl(e.data.bendUrl); // 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; return;
} }
if (kind === "heap") { if (kind === "heap") {

File diff suppressed because one or more lines are too long

View file

@ -61,6 +61,14 @@ async function _bootstrap() {
// global so runner.js can propagate a saved playground URL. // global so runner.js can propagate a saved playground URL.
return { return {
setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; }, setBendUrl(url) { globalThis._lumbdaCBendUrl = url || null; },
// Pause hook — js_lumbda_pause_requested (the JS-library import
// wired in bend-call-library.js) reads globalThis._lumbdaCPauseFlag
// every K leval iterations. Setting it to an Int32Array view
// over a SharedArrayBuffer lets the REPL signal a pause from
// the main thread. Null disables (no COOP/COEP isolation).
setPauseFlag(flag) {
globalThis._lumbdaCPauseFlag = flag ? new Int32Array(flag) : null;
},
// portalSave / portalLoad bridge MEMFS to the JS side so the // portalSave / portalLoad bridge MEMFS to the JS side so the
// REPL can persist checkpoints to the encrypted vault and // REPL can persist checkpoints to the encrypted vault and
// restore them on a fresh worker. Names come from the worker // restore them on a fresh worker. Names come from the worker

Binary file not shown.

View file

@ -91,7 +91,7 @@
<span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span> <span class="portal-label" title="save the current tab's tier state to your encrypted vault. comes back exactly the same after a tab close + reopen.">portals:</span>
<button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button> <button id="portal-save" class="ghost" title="save tier state — names the snapshot by date+time">💾 save</button>
<button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button> <button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button>
<button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">📂 this tab</button> <button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">scope: this tab 📂</button>
<input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden> <input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden>
<div id="portal-chips" class="portal-chips"></div> <div id="portal-chips" class="portal-chips"></div>
</section> </section>

View file

@ -233,12 +233,17 @@ body.repl {
} }
.portal-chips .chip .chip-export, .portal-chips .chip .chip-export,
.portal-chips .chip .chip-close { .portal-chips .chip .chip-close {
color: var(--muted); /* Bumped from 0.85em + muted because fox couldn't find the export
font-size: 0.85em; * icon on the chip. Now sized at parity with the label and tinted
* with the chip's accent so the action affordance reads as a
* sibling of the name, not a footnote. */
font-size: 1em;
cursor: pointer; cursor: pointer;
padding: 0 0.15rem; padding: 0 0.15rem;
} }
.portal-chips .chip .chip-export:hover { color: var(--green); } .portal-chips .chip .chip-export { color: var(--green); }
.portal-chips .chip .chip-export:hover { filter: brightness(1.4); }
.portal-chips .chip .chip-close { color: var(--muted); }
.portal-chips .chip .chip-close:hover { color: var(--err); } .portal-chips .chip .chip-close:hover { color: var(--err); }
/* ─── Transcript ───────────────────────────────────────────────── */ /* ─── Transcript ───────────────────────────────────────────────── */

View file

@ -49,8 +49,22 @@ const state = {
nextRunId: 1, nextRunId: 1,
nextTabId: 1, nextTabId: 1,
saveTimer: null, saveTimer: null,
// SharedArrayBuffer the tier's eval loop polls for a cooperative
// pause signal. C tier reads via Atomics in the JS-library import;
// Python via globals + sys.settrace. Browsers refuse the SAB
// constructor without COOP/COEP cross-origin-isolation headers,
// so this stays null on a plain http.server and the ⏸ pause
// button surfaces a helpful tooltip instead of crashing.
pauseFlag: (() => {
try { return new SharedArrayBuffer(4); } catch (e) { return null; }
})(),
pausing: false, // true while a pause-and-snapshot flow is mid-air
}; };
// Eager view over the pause flag so the button handler doesn't have to
// re-wrap on every click. Null when SAB isn't available.
const pauseFlagView = state.pauseFlag ? new Int32Array(state.pauseFlag) : null;
// ─── Vault ────────────────────────────────────────────────────────── // ─── Vault ──────────────────────────────────────────────────────────
async function saveSoon() { async function saveSoon() {
if (!state.vault) return; if (!state.vault) return;
@ -159,16 +173,138 @@ function closeTab(id) {
saveSoon(); saveSoon();
} }
function setActiveTab(id) { async function setActiveTab(id) {
if (id === state.activeTabId) return;
// Capture the outgoing tab's current input as its draft before we // Capture the outgoing tab's current input as its draft before we
// swap tabs — otherwise switching away from a half-written form and // swap tabs — otherwise switching away from a half-written form and
// back loses everything between the last input event and the next // back loses everything between the last input event and the next
// saveSoon flush. // saveSoon flush.
const out = activeTab(); const out = activeTab();
if (out) out.inputDraft = inputEl.value; if (out) {
out.inputDraft = inputEl.value;
// Auto-pause + portal-save when the user leaves a tab whose
// tier is mid-eval. Without this, switching tabs during a
// long search abandons the state silently. autoPauseTab snapshots
// the tier's env (when SAB is available — C tier only today),
// stashes the input that started the eval, and terminates the
// worker so the heap is released. autoResumeTab on the way
// back hydrates and re-fires the input.
if (isEvalInFlight(out)) {
await autoPauseTab(out);
}
}
state.activeTabId = id; state.activeTabId = id;
renderAll(); renderAll();
saveSoon(); saveSoon();
const incoming = activeTab();
if (incoming && incoming.autoPause) {
await autoResumeTab(incoming);
}
}
function isEvalInFlight(tab) {
for (const k of Object.keys(state.pending)) {
if (k.startsWith(tab.id + ":")) return true;
}
return false;
}
async function waitForPending(tabId, tier, timeoutMs) {
const key = workerKey(tabId, tier);
const start = performance.now();
while (state.pending[key] && (performance.now() - start) < timeoutMs) {
await new Promise((r) => setTimeout(r, 30));
}
}
// Auto-pause hook for tab-switch-away. The tier may be deep inside a
// (let loop ...) — set the SAB pause atomic, wait for the eval-loop
// poll to raise lisp_error("paused"), then snapshot env via the
// regular portal-save path so the state survives until the user
// switches back. Without SAB we still capture the in-flight input
// so the resume can re-eval it cold.
async function autoPauseTab(tab) {
const tier = tab.tier === "all" ? "c" : tab.tier;
const activeInput = tab.activeInput || null;
let blob = null;
if (pauseFlagView && tier === "c") {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested
// and unwinds via lisp_error("paused"). Python tier needs a
// settrace-based hook before it can join (deferred). Asm tier
// has no in-eval poll site — falls through to hard cancel.
// Signal the eval loop and wait for it to settle.
Atomics.store(pauseFlagView, 0, 1);
await waitForPending(tab.id, tier, 5000);
Atomics.store(pauseFlagView, 0, 0);
// Snapshot the (now-idle) tier's env to /tmp and pull the
// blob across the worker bridge. portal-snapshot! is the
// same lisp the manual save flow uses.
const snapName = `_autopause_t${tab.id}`;
try {
await evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`);
const w = state.workers[workerKey(tab.id, tier)];
if (w) {
const runId = state.nextRunId++;
const reply = await bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save");
if (reply && reply.blob) blob = reply.blob;
}
} catch (e) { /* no blob — resume will re-eval from scratch */ }
} else if (isEvalInFlight(tab)) {
// No SAB or asm tier — hard-cancel the in-flight eval(s).
for (const k of Object.keys(state.pending)) {
if (!k.startsWith(tab.id + ":")) continue;
try { state.pending[k].reject(new Error("tab paused")); } catch {}
delete state.pending[k];
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
if (activeInput) {
tab.autoPause = { tier, blob, inputSrc: activeInput, savedAt: Date.now() };
}
// Terminate the worker(s) so the heap is reclaimed while the tab
// is dormant. autoResumeTab spawns a fresh one.
for (const k of Object.keys(state.workers)) {
if (!k.startsWith(tab.id + ":")) continue;
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
async function autoResumeTab(tab) {
const ap = tab.autoPause;
if (!ap) return;
tab.autoPause = null; // consume up-front so a re-pause picks fresh
tab.tier = ap.tier;
tierSelectEl.value = ap.tier;
if (ap.blob) {
rebootTier(tab.id, ap.tier);
// Round-trip through the tier so portal-load! has the blob
// to read. The bare 'init eval forces the tier to bootstrap
// before we push the blob into MEMFS.
try { await evalInTier(tab.id, ap.tier, "'init"); } catch (e) { /* harmless */ }
const w = state.workers[workerKey(tab.id, ap.tier)];
if (w) {
const snapName = `_autopause_t${tab.id}`;
const runId = state.nextRunId++;
await bridgeWorker(w, { kind: "portal-load", runId, tier: ap.tier, name: snapName, blob: ap.blob }, "portal-load");
try { await evalInTier(tab.id, ap.tier, `(portal-load! "${snapName}")`); }
catch (e) { /* surface as resume failure but keep going */ }
}
}
// Re-run the original input. The transcript already shows the
// prior partial run; this appears as a new entry so the user
// sees the resumption explicitly.
inputEl.value = ap.inputSrc;
autosizeInput();
await sendInput();
} }
function activeTab() { function activeTab() {
@ -181,6 +317,11 @@ function workerKey(tabId, tier) { return `${tabId}:${tier}`; }
function spawnWorker(tabId, tier) { function spawnWorker(tabId, tier) {
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" }); const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
state.workers[workerKey(tabId, tier)] = w; state.workers[workerKey(tabId, tier)] = w;
// Hand the worker its pause flag. Worker stashes the SAB so the
// tier loader can install an Int32Array view when it boots. We
// skip bendUrl here so the worker's previous bend setting (if
// any) isn't clobbered to null.
w.postMessage({ kind: "config", pauseFlag: state.pauseFlag });
return w; return w;
} }
@ -624,10 +765,13 @@ function renderAll() {
// tab A into tab B. The label gains a "· tabName" suffix in // tab A into tab B. The label gains a "· tabName" suffix in
// global mode so it's clear where each chip came from. // global mode so it's clear where each chip came from.
portalChipsEl.innerHTML = ""; portalChipsEl.innerHTML = "";
portalScopeBtn.textContent = showGlobalCheckpoints ? "🌐 all tabs" : "📂 this tab"; // Always lead with "scope:" so the visible text reads as a state
// label rather than a target ("this tab" alone was ambiguous —
// is it telling me the current scope, or what I'd switch to?).
portalScopeBtn.textContent = showGlobalCheckpoints ? "scope: all tabs 🌐" : "scope: this tab 📂";
portalScopeBtn.title = showGlobalCheckpoints portalScopeBtn.title = showGlobalCheckpoints
? "showing checkpoints from every tab — click for this tab only" ? "showing checkpoints from every tab — click to show only this tab"
: "showing only this tab's checkpoints — click for every tab"; : "showing only this tab's checkpoints — click to show every tab";
const chipRows = []; const chipRows = [];
if (showGlobalCheckpoints) { if (showGlobalCheckpoints) {
for (const t of state.tabs) { for (const t of state.tabs) {
@ -801,6 +945,9 @@ async function sendInput() {
tab.transcript.push(entry); tab.transcript.push(entry);
inputEl.value = ""; inputEl.value = "";
tab.inputDraft = ""; tab.inputDraft = "";
// Remember the input string while it's evaluating so that auto-
// pause-on-tab-switch can re-fire it on resume. Cleared on completion.
tab.activeInput = src;
autosizeInput(); autosizeInput();
resetHistory(); resetHistory();
sendBtn.disabled = true; sendBtn.disabled = true;
@ -866,6 +1013,10 @@ async function sendInput() {
sendBtn.disabled = false; sendBtn.disabled = false;
cancelBtn.disabled = true; cancelBtn.disabled = true;
inputEl.focus(); inputEl.focus();
// Eval finished cleanly — drop the activeInput marker so
// a subsequent tab switch doesn't try to resume something
// that already completed.
tab.activeInput = null;
} }
}); });
} }

View file

@ -8,7 +8,7 @@ import { createCTier } from "./c/lumbda-c.loader.js";
import { createAsmTier } from "./asm/lumbda-asm.loader.js"; import { createAsmTier } from "./asm/lumbda-asm.loader.js";
const cache = {}; const cache = {};
const config = { bendUrl: null }; const config = { bendUrl: null, pauseFlag: null };
export function setBendUrl(url) { export function setBendUrl(url) {
config.bendUrl = url || null; config.bendUrl = url || null;
@ -18,6 +18,19 @@ export function setBendUrl(url) {
} }
} }
// 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) { export async function getTier(name, onLoad) {
if (cache[name]) return cache[name]; if (cache[name]) return cache[name];
if (onLoad) onLoad(name); if (onLoad) onLoad(name);
@ -26,6 +39,7 @@ export async function getTier(name, onLoad) {
else if (name === "asm") cache[name] = await createAsmTier(); else if (name === "asm") cache[name] = await createAsmTier();
else throw new Error(`unknown tier: ${name}`); else throw new Error(`unknown tier: ${name}`);
if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl); if (cache[name].setBendUrl) cache[name].setBendUrl(config.bendUrl);
if (cache[name].setPauseFlag) cache[name].setPauseFlag(config.pauseFlag);
return cache[name]; return cache[name];
} }

View file

@ -3,12 +3,17 @@
// live ms counter actually ticks AND so cancel works (main thread // live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()). // terminates this worker via worker.terminate()).
import { evalOnTier, setBendUrl, heapStats, portalSave, portalLoad } from "./runner.js"; import { evalOnTier, setBendUrl, setPauseFlag, heapStats, portalSave, portalLoad } from "./runner.js";
self.onmessage = async (e) => { self.onmessage = async (e) => {
const { kind } = e.data; const { kind } = e.data;
if (kind === "config") { if (kind === "config") {
setBendUrl(e.data.bendUrl); // 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; return;
} }
if (kind === "heap") { if (kind === "heap") {