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
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