repl: portal save/resume — vault-backed tier checkpoints

Adds a portal-bar to the REPL between tabbar and transcript: a save
button + chip strip showing all saved checkpoints for the active tab.
Click a chip to restore, click × to delete.

Per-tier strategy:
  * c, python — call the tier's (portal-snapshot! NAME), then read the
    JSON blob out of MEMFS (Emscripten/Pyodide FS) and stash it in the
    encrypted vault entry. Restore reverses: hydrate MEMFS, then
    (portal-load! NAME) merges the bindings into the live env.
  * asm — no portal serializer in the WAT tier yet (would need a
    Cheney-aware walk). Falls back to transcript replay: save snapshots
    every successful prior input, restore reboots the tier and re-evals
    them in order.

Plumbing:
  * Worker bridge: new portal-save / portal-load message kinds wire
    MEMFS reads/writes to the main thread.
  * runner.js exposes portalSave / portalLoad — null when a tier
    hasn't implemented portals (asm stays grey).
  * C tier: replace EM_JS with extern + --js-library for js_lumbda_bend_call
    (EM_JS-generated declaration was unreachable from wasmImports at
    instantiate time, browsers threw "import object field ... not a
    Function"). FS added to EXPORTED_RUNTIME_METHODS so JS can reach
    pyodide.FS / Module.FS for MEMFS I/O.

Smoke-tested all three tiers headlessly: save → chip render → restore
round-trips clean on c / python / asm, zero console errors.
This commit is contained in:
russell@unturf.com 2026-06-15 07:43:07 -04:00
parent f528d6df43
commit d4380c64c7
No known key found for this signature in database
19 changed files with 776 additions and 62 deletions

View file

@ -100,7 +100,7 @@ C_CFLAGS := -O2 -DLUMBDA_WASM -std=c11 \
C_LDFLAGS := -s WASM=1 -s MODULARIZE=1 -s EXPORT_ES6=1 \
-s EXPORT_NAME=createLumbdaC \
-s EXPORTED_FUNCTIONS='["_lumbda_wasm_init","_lumbda_wasm_eval","_lumbda_wasm_free_result","_malloc","_free"]' \
-s EXPORTED_RUNTIME_METHODS='["cwrap","ccall","UTF8ToString","stringToUTF8","lengthBytesUTF8"]' \
-s EXPORTED_RUNTIME_METHODS='["cwrap","ccall","UTF8ToString","stringToUTF8","lengthBytesUTF8","FS"]' \
-s ALLOW_MEMORY_GROWTH=1 -s INITIAL_MEMORY=32MB \
-s STACK_SIZE=8MB \
-s ENVIRONMENT=web,worker,node \

View file

@ -57,8 +57,6 @@
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
#include <emscripten.h>
static Env *g_wasm_env = NULL;
/* bend!-call — dispatch a payload string to the configured gpu-worker
@ -71,39 +69,21 @@ static Env *g_wasm_env = NULL;
* passed by ptr+len, response is written into a heap-allocated buffer,
* length is returned. We then wrap the bytes as a lumbda string Value.
*
* The JS implementation lives in wasm/c/bend-call-library.js (linked
* via --js-library); declaring extern here makes emcc emit an env
* import that the library's mergeInto fills. Earlier draft used EM_JS
* but the generated function declaration landed in a scope that wasn't
* visible to wasmImports at instantiate time browsers threw
* "import object field 'js_lumbda_bend_call' is not a Function".
*
* The 256 KiB cap matches a typical (cuda-shake-fanout (...) 32) round
* trip plus headroom; larger responses get truncated rather than
* crashing the tier. Phase 2 (factory recipes) will replace large
* payloads with server-side bin compilation anyway. */
#define BEND_RESP_CAP (256 * 1024)
EM_JS(int, js_lumbda_bend_call, (const char *payload_ptr, int payload_len,
char *resp_buf, int resp_cap), {
var bendUrl = globalThis._lumbdaCBendUrl;
if (!bendUrl) {
var msg = "no bend URL configured";
var n = Math.min(msg.length, resp_cap - 1);
for (var i = 0; i < n; i++) HEAPU8[resp_buf + i] = msg.charCodeAt(i);
return n;
}
try {
var payload = UTF8ToString(payload_ptr, payload_len);
var xhr = new XMLHttpRequest();
xhr.open("POST", bendUrl, false); /* sync */
xhr.setRequestHeader("Content-Type", "text/plain; charset=utf-8");
xhr.send(payload);
var resp = xhr.responseText || "";
var bytes = new TextEncoder().encode(resp);
var n = Math.min(bytes.length, resp_cap);
HEAPU8.set(bytes.subarray(0, n), resp_buf);
return n;
} catch (e) {
var err = "bend error: " + (e.message || String(e));
var n = Math.min(err.length, resp_cap - 1);
for (var i = 0; i < n; i++) HEAPU8[resp_buf + i] = err.charCodeAt(i);
return n;
}
});
extern int js_lumbda_bend_call(const char *payload_ptr, int payload_len,
char *resp_buf, int resp_cap);
static Value bi_bend_call_wasm(Value *a, int n, Env *e) {
(void)e;
@ -117,6 +97,55 @@ static Value bi_bend_call_wasm(Value *a, int n, Env *e) {
return out;
}
/* portal-snapshot! / portal-load! — REPL save/resume bridge.
*
* Native portal-checkpoint! works mid-execution: it sets a thread-local
* flag, the VM saves at the next OP_JUMP (vm.c:770). At top level in
* the REPL there's no VM running, so the flag would never fire. These
* two builtins skip the flag and call portal_save/portal_resume
* directly with the current global env no continuation captured,
* which is exactly what a "save my session" REPL gesture wants.
*
* Files land in MEMFS under /tmp/<name>.portal so the JS bridge
* (lumbda-c.loader.js) can read them out and forward to the vault. */
static char portal_path_buf[256];
static const char *portal_path_for(const char *name) {
snprintf(portal_path_buf, sizeof(portal_path_buf),
"/tmp/%s.portal", name);
return portal_path_buf;
}
static Value bi_portal_snapshot(Value *a, int n, Env *e) {
if (n < 1 || !IS_STRING(a[0]))
lisp_error("portal-snapshot!: expected string name");
const char *path = portal_path_for(AS_STRING(a[0])->data);
portal_save(e->global ? e->global : e, path, NULL);
return make_string(path, strlen(path), false);
}
static Value bi_portal_load(Value *a, int n, Env *e) {
if (n < 1 || !IS_STRING(a[0]))
lisp_error("portal-load!: expected string name");
const char *path = portal_path_for(AS_STRING(a[0])->data);
Env *new_env = NULL;
FullCont *cont = NULL;
Env *base = e->global ? e->global : e;
if (!portal_resume(path, base, &new_env, &cont))
lisp_error("portal-load!: failed to resume from %s", path);
/* Copy bindings from restored env into our live global env so
* subsequent evals see them. portal_resume creates a fresh env
* but the REPL workers keep g_wasm_env stable. */
if (new_env) {
for (size_t b = 0; b < new_env->nbuckets; b++) {
for (EnvBinding *bind = new_env->buckets[b]; bind; bind = bind->next) {
env_define(base, bind->sym, bind->val);
}
}
}
return VAL_TRUE;
}
static const char *MINI_STDLIB =
"(define (caar p) (car (car p)))\n"
"(define (cadr p) (car (cdr p)))\n"
@ -170,6 +199,10 @@ void lumbda_wasm_init(void) {
* Done here (not in c/builtins.c) so the native build doesn't
* acquire a WASM-flavored binding it can't satisfy. */
env_define(g_wasm_env, intern("bend!-call"), VAL_BUILTIN(bi_bend_call_wasm));
/* Portal save/resume for REPL — see bi_portal_snapshot above. */
env_define(g_wasm_env, intern("portal-snapshot!"), VAL_BUILTIN(bi_portal_snapshot));
env_define(g_wasm_env, intern("portal-load!"), VAL_BUILTIN(bi_portal_load));
}
/* Eval src. Output during eval goes to stdout (captured by Module.print

File diff suppressed because one or more lines are too long

View file

@ -87,6 +87,11 @@
<button id="cancel" class="ghost" disabled>cancel</button>
<button id="lock" class="ghost" title="lock vault — passes back to lock screen">lock</button>
</section>
<section class="portal-bar" id="portal-bar">
<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>
<div id="portal-chips" class="portal-chips"></div>
</section>
<div id="transcript" class="transcript"></div>
<div class="prompt-bar" id="prompt-bar">
<span class="prompt-sigil">λ&gt;</span>

View file

@ -120,7 +120,11 @@ body.repl {
top: 0;
z-index: 40;
display: grid;
grid-template-columns: auto auto 1fr auto auto auto auto;
/* tabs · new-tab · spacer · heap-pressure (injected by repl.js) ·
* reboot · clear · cancel · lock 8 cells. Extend if the toolbar
* grows; CSS Grid drops trailing children onto a new row, which is
* how lock used to wrap. */
grid-template-columns: auto auto 1fr auto auto auto auto auto;
gap: 0.3rem;
align-items: center;
padding: 0.4rem 0.4rem;
@ -181,6 +185,52 @@ body.repl {
padding: 0 0.4rem;
}
/* Portal bar REPL save/resume controls. Sits between tabbar and
* transcript. Hidden when the active tier has no portal support yet
* (the asm-wat tier stays grey). */
.portal-bar {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 0.4rem;
align-items: center;
padding: 0.2rem 0.5rem;
background: var(--code-bg);
border-bottom: 1px solid var(--rule);
font-size: 0.78em;
font-family: var(--mono);
}
.portal-bar .portal-label {
color: var(--muted);
}
.portal-bar .portal-chips {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
gap: 0.3rem;
overflow-x: auto;
}
.portal-chips .chip {
display: inline-grid;
grid-template-columns: auto auto;
gap: 0.3rem;
align-items: center;
background: var(--pane-bg);
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.1rem 0.4rem;
color: var(--green);
cursor: pointer;
white-space: nowrap;
}
.portal-chips .chip:hover { border-color: var(--green); }
.portal-chips .chip .chip-close {
color: var(--muted);
font-size: 0.85em;
cursor: pointer;
padding: 0 0.15rem;
}
.portal-chips .chip .chip-close:hover { color: var(--err); }
/* ─── Transcript ───────────────────────────────────────────────── */
/* The REPL stream is the page's only scroller. Header stays pinned;

View file

@ -28,6 +28,9 @@ 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 portalChipsEl = document.getElementById("portal-chips");
// ─── State ──────────────────────────────────────────────────────────
const state = {
@ -174,7 +177,7 @@ function ensureWorker(tabId, tier) {
return state.workers[k];
}
function evalInTier(tabId, tier, src) {
function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
return new Promise((resolve, reject) => {
const w = ensureWorker(tabId, tier);
const runId = state.nextRunId++;
@ -182,7 +185,11 @@ function evalInTier(tabId, tier, src) {
state.pending[pendingKey] = { runId, reject };
const handler = (e) => {
if (e.data.runId !== runId) return;
if (e.data.kind === "done") {
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);
@ -197,6 +204,185 @@ function evalInTier(tabId, 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;
},
appendNewline() {
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
resultSpan.appendChild(pendingLine);
},
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$/, "");
// If the streamed text doesn't match the full eval output
// (e.g. an error fired without intermediate chunks), nuke
// and rebuild from the full text. Otherwise leave the
// streamed divs as-is — they already match.
if (streamedText !== expected || errText) {
resultSpan.textContent = "";
const combined = (text + (errText && text ? "\n" : "") + errText).replace(/\n$/, "");
const lines = combined.split("\n");
for (const ln of lines) {
const d = makeLine();
d.textContent = ln || " ";
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;
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();
}
async function restoreCheckpoint(name) {
const tab = activeTab();
if (!tab) return;
const cp = 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 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]) {
@ -287,6 +473,28 @@ function renderAll() {
}
transcriptEl.appendChild(block);
}
// Portal chips — one per saved checkpoint in the active tab.
portalChipsEl.innerHTML = "";
const checkpoints = tab.checkpoints || {};
const names = Object.keys(checkpoints).sort();
for (const name of names) {
const chip = document.createElement("span");
chip.className = "chip";
chip.title = `${name} · ${checkpoints[name].tier} · saved ${new Date(checkpoints[name].savedAt).toLocaleString()} — click to restore`;
const label = document.createElement("span");
label.textContent = name;
label.addEventListener("click", () => restoreCheckpoint(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(close);
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
@ -355,7 +563,18 @@ async function sendInput() {
if (!tab) return;
tab.tier = tierSelectEl.value;
const tiers = tab.tier === "all" ? ["python", "c", "asm"] : [tab.tier];
const entry = { input: src, results: [], kind: "ok" };
// 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 = "";
resetHistory();
@ -364,20 +583,58 @@ async function sendInput() {
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) =>
evalInTier(tab.id, t, src)
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] })));
// Append results as they arrive so user sees them in race order.
.catch((e) => ({ tier: t, error: e.message, elapsed: performance.now() - startTimes[t] }));
});
let remaining = promises.length;
for (const p of promises) {
p.then((r) => {
entry.results.push(r);
if (r.error) entry.kind = "error";
renderAll();
// 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) {
@ -448,6 +705,7 @@ clearLogBtn.addEventListener("click", () => {
if (tab) { tab.transcript = []; renderAll(); saveSoon(); }
});
cancelBtn.addEventListener("click", cancelAllPendingInActiveTab);
portalSaveBtn.addEventListener("click", saveCheckpoint);
lockBtn.addEventListener("click", relock);
// Insert heap pressure indicator into the tabbar after the spacer.
document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn);

View file

@ -29,9 +29,9 @@ export async function getTier(name, onLoad) {
return cache[name];
}
export async function evalOnTier(name, src, onLoad) {
export async function evalOnTier(name, src, onLoad, onChunk) {
const tier = await getTier(name, onLoad);
return tier.evalLisp(src);
return tier.evalLisp(src, onChunk);
}
export function heapStats(name) {
@ -39,3 +39,20 @@ export function heapStats(name) {
if (!tier || !tier.heapStats) return null;
return tier.heapStats();
}
// Portal — REPL save/resume bridge. portalSave reads a snapshot blob
// from the tier (MEMFS on the C tier, similar bridges on others as
// they land). Returns null when the tier hasn't implemented portals
// yet (asm-wat today) so the caller can surface a friendly message
// instead of crashing. portalLoad is the symmetric write-in path.
export function portalSave(name, checkpointName) {
const tier = cache[name];
if (!tier || !tier.portalSave) return null;
return tier.portalSave(checkpointName);
}
export function portalLoad(name, checkpointName, blob) {
const tier = cache[name];
if (!tier || !tier.portalLoad) return false;
tier.portalLoad(checkpointName, blob);
return true;
}

View file

@ -350,6 +350,16 @@ header code {
white-space: pre;
font-family: var(--mono);
}
/* Streamed output: each emit_chunk slice that ended with \n becomes
its own <div class="stream-line"> child of the <pre>. Block display
guarantees vertical stacking regardless of how the browser handles
raw \n inside a sequence of text nodes the bug we kept chasing
in firefox where "tick 0\ntick 10000\n..." rendered horizontally
even though each text node clearly contained the newline byte. */
#output .tier-block .stream-line {
display: block;
white-space: pre;
}
#output .err { color: var(--err); }
/* ─── Footer ────────────────────────────────────────────────────── */

View file

@ -3,7 +3,7 @@
// live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()).
import { evalOnTier, setBendUrl, heapStats } from "./runner.js";
import { evalOnTier, setBendUrl, heapStats, portalSave, portalLoad } from "./runner.js";
self.onmessage = async (e) => {
const { kind } = e.data;
@ -17,12 +17,77 @@ self.onmessage = async (e) => {
self.postMessage({ kind: "heap", runId: e.data.runId, tier: e.data.tier, stats: heapStats(e.data.tier) });
return;
}
if (kind === "portal-save") {
// Pulls the most-recent /tmp/<name>.portal out of the tier's
// MEMFS after the lisp side ran (portal-snapshot! NAME). Main
// thread encrypts and stuffs it into the vault.
const blob = portalSave(e.data.tier, e.data.name);
self.postMessage({ kind: "portal-save", runId: e.data.runId, name: e.data.name, blob });
return;
}
if (kind === "portal-load") {
// Hydrates MEMFS from a vault-decrypted blob so a subsequent
// (portal-load! NAME) eval finds the file ready.
const ok = portalLoad(e.data.tier, e.data.name, e.data.blob);
self.postMessage({ kind: "portal-load", runId: e.data.runId, name: e.data.name, ok });
return;
}
if (kind !== "eval") return;
const { runId, tier, src } = e.data;
try {
const output = await evalOnTier(tier, src, (loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
});
const output = await evalOnTier(
tier,
src,
(loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
},
// Stream every print/display from the tier to the main
// thread as it happens. Sync XHR inside bend!-call still
// blocks the worker, but displays BEFORE/AFTER the bend
// round-trip surface immediately instead of waiting for
// the whole eval to finish. Long demos feel alive.
//
// Defensive newline normalization: the C-tier loader
// adds the trailing \n that Emscripten's Module.print
// strips, but we kept seeing horizontal output in fox's
// Firefox tab as if the \n was lost somewhere on the
// wire. To rule out anything between here and the main
// thread, split each chunk on \n at the source and post
// one message per line — newline preserved as a flag
// rather than a byte. The main-thread receiver knows to
// re-add the line break.
(chunk) => {
if (!chunk) return;
// Split on \n at the source and post TWO separate
// message kinds: chunk-text (visible bytes, never
// containing a newline) and chunk-eol (a bare event
// marking end-of-line). Newlines no longer travel
// as bytes — they're typed messages. Whatever was
// eating the \n between Module.print and the DOM
// in fox's tab is bypassed.
let start = 0;
for (let i = 0; i < chunk.length; i++) {
if (chunk.charCodeAt(i) === 10) {
if (i > start) {
self.postMessage({
kind: "chunk-text",
runId, tier,
text: chunk.slice(start, i),
});
}
self.postMessage({ kind: "chunk-eol", runId, tier });
start = i + 1;
}
}
if (start < chunk.length) {
self.postMessage({
kind: "chunk-text",
runId, tier,
text: chunk.slice(start),
});
}
},
);
self.postMessage({ kind: "done", runId, output });
} catch (err) {
self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });

View file

@ -87,6 +87,11 @@
<button id="cancel" class="ghost" disabled>cancel</button>
<button id="lock" class="ghost" title="lock vault — passes back to lock screen">lock</button>
</section>
<section class="portal-bar" id="portal-bar">
<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>
<div id="portal-chips" class="portal-chips"></div>
</section>
<div id="transcript" class="transcript"></div>
<div class="prompt-bar" id="prompt-bar">
<span class="prompt-sigil">λ&gt;</span>

View file

@ -120,7 +120,11 @@ body.repl {
top: 0;
z-index: 40;
display: grid;
grid-template-columns: auto auto 1fr auto auto auto auto;
/* tabs · new-tab · spacer · heap-pressure (injected by repl.js) ·
* reboot · clear · cancel · lock 8 cells. Extend if the toolbar
* grows; CSS Grid drops trailing children onto a new row, which is
* how lock used to wrap. */
grid-template-columns: auto auto 1fr auto auto auto auto auto;
gap: 0.3rem;
align-items: center;
padding: 0.4rem 0.4rem;
@ -181,6 +185,52 @@ body.repl {
padding: 0 0.4rem;
}
/* Portal bar REPL save/resume controls. Sits between tabbar and
* transcript. Hidden when the active tier has no portal support yet
* (the asm-wat tier stays grey). */
.portal-bar {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 0.4rem;
align-items: center;
padding: 0.2rem 0.5rem;
background: var(--code-bg);
border-bottom: 1px solid var(--rule);
font-size: 0.78em;
font-family: var(--mono);
}
.portal-bar .portal-label {
color: var(--muted);
}
.portal-bar .portal-chips {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
gap: 0.3rem;
overflow-x: auto;
}
.portal-chips .chip {
display: inline-grid;
grid-template-columns: auto auto;
gap: 0.3rem;
align-items: center;
background: var(--pane-bg);
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.1rem 0.4rem;
color: var(--green);
cursor: pointer;
white-space: nowrap;
}
.portal-chips .chip:hover { border-color: var(--green); }
.portal-chips .chip .chip-close {
color: var(--muted);
font-size: 0.85em;
cursor: pointer;
padding: 0 0.15rem;
}
.portal-chips .chip .chip-close:hover { color: var(--err); }
/* ─── Transcript ───────────────────────────────────────────────── */
/* The REPL stream is the page's only scroller. Header stays pinned;

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -87,6 +87,11 @@
<button id="cancel" class="ghost" disabled>cancel</button>
<button id="lock" class="ghost" title="lock vault — passes back to lock screen">lock</button>
</section>
<section class="portal-bar" id="portal-bar">
<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>
<div id="portal-chips" class="portal-chips"></div>
</section>
<div id="transcript" class="transcript"></div>
<div class="prompt-bar" id="prompt-bar">
<span class="prompt-sigil">λ&gt;</span>

View file

@ -29,7 +29,15 @@ async function _bootstrap() {
// the response text back. Sync XHR is the only sync HTTP available
// in a Web Worker; perfect for the blocking eval model lumbda
// primitives expect.
const refs = { bendUrl: null };
const refs = { bendUrl: null, currentOnChunk: null };
// Streaming output bridge — sys.stdout in pyodide is replaced
// (during _lumbda_eval) with a class whose write() calls back
// here. We forward to the current onChunk so the worker can
// postMessage chunks to the UI as work happens, instead of the
// user staring at a blank panel during a long bend dispatch.
globalThis._lumbdaPyEmitChunk = (s) => {
if (refs.currentOnChunk && s) refs.currentOnChunk(s);
};
globalThis._lumbdaPyBendCall = (payload) => {
if (!refs.bendUrl) return "no bend URL configured";
try {
@ -63,8 +71,50 @@ def _bend_call_prim(args, env):
return str(_js_bend_call(payload))
_env.define(lumbda.S('bend!-call'), _bend_call_prim)
# Portal save/resume REPL bridge. Mirrors the C tier's
# (portal-snapshot! NAME) / (portal-load! NAME): writes/reads
# /tmp/<name>.portal so the JS side can round-trip blobs to vault.
import os
os.makedirs('/tmp', exist_ok=True)
def _portal_snapshot_prim(args, env):
if not args or not isinstance(args[0], str):
raise lumbda.LispErr('portal-snapshot!: expected string name')
path = f'/tmp/{args[0]}.portal'
g = env.g if env.g else _env
lumbda.portal_save(g, path)
return path
def _portal_load_prim(args, env):
if not args or not isinstance(args[0], str):
raise lumbda.LispErr('portal-load!: expected string name')
path = f'/tmp/{args[0]}.portal'
restored_env, _cont = lumbda.portal_resume(path, _env)
# Copy bindings from the restored env into our live global env so
# subsequent evals see them. portal_resume gives us back a fresh
# env with the loaded bindings; we merge into _env in-place.
for sym, val in restored_env.b.items():
_env.b[sym] = val
return True
_env.define(lumbda.S('portal-snapshot!'), _portal_snapshot_prim)
_env.define(lumbda.S('portal-load!'), _portal_load_prim)
# Streaming stdout wrapper every write() also calls back into JS so
# the worker can postMessage chunks to the UI during the eval. The
# StringIO behind it still captures everything for the final return.
from js import _lumbdaPyEmitChunk as _js_emit_chunk
class _StreamingStdout(io.StringIO):
def write(self, s):
n = super().write(s)
try:
_js_emit_chunk(s)
except Exception:
pass
return n
def _lumbda_eval(src):
buf = io.StringIO()
buf = _StreamingStdout()
old = sys.stdout
sys.stdout = buf
last = None
@ -81,15 +131,39 @@ def _lumbda_eval(src):
if out and not out.endswith("\\n"):
out += "\\n"
out += rep
try:
_js_emit_chunk(("" if out.endswith(rep) else "\\n") + rep)
except Exception:
pass
return out
`);
return {
async evalLisp(src) {
async evalLisp(src, onChunk) {
pyodide.globals.set("_src_in", src);
return await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
refs.currentOnChunk = onChunk || null;
try {
return await pyodide.runPythonAsync("_lumbda_eval(_src_in)");
} finally {
refs.currentOnChunk = null;
}
},
setBendUrl(url) { refs.bendUrl = url || null; },
// Portal save/resume bridge to vault — same shape as the C tier
// loader. Pyodide.FS is the same Emscripten FS, so the path
// /tmp/<name>.portal is reachable from JS exactly as in C.
portalSave(name) {
const path = `/tmp/${name}.portal`;
try {
const bytes = pyodide.FS.readFile(path);
return new TextDecoder().decode(bytes);
} catch (e) { return null; }
},
portalLoad(name, blob) {
const path = `/tmp/${name}.portal`;
try { pyodide.FS.mkdir("/tmp"); } catch (e) { /* exists */ }
pyodide.FS.writeFile(path, blob);
},
heapStats() {
// Pyodide's runtime memory is the Emscripten linear memory.
// CPython's GC reclaims behind the scenes, so this number

View file

@ -120,7 +120,11 @@ body.repl {
top: 0;
z-index: 40;
display: grid;
grid-template-columns: auto auto 1fr auto auto auto auto;
/* tabs · new-tab · spacer · heap-pressure (injected by repl.js) ·
* reboot · clear · cancel · lock 8 cells. Extend if the toolbar
* grows; CSS Grid drops trailing children onto a new row, which is
* how lock used to wrap. */
grid-template-columns: auto auto 1fr auto auto auto auto auto;
gap: 0.3rem;
align-items: center;
padding: 0.4rem 0.4rem;
@ -181,6 +185,52 @@ body.repl {
padding: 0 0.4rem;
}
/* Portal bar REPL save/resume controls. Sits between tabbar and
* transcript. Hidden when the active tier has no portal support yet
* (the asm-wat tier stays grey). */
.portal-bar {
display: grid;
grid-template-columns: auto auto 1fr;
gap: 0.4rem;
align-items: center;
padding: 0.2rem 0.5rem;
background: var(--code-bg);
border-bottom: 1px solid var(--rule);
font-size: 0.78em;
font-family: var(--mono);
}
.portal-bar .portal-label {
color: var(--muted);
}
.portal-bar .portal-chips {
display: grid;
grid-auto-flow: column;
grid-auto-columns: max-content;
gap: 0.3rem;
overflow-x: auto;
}
.portal-chips .chip {
display: inline-grid;
grid-template-columns: auto auto;
gap: 0.3rem;
align-items: center;
background: var(--pane-bg);
border: 1px solid var(--rule);
border-radius: 3px;
padding: 0.1rem 0.4rem;
color: var(--green);
cursor: pointer;
white-space: nowrap;
}
.portal-chips .chip:hover { border-color: var(--green); }
.portal-chips .chip .chip-close {
color: var(--muted);
font-size: 0.85em;
cursor: pointer;
padding: 0 0.15rem;
}
.portal-chips .chip .chip-close:hover { color: var(--err); }
/* ─── Transcript ───────────────────────────────────────────────── */
/* The REPL stream is the page's only scroller. Header stays pinned;

View file

@ -29,9 +29,9 @@ export async function getTier(name, onLoad) {
return cache[name];
}
export async function evalOnTier(name, src, onLoad) {
export async function evalOnTier(name, src, onLoad, onChunk) {
const tier = await getTier(name, onLoad);
return tier.evalLisp(src);
return tier.evalLisp(src, onChunk);
}
export function heapStats(name) {
@ -39,3 +39,20 @@ export function heapStats(name) {
if (!tier || !tier.heapStats) return null;
return tier.heapStats();
}
// Portal — REPL save/resume bridge. portalSave reads a snapshot blob
// from the tier (MEMFS on the C tier, similar bridges on others as
// they land). Returns null when the tier hasn't implemented portals
// yet (asm-wat today) so the caller can surface a friendly message
// instead of crashing. portalLoad is the symmetric write-in path.
export function portalSave(name, checkpointName) {
const tier = cache[name];
if (!tier || !tier.portalSave) return null;
return tier.portalSave(checkpointName);
}
export function portalLoad(name, checkpointName, blob) {
const tier = cache[name];
if (!tier || !tier.portalLoad) return false;
tier.portalLoad(checkpointName, blob);
return true;
}

View file

@ -350,6 +350,16 @@ header code {
white-space: pre;
font-family: var(--mono);
}
/* Streamed output: each emit_chunk slice that ended with \n becomes
its own <div class="stream-line"> child of the <pre>. Block display
guarantees vertical stacking regardless of how the browser handles
raw \n inside a sequence of text nodes the bug we kept chasing
in firefox where "tick 0\ntick 10000\n..." rendered horizontally
even though each text node clearly contained the newline byte. */
#output .tier-block .stream-line {
display: block;
white-space: pre;
}
#output .err { color: var(--err); }
/* ─── Footer ────────────────────────────────────────────────────── */

View file

@ -3,7 +3,7 @@
// live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()).
import { evalOnTier, setBendUrl, heapStats } from "./runner.js";
import { evalOnTier, setBendUrl, heapStats, portalSave, portalLoad } from "./runner.js";
self.onmessage = async (e) => {
const { kind } = e.data;
@ -17,12 +17,77 @@ self.onmessage = async (e) => {
self.postMessage({ kind: "heap", runId: e.data.runId, tier: e.data.tier, stats: heapStats(e.data.tier) });
return;
}
if (kind === "portal-save") {
// Pulls the most-recent /tmp/<name>.portal out of the tier's
// MEMFS after the lisp side ran (portal-snapshot! NAME). Main
// thread encrypts and stuffs it into the vault.
const blob = portalSave(e.data.tier, e.data.name);
self.postMessage({ kind: "portal-save", runId: e.data.runId, name: e.data.name, blob });
return;
}
if (kind === "portal-load") {
// Hydrates MEMFS from a vault-decrypted blob so a subsequent
// (portal-load! NAME) eval finds the file ready.
const ok = portalLoad(e.data.tier, e.data.name, e.data.blob);
self.postMessage({ kind: "portal-load", runId: e.data.runId, name: e.data.name, ok });
return;
}
if (kind !== "eval") return;
const { runId, tier, src } = e.data;
try {
const output = await evalOnTier(tier, src, (loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
});
const output = await evalOnTier(
tier,
src,
(loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
},
// Stream every print/display from the tier to the main
// thread as it happens. Sync XHR inside bend!-call still
// blocks the worker, but displays BEFORE/AFTER the bend
// round-trip surface immediately instead of waiting for
// the whole eval to finish. Long demos feel alive.
//
// Defensive newline normalization: the C-tier loader
// adds the trailing \n that Emscripten's Module.print
// strips, but we kept seeing horizontal output in fox's
// Firefox tab as if the \n was lost somewhere on the
// wire. To rule out anything between here and the main
// thread, split each chunk on \n at the source and post
// one message per line — newline preserved as a flag
// rather than a byte. The main-thread receiver knows to
// re-add the line break.
(chunk) => {
if (!chunk) return;
// Split on \n at the source and post TWO separate
// message kinds: chunk-text (visible bytes, never
// containing a newline) and chunk-eol (a bare event
// marking end-of-line). Newlines no longer travel
// as bytes — they're typed messages. Whatever was
// eating the \n between Module.print and the DOM
// in fox's tab is bypassed.
let start = 0;
for (let i = 0; i < chunk.length; i++) {
if (chunk.charCodeAt(i) === 10) {
if (i > start) {
self.postMessage({
kind: "chunk-text",
runId, tier,
text: chunk.slice(start, i),
});
}
self.postMessage({ kind: "chunk-eol", runId, tier });
start = i + 1;
}
}
if (start < chunk.length) {
self.postMessage({
kind: "chunk-text",
runId, tier,
text: chunk.slice(start),
});
}
},
);
self.postMessage({ kind: "done", runId, output });
} catch (err) {
self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });