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:
parent
f528d6df43
commit
d4380c64c7
19 changed files with 776 additions and 62 deletions
|
|
@ -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 \
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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">λ></span>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
|
|
@ -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">λ></span>
|
||||
|
|
|
|||
|
|
@ -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
|
|
@ -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">λ></span>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 ────────────────────────────────────────────────────── */
|
||||
|
|
|
|||
|
|
@ -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) });
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue