lumbda/wasm/repl/repl.js
russell@unturf.com 2ed5be124c
repl: timeout the auto-pause snapshot so a non-yielding worker dies
If the C/Python eval was stuck in code without a poll site (sync XHR
inside bend!-call, a builtin that doesn't pass through leval), the
SAB pause flag would set, waitForPending would time out after 5s,
and then portal-snapshot would queue behind the still-busy worker
and never resolve. autoPauseTab hung on that await and never
reached its terminate loop — leaving the worker running while the
user sat on a new tab watching the old loop keep streaming.

Now:
* SAB pause wait drops from 5s to 2s.
* If the eval is still in flight after the pause wait, skip the
  snapshot attempt entirely — the worker isn't yielding and the
  snapshot would just hang the UI.
* Snapshot eval + portal-save bridge are wrapped in withTimeout
  (2s + 1.5s) so even if pause partially worked but snapshot
  stalled, we fall through to the unconditional worker.terminate
  at the bottom of autoPauseTab.
* The hard-cancel branch and the SAB-timeout-fallback now share the
  same pending-rejection loop instead of duplicating; the
  unconditional terminate loop at the end kills the worker.

pauseAndSnapshot (manual ⏸ button) also surfaces the timeout case
with an alert pointing at cancel + reboot instead of locking the UI.
2026-06-15 13:47:20 -04:00

1265 lines
53 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// wasm/repl/repl.js
// Lumbda interactive REPL with multi-tab sessions + encrypted persistence.
import { openVault } from "./crypto.js";
const TIER_LABEL = {
python: "python (pyodide)",
c: "c (emcc)",
asm: "asm (wat)",
};
// ─── DOM ────────────────────────────────────────────────────────────
const lockScreen = document.getElementById("lock-screen");
const passwordEl = document.getElementById("password");
const unlockBtn = document.getElementById("unlock");
const freshBtn = document.getElementById("fresh");
const lockNote = document.getElementById("lock-note");
const tabsEl = document.getElementById("tabs");
const newTabBtn = document.getElementById("new-tab");
const resetTierBtn = document.getElementById("reset-tier");
const clearLogBtn = document.getElementById("clear-log");
const cancelBtn = document.getElementById("cancel");
const pauseBtn = document.getElementById("pause");
const lockBtn = document.getElementById("lock");
const replStream = document.getElementById("repl-stream");
const transcriptEl = document.getElementById("transcript");
const inputEl = document.getElementById("input");
const tierSelectEl = document.getElementById("tier-select");
const sendBtn = document.getElementById("send");
const portalBarEl = document.getElementById("portal-bar");
const portalSaveBtn = document.getElementById("portal-save");
const portalImportBtn = document.getElementById("portal-import");
const portalImportFileEl = document.getElementById("portal-import-file");
const portalScopeBtn = document.getElementById("portal-scope");
const portalChipsEl = document.getElementById("portal-chips");
// Whether the chip strip shows every tab's checkpoints (true) or just
// the active tab's (false). UI-only — never persisted, so a fresh
// session always starts in the focused per-tab view.
let showGlobalCheckpoints = false;
// ─── State ──────────────────────────────────────────────────────────
const state = {
vault: null, // { read, write, destroy, vaultId } or null (ephemeral)
tabs: [], // [{ id, name, tier, transcript: [{kind, text, results?}] }]
activeTabId: null,
workers: {}, // { [tabId+":"+tier]: Worker }
pending: {}, // same key → { runId, reject }
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;
if (state.saveTimer) clearTimeout(state.saveTimer);
state.saveTimer = setTimeout(async () => {
try {
await state.vault.write({
tabs: state.tabs,
activeTabId: state.activeTabId,
savedAt: Date.now(),
});
} catch (e) {
console.error("vault write failed:", e);
}
}, 400);
}
async function tryUnlock() {
const pw = passwordEl.value;
if (!pw) { lockNote.textContent = "password required (or click ephemeral)"; return; }
lockNote.textContent = "";
try {
state.vault = await openVault(pw);
const data = await state.vault.read();
if (data && data.__decryptionFailed) {
// Wrong password for an existing vault under this id.
lockNote.textContent = "vault exists but password is wrong";
state.vault = null;
return;
}
if (data && Array.isArray(data.tabs) && data.tabs.length > 0) {
state.tabs = data.tabs;
state.activeTabId = data.activeTabId || data.tabs[0].id;
state.nextTabId = Math.max(...state.tabs.map((t) => t.id)) + 1;
} else {
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
}
enterRepl();
} catch (e) {
lockNote.textContent = "unlock failed: " + e.message;
}
}
function enterEphemeral() {
state.vault = null;
state.tabs = [];
state.activeTabId = null;
state.nextTabId = 1;
enterRepl();
}
function enterRepl() {
lockScreen.hidden = true;
replStream.hidden = false;
if (state.tabs.length === 0) newTab();
else renderAll();
inputEl.focus();
}
function relock() {
// Terminate all workers, clear in-memory state, return to lock screen.
for (const k of Object.keys(state.workers)) {
try { state.workers[k].terminate(); } catch {}
}
state.workers = {};
state.pending = {};
state.vault = null;
state.tabs = [];
state.activeTabId = null;
transcriptEl.innerHTML = "";
tabsEl.innerHTML = "";
replStream.hidden = true;
lockScreen.hidden = false;
passwordEl.value = "";
passwordEl.focus();
}
// ─── Tabs ───────────────────────────────────────────────────────────
async function newTab(name) {
const id = state.nextTabId++;
name = name || `session ${id}`;
state.tabs.push({ id, name, tier: "c", transcript: [], inputDraft: "" });
// Route through setActiveTab so the outgoing tab's auto-pause
// fires and the toolbar buttons sync to the freshly-created
// (idle) tab. Without this, opening a new tab during an eval
// leaves the send button disabled and the eval orphaned.
await setActiveTab(id);
}
function closeTab(id) {
const idx = state.tabs.findIndex((t) => t.id === id);
if (idx < 0) return;
// Terminate this tab's workers.
for (const k of Object.keys(state.workers)) {
if (k.startsWith(id + ":")) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
}
state.tabs.splice(idx, 1);
if (state.activeTabId === id) {
state.activeTabId = state.tabs.length ? state.tabs[Math.max(0, idx - 1)].id : null;
}
if (state.tabs.length === 0) newTab();
else renderAll();
saveSoon();
}
async function setActiveTab(id) {
if (id === state.activeTabId) return;
// Re-entry guard: rapid clicks on tab labels can fire setActiveTab
// before the previous autoPause/autoResume cycle finishes. Without
// this, two pause flows race on the same outgoing tab and the
// second one captures torn state. Drop the later click silently
// (cheaper than queueing — the user can click again once they
// see the active tab settle).
if (state.switching) return;
state.switching = true;
try {
// 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;
// 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 env (SAB-poll path for C/python,
// transcript replay for asm), 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();
// Sync the global toolbar button states to the incoming tab —
// send/cancel/pause are not tab-scoped DOM elements, so a
// switch-away during one tab's eval would otherwise leave the
// newly-active (idle) tab with send disabled. The active tab
// is idle iff nothing in state.pending matches its id.
const incoming = activeTab();
if (incoming) {
const evalRunning = isEvalInFlight(incoming);
sendBtn.disabled = evalRunning;
cancelBtn.disabled = !evalRunning;
pauseBtn.disabled = !evalRunning || !pauseFlagView;
}
saveSoon();
if (incoming && incoming.autoPause) {
await autoResumeTab(incoming);
}
} finally {
state.switching = false;
}
}
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));
}
}
// Race a promise against a wall-clock timeout. Returns null when the
// promise didn't settle in time so the caller can move on rather than
// hang. Used in autoPauseTab so a worker that won't yield to the
// pause flag (e.g. stuck in sync XHR, or eval-loop poll never fires
// because we're outside the C poll site) can still be terminated.
function withTimeout(p, ms) {
return Promise.race([
p.catch(() => null),
new Promise((r) => setTimeout(() => r(null), ms)),
]);
}
// 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" || tier === "python")) {
// SAB-poll path: C tier's eval loop hooks js_lumbda_pause_requested,
// Python tier's lumbda.py polls _lumbdaPyPauseRequested. Both
// raise their own "paused" error (lisp_error / LispErr) so the
// module-global env survives intact. Asm tier has no in-eval
// poll site — falls through to hard cancel.
Atomics.store(pauseFlagView, 0, 1);
await waitForPending(tab.id, tier, 2000);
Atomics.store(pauseFlagView, 0, 0);
// If the eval is still in flight after 2s, the worker isn't
// yielding to the SAB flag (stuck in sync XHR, native builtin
// without a yield point, etc). Skip the snapshot attempt and
// drop straight to the unconditional terminate below — the
// user gets the cancel-and-replay fallback instead of a hang.
if (!isEvalInFlight(tab)) {
const snapName = `_autopause_t${tab.id}`;
try {
const snapOk = await withTimeout(
evalInTier(tab.id, tier, `(portal-snapshot! "${snapName}")`),
2000,
);
if (snapOk !== null) {
const w = state.workers[workerKey(tab.id, tier)];
if (w) {
const runId = state.nextRunId++;
const reply = await withTimeout(
bridgeWorker(w, { kind: "portal-save", runId, tier, name: snapName }, "portal-save"),
1500,
);
if (reply && reply.blob) blob = reply.blob;
}
}
} catch (e) { /* no blob — resume will re-eval from scratch */ }
}
}
if (isEvalInFlight(tab)) {
// SAB unavailable, asm tier, OR SAB pause timed out without
// the eval unwinding — hard-cancel the in-flight eval(s) so
// the unconditional terminate loop below actually kills them.
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];
}
}
if (activeInput) {
// Asm tier has no SAB poll site, so we can't snapshot env mid-eval.
// Fall back to replay mode: every successful prior input gets
// re-eval'd on resume to rebuild the env, then the active input
// is sent fresh. Same pattern the manual asm portal-save uses.
const replayInputs = (tier === "asm")
? tab.transcript.filter((e) => e.kind !== "error" && e.input !== activeInput).map((e) => e.input)
: null;
tab.autoPause = { tier, blob, replayInputs, 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;
rebootTier(tab.id, ap.tier);
if (ap.replayInputs && ap.replayInputs.length) {
// Asm tier replay path — re-eval every successful prior input
// in order so the env is reconstructed before sendInput fires
// the active one. Errors mid-replay are swallowed; restoring
// from a transcript that defined-on-error is the user's call.
for (const src of ap.replayInputs) {
try { await evalInTier(tab.id, ap.tier, src); } catch (e) { /* keep going */ }
}
tab.transcript.push({
input: `; resumed asm tier — replayed ${ap.replayInputs.length} prior inputs`,
results: [{ tier: ap.tier, output: "#t" }], kind: "ok",
});
} else if (ap.blob) {
// 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() {
return state.tabs.find((t) => t.id === state.activeTabId);
}
// ─── Workers ────────────────────────────────────────────────────────
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;
}
function ensureWorker(tabId, tier) {
const k = workerKey(tabId, tier);
if (!state.workers[k]) spawnWorker(tabId, tier);
return state.workers[k];
}
function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tabId, tier);
const runId = state.nextRunId++;
const pendingKey = workerKey(tabId, tier);
state.pending[pendingKey] = { runId, reject };
const handler = (e) => {
if (e.data.runId !== runId) return;
if (e.data.kind === "chunk-text") {
onChunkText && onChunkText(e.data.text);
} else if (e.data.kind === "chunk-eol") {
onChunkEol && onChunkEol();
} else if (e.data.kind === "done") {
w.removeEventListener("message", handler);
delete state.pending[pendingKey];
resolve(e.data.output);
} else if (e.data.kind === "error") {
w.removeEventListener("message", handler);
delete state.pending[pendingKey];
reject(new Error(e.data.message));
}
};
w.addEventListener("message", handler);
w.postMessage({ kind: "eval", runId, tier, src });
});
}
// Streaming live block — attached to a tier-result span after the
// initial renderAll so chunks arrive directly into the DOM without
// re-rendering the whole transcript (which would clobber sibling
// tiers still streaming). Same per-line-div pattern the playground
// uses: each chunk-eol closes a block-level div, sibling lines
// stack vertically regardless of <span>'s inline default.
function attachStreaming(resultSpan, metaSpan, tier) {
const makeLine = () => {
const d = document.createElement("div");
d.style.display = "block";
d.style.whiteSpace = "pre";
return d;
};
let pendingLine = makeLine();
let pendingText = "";
resultSpan.textContent = "";
resultSpan.appendChild(pendingLine);
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`;
return {
appendText(t) {
if (!t) return;
pendingText += t;
pendingLine.textContent = pendingText;
maybeAutoscroll();
},
appendNewline() {
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
resultSpan.appendChild(pendingLine);
maybeAutoscroll();
},
finalize(r) {
const text = r.output != null ? r.output : "";
const errText = r.error ? "error: " + r.error : "";
const streamedText = Array.from(resultSpan.children)
.map((d) => d.textContent === " " ? "" : d.textContent)
.join("\n");
const expected = text.replace(/\n$/, "");
// Preserving partial output on cancel/error: a long-running
// (display ...) loop that fox cancels half-way through, or
// an asm tier that hits "index out of bounds" 5000 lines
// into a sieve, used to lose every line that already
// streamed. Now:
// * success, streamed matches full output → keep DOM.
// * success, no streaming happened → rebuild from
// the final text.
// * error with prior streaming → KEEP the
// streamed divs, append a final divider + error line.
// * error with no streaming → show error.
if (!errText) {
if (streamedText !== expected) {
resultSpan.textContent = "";
const lines = expected.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
resultSpan.appendChild(d);
}
}
} else if (streamedText) {
// Append the error AFTER what's already on screen so
// the user keeps every line they were watching scroll.
const d = makeLine();
d.textContent = errText;
d.classList.add("err-line");
resultSpan.appendChild(d);
} else {
resultSpan.textContent = "";
const d = makeLine();
d.textContent = errText;
d.classList.add("err-line");
resultSpan.appendChild(d);
}
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · ${(r.elapsed | 0)}ms`;
},
};
}
// ─── Portal save / resume ───────────────────────────────────────────
// Cooperative checkpoints: the tier exposes (portal-snapshot! NAME) and
// (portal-load! NAME) which write/read /tmp/NAME.portal inside MEMFS.
// JS reaches into MEMFS via the worker bridge, encrypts the blob into
// the vault, and on restore reverses the flow. Names are alphanumeric;
// the timestamp format we generate ourselves never embeds slashes.
function safePortalName(s) {
return (s || "").replace(/[^A-Za-z0-9_-]/g, "_").slice(0, 40) || "snap";
}
function bridgeWorker(w, msg, matchKind) {
return new Promise((resolve) => {
const handler = (e) => {
if (e.data.kind !== matchKind || e.data.runId !== msg.runId) return;
w.removeEventListener("message", handler);
resolve(e.data);
};
w.addEventListener("message", handler);
w.postMessage(msg);
});
}
// Tier-specific save strategies:
// * c, python — call the tier's (portal-snapshot! NAME) so portal.c /
// lumbda.py's portal_save dumps env+RNG to /tmp/<name>.portal JSON,
// then JS reads the blob out of MEMFS. Faithful reproduction.
// * asm-wat — no portal subsystem in the tier yet (it'd need a
// Cheney-aware serializer). Instead snapshot the transcript and
// replay on restore. Works for the REPL's actual workflow of
// successive defines because every closure rebuilds from source.
async function saveCheckpoint() {
const tab = activeTab();
if (!tab) return;
const tier = tab.tier === "all" ? "c" : tab.tier;
const ts = new Date();
const defaultName = `snap-${ts.getHours().toString().padStart(2, "0")}` +
`${ts.getMinutes().toString().padStart(2, "0")}` +
`${ts.getSeconds().toString().padStart(2, "0")}`;
const name = safePortalName(prompt("checkpoint name:", defaultName) || "");
if (!name) return;
// Overwrite guard — saving with an existing name silently nuked
// the old blob, which has cost more than one "where did my snapshot
// go?" moment. Ask first.
if (tab.checkpoints && tab.checkpoints[name]) {
const old = tab.checkpoints[name];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) return;
}
let entry;
if (tier === "asm") {
// Replay mode — capture every successful prior input. Errors
// get dropped because re-running them on restore would just
// crash the tier mid-replay.
const inputs = tab.transcript
.filter((e) => e.kind !== "error")
.map((e) => e.input);
entry = { tier: "asm", mode: "replay", inputs, savedAt: Date.now() };
} else {
// C / Python — portal-snapshot! lands a JSON blob in MEMFS.
try {
await evalInTier(tab.id, tier, `(portal-snapshot! "${name}")`);
} catch (e) {
alert("portal-snapshot! failed: " + e.message);
return;
}
const w = state.workers[workerKey(tab.id, tier)];
if (!w) { alert("worker missing"); return; }
const runId = state.nextRunId++;
const reply = await bridgeWorker(w, { kind: "portal-save", runId, tier, name }, "portal-save");
if (!reply.blob) { alert("portal blob empty — snapshot didn't land in MEMFS"); return; }
entry = { tier, mode: "blob", blob: reply.blob, savedAt: Date.now() };
}
tab.checkpoints = tab.checkpoints || {};
tab.checkpoints[name] = entry;
renderAll();
saveSoon();
}
// Restore a checkpoint into the active tab. The optional sourceCp
// override lets the global-checkpoints view pass in a cp from a
// different tab — the restore still operates on activeTab() so the
// user lands on the tab they were working in, with that tab's tier
// state reset to the snapshot.
async function restoreCheckpoint(name, sourceCp) {
const tab = activeTab();
if (!tab) return;
const cp = sourceCp || (tab.checkpoints && tab.checkpoints[name]);
if (!cp) return;
if (!confirm(`restore '${name}'? current ${cp.tier} state is replaced.`)) return;
if (tab.tier !== cp.tier) {
tab.tier = cp.tier;
tierSelectEl.value = cp.tier;
renderAll();
}
// Reboot the tier so we start from a clean global env — either
// replay needs it, or portal_resume's "merge into _env" semantics
// would otherwise compound on top of whatever's already defined.
rebootTier(tab.id, cp.tier);
if (cp.mode === "replay") {
// Re-eval every saved input on the fresh worker, in order.
for (const src of (cp.inputs || [])) {
try { await evalInTier(tab.id, cp.tier, src); } catch (e) { /* keep going */ }
}
tab.transcript.push({ input: `; replayed ${cp.inputs.length} inputs from '${name}'`,
results: [{ tier: cp.tier, output: "#t" }], kind: "ok" });
} else {
// Blob mode — hydrate MEMFS, then ask the tier to load it.
try { await evalInTier(tab.id, cp.tier, "'init"); } catch (e) { /* harmless */ }
const w = state.workers[workerKey(tab.id, cp.tier)];
if (!w) { alert("worker missing"); return; }
const runId = state.nextRunId++;
await bridgeWorker(w, { kind: "portal-load", runId, tier: cp.tier, name, blob: cp.blob }, "portal-load");
try {
await evalInTier(tab.id, cp.tier, `(portal-load! "${name}")`);
tab.transcript.push({ input: `(portal-load! "${name}")`,
results: [{ tier: cp.tier, output: "#t" }], kind: "ok" });
} catch (e) {
alert("portal-load! failed: " + e.message);
return;
}
}
renderAll();
saveSoon();
}
function renameCheckpoint(oldName) {
const tab = activeTab();
if (!tab || !tab.checkpoints || !tab.checkpoints[oldName]) return;
const raw = prompt(`rename '${oldName}' to:`, oldName);
if (raw === null) return;
const newName = safePortalName(raw);
if (!newName || newName === oldName) return;
if (tab.checkpoints[newName]) {
const old = tab.checkpoints[newName];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${newName}' already exists (${old.tier}, saved ${when}). overwrite?`)) return;
}
tab.checkpoints[newName] = tab.checkpoints[oldName];
delete tab.checkpoints[oldName];
renderAll();
saveSoon();
}
// Export a checkpoint to a downloadable .portal.json file. The blob
// itself is opaque JSON for c/python (portal_save's wire format) and a
// {tier, mode: "replay", inputs} object for asm. Either way the file
// holds everything importCheckpoint needs to reconstruct the entry; no
// vault-side metadata bleeds out.
function exportCheckpoint(name) {
const tab = activeTab();
if (!tab || !tab.checkpoints || !tab.checkpoints[name]) return;
const entry = tab.checkpoints[name];
const payload = { __portal: 1, name, ...entry };
const data = new Blob([JSON.stringify(payload, null, 2)], { type: "application/json" });
const url = URL.createObjectURL(data);
const a = document.createElement("a");
a.href = url;
a.download = `${name}.portal.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// Import a checkpoint File (from <input type="file">). Validates the
// shape, prompts on name collision, lands the entry in the active
// tab's checkpoints map. The imported entry lives on whatever tier it
// was saved under — restoring it will switch the active tab to that
// tier automatically (restoreCheckpoint already does the swap).
async function importCheckpoint(file) {
const tab = activeTab();
if (!tab) return;
let payload;
try {
const text = await file.text();
payload = JSON.parse(text);
} catch (e) {
alert("import failed — not valid JSON: " + e.message);
return;
}
if (!payload || payload.__portal !== 1 || !payload.tier || !payload.savedAt) {
alert("import failed — file isn't a portal snapshot (missing __portal/tier/savedAt)");
return;
}
const baseName = safePortalName(payload.name || file.name.replace(/\.portal\.json$/i, "")) || "imported";
let name = baseName;
tab.checkpoints = tab.checkpoints || {};
if (tab.checkpoints[name]) {
const old = tab.checkpoints[name];
const when = new Date(old.savedAt).toLocaleString();
if (!confirm(`'${name}' already exists (${old.tier}, saved ${when}). overwrite?`)) {
// Auto-suffix on decline so the user gets to keep both.
let i = 2;
while (tab.checkpoints[`${baseName}-${i}`]) i++;
name = `${baseName}-${i}`;
}
}
const entry = { tier: payload.tier, savedAt: payload.savedAt };
if (payload.mode === "replay") {
entry.mode = "replay";
entry.inputs = Array.isArray(payload.inputs) ? payload.inputs : [];
} else {
entry.mode = "blob";
entry.blob = payload.blob || "";
}
tab.checkpoints[name] = entry;
renderAll();
saveSoon();
}
// Manual pause-and-snapshot — the user wants to inspect or save the
// state of a long-running eval without leaving the tab. Signals the
// SAB pause flag, waits for the in-flight eval to settle, then runs
// saveCheckpoint so the snapshot lands in the visible chip strip
// with a user-chosen name. Workers stay alive so the next input
// runs immediately.
async function pauseAndSnapshot() {
if (!pauseFlagView) {
alert("pause requires COOP/COEP headers — start with `make serve-repl`, or for production set Cross-Origin-Opener-Policy: same-origin + Cross-Origin-Embedder-Policy: require-corp on the deploy");
return;
}
const tab = activeTab();
if (!tab) return;
const tier = tab.tier === "all" ? "c" : tab.tier;
if (tier === "asm") {
alert("manual pause not supported on asm tier yet — use cancel + portal-save when the eval is idle");
return;
}
pauseBtn.disabled = true;
Atomics.store(pauseFlagView, 0, 1);
await waitForPending(tab.id, tier, 2000);
Atomics.store(pauseFlagView, 0, 0);
if (isEvalInFlight(tab)) {
// The worker didn't yield (stuck in sync XHR, native builtin
// without a poll site, etc). Bail out cleanly rather than
// trying to snapshot on a still-busy worker — that would
// queue portal-snapshot! behind the running eval and lock the
// UI until the eval finishes on its own.
alert("pause request timed out — the eval is in code without a yield point. cancel + reboot if you need to stop it.");
return;
}
// Reset activeInput marker so a follow-up tab switch doesn't try
// to "auto-resume" what the user has now explicitly saved.
tab.activeInput = null;
await saveCheckpoint();
}
function deleteCheckpoint(name) {
const tab = activeTab();
if (!tab || !tab.checkpoints) return;
delete tab.checkpoints[name];
renderAll();
saveSoon();
}
function rebootTier(tabId, tier) {
const k = workerKey(tabId, tier);
if (state.workers[k]) {
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
}
if (state.pending[k]) {
try { state.pending[k].reject(new Error("rebooted")); } catch {}
delete state.pending[k];
}
}
function cancelAllPendingInActiveTab() {
const tab = activeTab();
if (!tab) return;
for (const k of Object.keys(state.pending)) {
if (k.startsWith(tab.id + ":")) {
try { state.pending[k].reject(new Error("cancelled")); } catch {}
try { state.workers[k].terminate(); } catch {}
delete state.workers[k];
delete state.pending[k];
}
}
}
// ─── Render ─────────────────────────────────────────────────────────
function renderAll() {
// Tabs
tabsEl.innerHTML = "";
for (const t of state.tabs) {
const el = document.createElement("div");
el.className = "tab" + (t.id === state.activeTabId ? " active" : "");
const name = document.createElement("span");
name.textContent = t.name;
name.addEventListener("click", () => setActiveTab(t.id));
name.addEventListener("dblclick", (e) => {
e.stopPropagation();
const newName = prompt("rename tab:", t.name);
if (newName) { t.name = newName.slice(0, 40); renderAll(); saveSoon(); }
});
const close = document.createElement("span");
close.className = "tab-close";
close.textContent = "×";
close.title = "close tab";
close.addEventListener("click", (e) => { e.stopPropagation(); closeTab(t.id); });
el.appendChild(name);
el.appendChild(close);
tabsEl.appendChild(el);
}
// Transcript — terminal-style: prompt line, then output lines underneath
// indented under the prompt with a tier-tag suffix as a Lisp comment.
transcriptEl.innerHTML = "";
const tab = activeTab();
if (!tab) return;
tierSelectEl.value = tab.tier;
// Restore the active tab's saved draft into the textarea so a page
// refresh (or tab switch) lands the user back on the half-written
// form they had before. The guard avoids fighting an active input
// event mid-stream — if the textarea already matches the draft
// there's nothing to do.
const draft = tab.inputDraft || "";
if (inputEl.value !== draft) {
inputEl.value = draft;
autosizeInput();
}
for (const entry of tab.transcript) {
const block = document.createElement("div");
block.className = "entry" + (entry.kind === "error" ? " error" : "");
// Render the input across one or more lines (preserve newlines).
const inputLines = entry.input.split(/\n/);
for (let i = 0; i < inputLines.length; i++) {
const line = document.createElement("div");
line.className = "prompt-line";
const sigil = document.createElement("span");
sigil.className = "sigil";
sigil.textContent = i === 0 ? "λ>" : "..";
line.appendChild(sigil);
line.appendChild(document.createTextNode(inputLines[i]));
block.appendChild(line);
}
for (const r of (entry.results || [])) {
const row = document.createElement("div");
row.className = "tier-output";
const result = document.createElement("span");
result.className = "tier-result";
const text = (r.output != null ? r.output : "");
const errText = r.error ? "error: " + r.error : "";
result.textContent = (text + (errText && text ? "\n" : "") + errText).replace(/\n$/, "");
const meta = document.createElement("span");
meta.className = "tier-meta";
meta.textContent = `${TIER_LABEL[r.tier] || r.tier} · ${(r.elapsed | 0)}ms`;
row.appendChild(result);
row.appendChild(meta);
block.appendChild(row);
}
transcriptEl.appendChild(block);
}
// Portal chips — when showGlobalCheckpoints is off, one per saved
// checkpoint in the active tab. When on, gather every checkpoint
// across every tab so the user can restore a snapshot saved on
// 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 = "";
// 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 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) {
const cps = t.checkpoints || {};
for (const name of Object.keys(cps)) {
chipRows.push({ tab: t, name, cp: cps[name] });
}
}
chipRows.sort((a, b) => b.cp.savedAt - a.cp.savedAt);
} else {
const cps = tab.checkpoints || {};
for (const name of Object.keys(cps).sort()) {
chipRows.push({ tab, name, cp: cps[name] });
}
}
for (const { tab: cpTab, name, cp } of chipRows) {
const chip = document.createElement("span");
chip.className = "chip";
const when = new Date(cp.savedAt).toLocaleString();
chip.title = showGlobalCheckpoints
? `${name} · ${cp.tier} · saved ${when} on tab '${cpTab.name}' — click to restore into THIS tab`
: `${name} · ${cp.tier} · saved ${when} — click to restore, dbl-click to rename`;
const label = document.createElement("span");
label.textContent = showGlobalCheckpoints ? `${name} · ${cpTab.name}` : name;
// Restore always operates on the active tab; in global mode
// the snapshot's cp object is passed so we don't depend on
// the active tab having a checkpoints[name] match.
label.addEventListener("click", () => restoreCheckpoint(name, cp));
if (!showGlobalCheckpoints) {
label.addEventListener("dblclick", (e) => { e.stopPropagation(); renameCheckpoint(name); });
const exportBtn = document.createElement("span");
exportBtn.className = "chip-export";
exportBtn.textContent = "⇣";
exportBtn.title = "download checkpoint as .portal.json";
exportBtn.addEventListener("click", (e) => { e.stopPropagation(); exportCheckpoint(name); });
const close = document.createElement("span");
close.className = "chip-close";
close.textContent = "×";
close.title = "delete checkpoint";
close.addEventListener("click", (e) => { e.stopPropagation(); if (confirm(`delete '${name}'?`)) deleteCheckpoint(name); });
chip.appendChild(label);
chip.appendChild(exportBtn);
chip.appendChild(close);
} else {
chip.classList.add("chip-global");
chip.appendChild(label);
}
portalChipsEl.appendChild(chip);
}
// Body owns the scroll now; jump to the bottom after layout settles.
// requestAnimationFrame lets the just-mounted DOM contribute to
// scrollHeight before we measure — otherwise on first load the page
// sticks at the top because the transcript hasn't been laid out yet.
// renderAll fires on user-initiated actions (send, tab switch, new
// entry) where they expect the most-recent content, so the jump
// here is unconditional and resets scrollPinned to true so streamed
// follow-ups continue to autoscroll.
requestAnimationFrame(() => {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
scrollPinned = true;
});
}
// ─── Sticky autoscroll ──────────────────────────────────────────────
// During a long streaming eval the page should track the bottom UNLESS
// the user has deliberately scrolled up to read older output. We sample
// "is the viewport at the bottom" on every user scroll, then streamed
// appendText/appendNewline only force the scroll when scrollPinned is
// still true. A 64px tolerance covers fractional-pixel scroll positions
// and short header offsets — anything farther than that is treated as
// "user wanted to look back" and autoscroll stops until they return.
let scrollPinned = true;
const SCROLL_BOTTOM_TOLERANCE = 64;
function isViewportAtBottom() {
return (window.innerHeight + window.scrollY) >=
(document.documentElement.scrollHeight - SCROLL_BOTTOM_TOLERANCE);
}
function maybeAutoscroll() {
if (scrollPinned) {
window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" });
}
}
window.addEventListener("scroll", () => {
scrollPinned = isViewportAtBottom();
}, { passive: true });
// ─── History navigation (readline-style up/down) ────────────────────
// Each tab owns its own history; the active tab's draft is preserved so
// that walking back into history doesn't eat what the user was typing.
const history = { idx: null, draft: "" };
function historyEntries() {
// Transcript's `input` fields, deduplicated against the immediate
// predecessor — pressing up shouldn't make you hit the same line
// twice in a row when you just submitted it.
const tab = activeTab();
if (!tab) return [];
const out = [];
for (const e of tab.transcript) {
if (out.length && out[out.length - 1] === e.input) continue;
out.push(e.input);
}
return out;
}
function historyPrev() {
const entries = historyEntries();
if (entries.length === 0) return;
if (history.idx === null) {
history.draft = inputEl.value;
history.idx = entries.length - 1;
} else if (history.idx > 0) {
history.idx--;
}
inputEl.value = entries[history.idx];
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
autosizeInput();
}
function historyNext() {
const entries = historyEntries();
if (history.idx === null) return;
if (history.idx >= entries.length - 1) {
history.idx = null;
inputEl.value = history.draft;
history.draft = "";
} else {
history.idx++;
inputEl.value = entries[history.idx];
}
inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length);
autosizeInput();
}
function resetHistory() {
history.idx = null;
history.draft = "";
}
// Auto-grow the textarea to fit its content (up to the max-height the
// CSS pins it at, after which the textarea scrolls internally). Paste
// a 40-line program and the prompt-bar expands instead of leaving you
// editing the program through a one-line keyhole. Fires on every input
// event and every programmatic value set (sendInput, history, etc.).
function autosizeInput() {
inputEl.style.height = "auto";
inputEl.style.height = inputEl.scrollHeight + "px";
}
// ─── Input handling ─────────────────────────────────────────────────
async function sendInput() {
const src = inputEl.value.trim();
if (!src) return;
const tab = activeTab();
if (!tab) return;
tab.tier = tierSelectEl.value;
const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier];
// Pre-populate placeholder results so the initial renderAll lays
// out the tier-output rows we'll stream into. Each placeholder
// carries output="" and a streaming:true flag so renderAll
// (if invoked later for any reason) renders an empty row rather
// than missing the tier. Real values land via the streaming
// attach below, finalized when each tier's promise resolves.
const entry = {
input: src,
results: tiers.map((t) => ({ tier: t, output: "", streaming: true })),
kind: "ok",
};
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;
cancelBtn.disabled = false;
pauseBtn.disabled = !pauseFlagView;
renderAll();
saveSoon();
// Grab the DOM rows we just rendered for this entry so streaming
// chunks land directly into them. transcriptEl.lastElementChild
// is the block we just added (entries render in order).
const entryEl = transcriptEl.lastElementChild;
const rowEls = entryEl ? Array.from(entryEl.querySelectorAll(".tier-output")) : [];
const liveRows = {};
for (let i = 0; i < tiers.length && i < rowEls.length; i++) {
const row = rowEls[i];
const resultSpan = row.querySelector(".tier-result");
const metaSpan = row.querySelector(".tier-meta");
if (resultSpan && metaSpan) {
liveRows[tiers[i]] = attachStreaming(resultSpan, metaSpan, tiers[i]);
}
}
const startTimes = {};
for (const t of tiers) startTimes[t] = performance.now();
const promises = tiers.map((t) => {
const live = liveRows[t];
return evalInTier(
tab.id, t, src,
live ? (text) => live.appendText(text) : null,
live ? () => live.appendNewline() : null,
)
.then((output) => ({ tier: t, output, elapsed: performance.now() - startTimes[t] }))
.catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] }));
});
let remaining = promises.length;
for (const p of promises) {
p.then((r) => {
// Mutate the placeholder result in place — never push a
// second entry per tier or we'd render twice. Mark
// streaming=false so a later tab switch redraws via the
// normal (full text) path instead of leaving it blank.
const idx = entry.results.findIndex((x) => x.tier === r.tier && x.streaming);
if (idx >= 0) {
entry.results[idx] = {
tier: r.tier,
output: r.output != null ? r.output : "",
error: r.error,
elapsed: r.elapsed,
};
if (r.error) entry.kind = "error";
}
// Update the DOM in place (don't call renderAll — it'd
// clobber sibling tiers still streaming). finalize swaps
// the streaming divs for the canonical text if they
// diverge and writes the elapsed-ms meta.
const live = liveRows[r.tier];
if (live) live.finalize(r);
saveSoon();
remaining--;
if (remaining === 0) {
sendBtn.disabled = false;
cancelBtn.disabled = true;
pauseBtn.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;
}
});
}
}
// ─── Heap pressure indicator ───────────────────────────────────────
// Polls each loaded tier in the active tab and prints a compact
// "py 12M · c 32M · asm 4M" string next to the tabbar buttons.
const heapEl = document.createElement("span");
heapEl.className = "heap-pressure";
heapEl.title = "tier worker memory — \"reboot tier\" reclaims on demand";
function formatBytes(n) {
if (n < 1024) return `${n}B`;
if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}K`;
return `${(n / (1024 * 1024)).toFixed(1)}M`;
}
async function pollHeap() {
const tab = activeTab();
if (!tab) return;
const tiers = ["python", "c", "asm"];
const parts = [];
for (const t of tiers) {
const k = workerKey(tab.id, t);
const w = state.workers[k];
if (!w) continue;
const runId = ++state.nextRunId;
const stats = await new Promise((resolve) => {
const handler = (e) => {
if (e.data.kind !== "heap" || e.data.runId !== runId) return;
w.removeEventListener("message", handler);
resolve(e.data.stats);
};
w.addEventListener("message", handler);
w.postMessage({ kind: "heap", runId, tier: t });
// Timeout safety — eval-busy workers won't reply.
setTimeout(() => { w.removeEventListener("message", handler); resolve(null); }, 200);
});
if (stats && stats.used != null) {
parts.push(`${t.slice(0, 3)} ${formatBytes(stats.used)}`);
}
}
heapEl.textContent = parts.length ? parts.join(" · ") : "";
}
setInterval(pollHeap, 2000);
// ─── Wire up ────────────────────────────────────────────────────────
unlockBtn.addEventListener("click", tryUnlock);
freshBtn.addEventListener("click", enterEphemeral);
passwordEl.addEventListener("keydown", (e) => {
if (e.key === "Enter") tryUnlock();
});
newTabBtn.addEventListener("click", () => newTab());
resetTierBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) rebootTier(tab.id, tab.tier);
});
clearLogBtn.addEventListener("click", () => {
const tab = activeTab();
if (tab) { tab.transcript = []; renderAll(); saveSoon(); }
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
pauseBtn.addEventListener("click", pauseAndSnapshot);
portalSaveBtn.addEventListener("click", saveCheckpoint);
portalImportBtn.addEventListener("click", () => portalImportFileEl.click());
portalImportFileEl.addEventListener("change", async (e) => {
const file = e.target.files && e.target.files[0];
if (!file) return;
await importCheckpoint(file);
e.target.value = "";
});
portalScopeBtn.addEventListener("click", () => {
showGlobalCheckpoints = !showGlobalCheckpoints;
renderAll();
});
lockBtn.addEventListener("click", relock);
// Insert heap pressure indicator into the tabbar after the spacer.
document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn);
inputEl.addEventListener("keydown", (e) => {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
sendInput();
return;
}
// Up/Down navigate per-tab history when the caret is in the only
// (or first/last) row — otherwise they belong to the textarea's
// natural multi-line navigation.
if (e.key === "ArrowUp") {
const before = inputEl.value.substring(0, inputEl.selectionStart);
if (!before.includes("\n")) {
e.preventDefault();
historyPrev();
}
return;
}
if (e.key === "ArrowDown") {
const after = inputEl.value.substring(inputEl.selectionStart);
if (!after.includes("\n")) {
e.preventDefault();
historyNext();
}
return;
}
});
inputEl.addEventListener("input", () => {
autosizeInput();
// Persist the in-progress input so a refresh, lock/unlock, or tab
// switch lands the user back where they were. saveSoon debounces
// to 400ms so paste storms don't thrash the vault writer.
const tab = activeTab();
if (tab) {
tab.inputDraft = inputEl.value;
saveSoon();
}
// Any keystroke that isn't an arrow drops history navigation —
// edits are now the user's own draft, not the historical entry.
if (history.idx !== null && document.activeElement === inputEl) {
// We can't reliably distinguish arrow-induced updates here, so
// we only invalidate when the buffer has actually diverged from
// the historical entry the cursor was on.
const entries = historyEntries();
if (entries[history.idx] !== inputEl.value) {
history.draft = inputEl.value;
history.idx = null;
}
}
});
sendBtn.addEventListener("click", sendInput);
tierSelectEl.addEventListener("change", () => {
const tab = activeTab();
if (tab) { tab.tier = tierSelectEl.value; saveSoon(); }
});