Adds a tabbar button that signals the SAB pause atomic, waits for
the eval to unwind via lisp_error/LispErr("paused"), then drops
into saveCheckpoint so the resulting snapshot lands in the visible
chip strip with a user-chosen name. Workers stay alive so the next
input runs immediately — this is the "I want to inspect/save state
but keep working" path, distinct from autoPauseTab's
"I'm leaving this tab" flow.
The button enables alongside cancel whenever an eval is running and
SAB is available; on plain http.server (no COOP/COEP) it stays
disabled and an alert points at make serve-repl. Asm tier gets a
"not supported on asm tier yet" alert since the WAT interpreter
has no in-eval poll site.
Tabbar grid expanded from 8 to 9 columns; comment updated to track.
Tab switching during a long-running eval used to silently abandon
the calc — output stopped streaming, no snapshot, nothing to come
back to. Now setActiveTab pauses the outgoing tab's eval (and
optionally portal-saves the env), terminates the worker, and on
re-entry hydrates + re-fires the original input.
Pieces:
* serve-coop.py + make serve-repl — dev server that emits
Cross-Origin-Opener-Policy: same-origin and
Cross-Origin-Embedder-Policy: require-corp so SharedArrayBuffer
is constructable in the browser. Same headers production needs.
* C tier eval-loop pause poll — c/eval.c grows lumbda_check_pause(),
guarded by #ifdef LUMBDA_WASM. Called at the top of leval()'s
while(1); masked to every 1024th iteration so the polling cost
stays under noise floor. When the JS-library import
js_lumbda_pause_requested returns 1, lisp_error("paused")
longjmps out so module-global env survives intact for the
portal-snapshot that follows.
* SAB plumbing — main thread allocates new SharedArrayBuffer(4),
hands it through worker config → runner.setPauseFlag →
lumbda-c.loader.setPauseFlag → globalThis._lumbdaCPauseFlag.
Atomics.store / Atomics.load on index 0 is the signalling
channel. Falls back to null when COOP/COEP isn't isolated, in
which case pause degrades to a hard worker.terminate().
* autoPauseTab() — on setActiveTab away, snapshots the tier
(C tier with SAB) or hard-cancels (other tiers / no SAB),
stashes tab.autoPause = {tier, blob, inputSrc, savedAt},
terminates the workers so the heap is reclaimed.
* autoResumeTab() — on setActiveTab into a tab with autoPause,
reboots the tier, hydrates MEMFS, runs (portal-load! ...), then
re-fires the original input via sendInput so the eval restarts
from the saved state. Asm + Python paths re-run from scratch
until their poll sites land.
Also closes two UX papercuts from fox: chip ⇣ export icon bumped
from 0.85em muted to 1em green so it's actually discoverable; the
scope toggle now reads "scope: this tab" / "scope: all tabs" so the
button label describes the state rather than a target.
Three UX gaps on the portal-bar closed in one pass plus a defensive
fix on the C tier's heap probe:
* Overwrite guard — saving with an existing name asks 'overwrite?'
with the existing entry's tier + savedAt. Rename via dbl-click on
the chip label; same overwrite guard applies on rename.
* Export / import — a ⇣ icon on each chip downloads it as
<name>.portal.json (opaque blob for c/python, replay-inputs for
asm). A 📁 import button on the portal-bar accepts a .portal.json
file via hidden <input type="file">; collisions prompt overwrite,
decline auto-suffixes (baseName-2, -3, …) so importing a 2nd copy
always lands somewhere.
* Cross-tab restore — a 📂 this tab / 🌐 all tabs toggle switches
the chip strip between the active tab's checkpoints and every
tab's. Global chips render as 'name · tabName' with a dashed
border; click restores the snapshot into the active tab (the
saved cp is passed through restoreCheckpoint's new sourceCp
argument so the chip doesn't need a checkpoints[name] match on
the active tab). Edit/delete are hidden in global mode — the
user switches to the owning tab to manage chips.
* heapStats defensive — wasm/c/lumbda-c.loader.js now returns null
when module.HEAPU8 isn't live yet (caught by the heap poll firing
during the tiny window between tier reboot and Module init), so a
restore no longer surfaces 'Cannot read properties of undefined
(reading byteLength)' as a TypeError.
Smoke-tested headlessly: overwrite confirm fires with the expected
message; rename via dblclick swaps the label; ⇣ produces a download
named '<name>.portal.json'; toggle shows both tabs' chips with the
'· tabName' annotation; import round-trips back into the receiving
tab. Zero page errors across the full flow.
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.
Header (logo + tagline) is the only fixed region. Footer removed.
Everything else — tab bar, transcript, prompt — now lives inside one
.repl-stream scroller. A fresh session shows the prompt right under
the tabs near the top; as entries arrive the prompt drifts down with
them. Tabs use a dashed bottom rule instead of a heavy bar so they
read as the start of the stream rather than a separate chrome strip.
Reads as one continuous stream now. Each entry is just:
λ> <input>
<output> ; tier · NNms
No left border, no boxed cards, no side-column tier label. Output
indents under the prompt (3ch) using monospace ch units. Tier+time
render as a Lisp-comment-style suffix in muted color.
Prompt bar: borderless textarea on the code-bg surface so the input
visually joins the transcript above. Placeholder cut to "(+ 1 2)" —
the surrounding text already explains the semantics.
Multi-line inputs keep prompt continuation marks ("..").
Interactive REPL at lumbda.com/repl with:
- multi-tab sessions (click + to add, × to close, double-click to rename)
- per-tab tier selector (python/c/asm/all-three race)
- persistent transcripts encrypted in localStorage via Web Crypto
(PBKDF2 + AES-GCM, vault id = SHA-256(password || device-salt) —
same pattern as unsandbox's vault-encryption-design.md, native
crypto.subtle API instead of CryptoJS)
- ephemeral mode (skip vault, transcripts vanish on reload)
- one worker per (tab × tier) — state persists across evals in a tab
- reboot tier button (terminate this tab's worker, fresh state next eval)
- cancel button (kills the running worker in active tab)
Home page now links to both /playground/ and /repl/.
Tier state itself does NOT persist across reloads — the transcript does,
but defines/set!/hash-tables vanish with the worker. Portal save/resume
in WAT (deferred) will let a tier session survive close+reopen.