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:
parent
84bd7cac8c
commit
df154b9a56
31 changed files with 703 additions and 42 deletions
22
c/eval.c
22
c/eval.c
|
|
@ -664,8 +664,30 @@ Value syntax_transform_value(SyntaxTransformer *st, Value form) {
|
|||
* 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) {
|
||||
while (1) {
|
||||
lumbda_check_pause();
|
||||
/* Self-evaluating */
|
||||
if (IS_NIL(expr) || IS_VOID(expr) || IS_TRUE(expr) || IS_FALSE(expr) ||
|
||||
IS_EOF(expr) || IS_CHAR(expr)) return expr;
|
||||
|
|
|
|||
|
|
@ -216,6 +216,13 @@ serve: build
|
|||
@echo "Serving $(DIST) on http://localhost: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: build repl
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { createCTier } from "./c/lumbda-c.loader.js";
|
|||
import { createAsmTier } from "./asm/lumbda-asm.loader.js";
|
||||
|
||||
const cache = {};
|
||||
const config = { bendUrl: null };
|
||||
const config = { bendUrl: null, pauseFlag: null };
|
||||
|
||||
export function setBendUrl(url) {
|
||||
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) {
|
||||
if (cache[name]) return cache[name];
|
||||
if (onLoad) onLoad(name);
|
||||
|
|
@ -26,6 +39,7 @@ export async function getTier(name, onLoad) {
|
|||
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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,17 @@
|
|||
// 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";
|
||||
import { evalOnTier, setBendUrl, setPauseFlag, heapStats, portalSave, portalLoad } from "./runner.js";
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { kind } = e.data;
|
||||
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;
|
||||
}
|
||||
if (kind === "heap") {
|
||||
|
|
|
|||
|
|
@ -13,6 +13,18 @@
|
|||
// setBendUrl that mutates it).
|
||||
|
||||
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) {
|
||||
var bendUrl = globalThis._lumbdaCBendUrl;
|
||||
if (!bendUrl) {
|
||||
|
|
|
|||
|
|
@ -61,6 +61,14 @@ async function _bootstrap() {
|
|||
// global so runner.js can propagate a saved playground URL.
|
||||
return {
|
||||
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
|
||||
// REPL can persist checkpoints to the encrypted vault and
|
||||
// restore them on a fresh worker. Names come from the worker
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -61,6 +61,14 @@ async function _bootstrap() {
|
|||
// global so runner.js can propagate a saved playground URL.
|
||||
return {
|
||||
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
|
||||
// REPL can persist checkpoints to the encrypted vault and
|
||||
// restore them on a fresh worker. Names come from the worker
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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>
|
||||
<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-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>
|
||||
<div id="portal-chips" class="portal-chips"></div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -233,12 +233,17 @@ body.repl {
|
|||
}
|
||||
.portal-chips .chip .chip-export,
|
||||
.portal-chips .chip .chip-close {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
/* Bumped from 0.85em + muted because fox couldn't find the export
|
||||
* 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;
|
||||
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); }
|
||||
|
||||
/* ─── Transcript ───────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -49,8 +49,22 @@ const state = {
|
|||
nextRunId: 1,
|
||||
nextTabId: 1,
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
async function saveSoon() {
|
||||
if (!state.vault) return;
|
||||
|
|
@ -159,16 +173,138 @@ function closeTab(id) {
|
|||
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
|
||||
// swap tabs — otherwise switching away from a half-written form and
|
||||
// back loses everything between the last input event and the next
|
||||
// saveSoon flush.
|
||||
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;
|
||||
renderAll();
|
||||
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() {
|
||||
|
|
@ -181,6 +317,11 @@ function workerKey(tabId, tier) { return `${tabId}:${tier}`; }
|
|||
function spawnWorker(tabId, tier) {
|
||||
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -624,10 +765,13 @@ function renderAll() {
|
|||
// tab A into tab B. The label gains a "· tabName" suffix in
|
||||
// global mode so it's clear where each chip came from.
|
||||
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
|
||||
? "showing checkpoints from every tab — click for this tab only"
|
||||
: "showing only this tab's checkpoints — click for every tab";
|
||||
? "showing checkpoints from every tab — click to show only this tab"
|
||||
: "showing only this tab's checkpoints — click to show every tab";
|
||||
const chipRows = [];
|
||||
if (showGlobalCheckpoints) {
|
||||
for (const t of state.tabs) {
|
||||
|
|
@ -801,6 +945,9 @@ async function sendInput() {
|
|||
tab.transcript.push(entry);
|
||||
inputEl.value = "";
|
||||
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();
|
||||
resetHistory();
|
||||
sendBtn.disabled = true;
|
||||
|
|
@ -866,6 +1013,10 @@ async function sendInput() {
|
|||
sendBtn.disabled = false;
|
||||
cancelBtn.disabled = true;
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { createCTier } from "./c/lumbda-c.loader.js";
|
|||
import { createAsmTier } from "./asm/lumbda-asm.loader.js";
|
||||
|
||||
const cache = {};
|
||||
const config = { bendUrl: null };
|
||||
const config = { bendUrl: null, pauseFlag: null };
|
||||
|
||||
export function setBendUrl(url) {
|
||||
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) {
|
||||
if (cache[name]) return cache[name];
|
||||
if (onLoad) onLoad(name);
|
||||
|
|
@ -26,6 +39,7 @@ export async function getTier(name, onLoad) {
|
|||
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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,17 @@
|
|||
// 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";
|
||||
import { evalOnTier, setBendUrl, setPauseFlag, heapStats, portalSave, portalLoad } from "./runner.js";
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { kind } = e.data;
|
||||
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;
|
||||
}
|
||||
if (kind === "heap") {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
<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-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>
|
||||
<div id="portal-chips" class="portal-chips"></div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -233,12 +233,17 @@ body.repl {
|
|||
}
|
||||
.portal-chips .chip .chip-export,
|
||||
.portal-chips .chip .chip-close {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
/* Bumped from 0.85em + muted because fox couldn't find the export
|
||||
* 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;
|
||||
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); }
|
||||
|
||||
/* ─── Transcript ───────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -49,8 +49,22 @@ const state = {
|
|||
nextRunId: 1,
|
||||
nextTabId: 1,
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
async function saveSoon() {
|
||||
if (!state.vault) return;
|
||||
|
|
@ -159,16 +173,138 @@ function closeTab(id) {
|
|||
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
|
||||
// swap tabs — otherwise switching away from a half-written form and
|
||||
// back loses everything between the last input event and the next
|
||||
// saveSoon flush.
|
||||
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;
|
||||
renderAll();
|
||||
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() {
|
||||
|
|
@ -181,6 +317,11 @@ function workerKey(tabId, tier) { return `${tabId}:${tier}`; }
|
|||
function spawnWorker(tabId, tier) {
|
||||
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -624,10 +765,13 @@ function renderAll() {
|
|||
// tab A into tab B. The label gains a "· tabName" suffix in
|
||||
// global mode so it's clear where each chip came from.
|
||||
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
|
||||
? "showing checkpoints from every tab — click for this tab only"
|
||||
: "showing only this tab's checkpoints — click for every tab";
|
||||
? "showing checkpoints from every tab — click to show only this tab"
|
||||
: "showing only this tab's checkpoints — click to show every tab";
|
||||
const chipRows = [];
|
||||
if (showGlobalCheckpoints) {
|
||||
for (const t of state.tabs) {
|
||||
|
|
@ -801,6 +945,9 @@ async function sendInput() {
|
|||
tab.transcript.push(entry);
|
||||
inputEl.value = "";
|
||||
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();
|
||||
resetHistory();
|
||||
sendBtn.disabled = true;
|
||||
|
|
@ -866,6 +1013,10 @@ async function sendInput() {
|
|||
sendBtn.disabled = false;
|
||||
cancelBtn.disabled = true;
|
||||
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
44
wasm/serve-coop.py
Normal 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
|
|
@ -61,6 +61,14 @@ async function _bootstrap() {
|
|||
// global so runner.js can propagate a saved playground URL.
|
||||
return {
|
||||
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
|
||||
// REPL can persist checkpoints to the encrypted vault and
|
||||
// restore them on a fresh worker. Names come from the worker
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -8,7 +8,7 @@ import { createCTier } from "./c/lumbda-c.loader.js";
|
|||
import { createAsmTier } from "./asm/lumbda-asm.loader.js";
|
||||
|
||||
const cache = {};
|
||||
const config = { bendUrl: null };
|
||||
const config = { bendUrl: null, pauseFlag: null };
|
||||
|
||||
export function setBendUrl(url) {
|
||||
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) {
|
||||
if (cache[name]) return cache[name];
|
||||
if (onLoad) onLoad(name);
|
||||
|
|
@ -26,6 +39,7 @@ export async function getTier(name, onLoad) {
|
|||
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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,17 @@
|
|||
// 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";
|
||||
import { evalOnTier, setBendUrl, setPauseFlag, heapStats, portalSave, portalLoad } from "./runner.js";
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { kind } = e.data;
|
||||
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;
|
||||
}
|
||||
if (kind === "heap") {
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -61,6 +61,14 @@ async function _bootstrap() {
|
|||
// global so runner.js can propagate a saved playground URL.
|
||||
return {
|
||||
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
|
||||
// REPL can persist checkpoints to the encrypted vault and
|
||||
// restore them on a fresh worker. Names come from the worker
|
||||
|
|
|
|||
Binary file not shown.
|
|
@ -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>
|
||||
<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-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>
|
||||
<div id="portal-chips" class="portal-chips"></div>
|
||||
</section>
|
||||
|
|
|
|||
|
|
@ -233,12 +233,17 @@ body.repl {
|
|||
}
|
||||
.portal-chips .chip .chip-export,
|
||||
.portal-chips .chip .chip-close {
|
||||
color: var(--muted);
|
||||
font-size: 0.85em;
|
||||
/* Bumped from 0.85em + muted because fox couldn't find the export
|
||||
* 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;
|
||||
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); }
|
||||
|
||||
/* ─── Transcript ───────────────────────────────────────────────── */
|
||||
|
|
|
|||
161
www/repl/repl.js
161
www/repl/repl.js
|
|
@ -49,8 +49,22 @@ const state = {
|
|||
nextRunId: 1,
|
||||
nextTabId: 1,
|
||||
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 ──────────────────────────────────────────────────────────
|
||||
async function saveSoon() {
|
||||
if (!state.vault) return;
|
||||
|
|
@ -159,16 +173,138 @@ function closeTab(id) {
|
|||
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
|
||||
// swap tabs — otherwise switching away from a half-written form and
|
||||
// back loses everything between the last input event and the next
|
||||
// saveSoon flush.
|
||||
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;
|
||||
renderAll();
|
||||
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() {
|
||||
|
|
@ -181,6 +317,11 @@ function workerKey(tabId, tier) { return `${tabId}:${tier}`; }
|
|||
function spawnWorker(tabId, tier) {
|
||||
const w = new Worker(new URL("./worker.mjs", import.meta.url), { type: "module" });
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -624,10 +765,13 @@ function renderAll() {
|
|||
// tab A into tab B. The label gains a "· tabName" suffix in
|
||||
// global mode so it's clear where each chip came from.
|
||||
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
|
||||
? "showing checkpoints from every tab — click for this tab only"
|
||||
: "showing only this tab's checkpoints — click for every tab";
|
||||
? "showing checkpoints from every tab — click to show only this tab"
|
||||
: "showing only this tab's checkpoints — click to show every tab";
|
||||
const chipRows = [];
|
||||
if (showGlobalCheckpoints) {
|
||||
for (const t of state.tabs) {
|
||||
|
|
@ -801,6 +945,9 @@ async function sendInput() {
|
|||
tab.transcript.push(entry);
|
||||
inputEl.value = "";
|
||||
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();
|
||||
resetHistory();
|
||||
sendBtn.disabled = true;
|
||||
|
|
@ -866,6 +1013,10 @@ async function sendInput() {
|
|||
sendBtn.disabled = false;
|
||||
cancelBtn.disabled = true;
|
||||
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;
|
||||
}
|
||||
});
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,7 +8,7 @@ import { createCTier } from "./c/lumbda-c.loader.js";
|
|||
import { createAsmTier } from "./asm/lumbda-asm.loader.js";
|
||||
|
||||
const cache = {};
|
||||
const config = { bendUrl: null };
|
||||
const config = { bendUrl: null, pauseFlag: null };
|
||||
|
||||
export function setBendUrl(url) {
|
||||
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) {
|
||||
if (cache[name]) return cache[name];
|
||||
if (onLoad) onLoad(name);
|
||||
|
|
@ -26,6 +39,7 @@ export async function getTier(name, onLoad) {
|
|||
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];
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -3,12 +3,17 @@
|
|||
// 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";
|
||||
import { evalOnTier, setBendUrl, setPauseFlag, heapStats, portalSave, portalLoad } from "./runner.js";
|
||||
|
||||
self.onmessage = async (e) => {
|
||||
const { kind } = e.data;
|
||||
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;
|
||||
}
|
||||
if (kind === "heap") {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue