lumbda/wasm/repl/index.html
russell@unturf.com df154b9a56
repl: auto-pause + portal-save when leaving a tab mid-eval, resume on return
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.
2026-06-15 09:02:53 -04:00

115 lines
5.6 KiB
HTML

<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>lumbda repl — three tiers, persistent sessions</title>
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="repl.css">
<script type="importmap">
{
"imports": {
"@codemirror/state": "https://esm.sh/*@codemirror/state@6.4.1",
"@codemirror/view": "https://esm.sh/*@codemirror/view@6.34.1",
"@codemirror/commands": "https://esm.sh/*@codemirror/commands@6.6.2",
"@codemirror/language": "https://esm.sh/*@codemirror/language@6.10.3",
"@codemirror/legacy-modes/mode/scheme": "https://esm.sh/*@codemirror/legacy-modes@6.4.1/mode/scheme",
"@codemirror/theme-one-dark": "https://esm.sh/*@codemirror/theme-one-dark@6.1.2",
"@lezer/highlight": "https://esm.sh/*@lezer/highlight@1.2.1",
"@lezer/common": "https://esm.sh/*@lezer/common@1.2.3",
"style-mod": "https://esm.sh/*style-mod@4.1.2",
"crelt": "https://esm.sh/*crelt@1.0.6",
"w3c-keyname": "https://esm.sh/*w3c-keyname@2.2.8"
}
}
</script>
</head>
<body class="repl">
<header>
<a class="brand" href="../" aria-label="lumbda home">
<img class="logo" src="lumbda-logo-green.png" alt="" aria-hidden="true">
<h1 aria-label="lumbda.">lumbda<span class="period" aria-hidden="true">.</span></h1>
</a>
<p class="tagline">three tiers, persistent sessions</p>
</header>
<div id="lock-screen" class="lock-screen">
<div class="lock-card">
<h2>unlock</h2>
<p>type a password to unlock or create a vault. every keystroke and
transcript line stays inside your browser &mdash; nothing leaves
this tab.</p>
<details class="lock-explain">
<summary>how the encryption works</summary>
<ul>
<li>your password is run through <strong>PBKDF2</strong> (200,000
iterations, SHA-256) with a random 32-byte salt generated
once per device and stored in <code>localStorage</code>.</li>
<li>that derives an <strong>AES-GCM 256-bit key</strong>. all
transcripts are encrypted with this key before any
<code>localStorage.setItem()</code> call.</li>
<li>the <strong>vault id</strong> is
<code>SHA-256(password || salt)</code> &mdash; different
passwords on the same device address completely independent
vaults. wrong password = decryption fails, you don't see
other vaults' contents.</li>
<li>same protocol as <a href="https://unsandbox.com/blog/vault-encryption-design"
target="_blank">unsandbox's vault design</a>, implemented
on the native <code>crypto.subtle</code> API instead of a
shipped CryptoJS bundle. source:
<a href="crypto.js" target="_blank"><code>crypto.js</code></a>.</li>
<li>passwords are never sent anywhere &mdash; not to lumbda.com,
not to a backend, not to a worker. nothing to leak in
transit because nothing transits.</li>
<li><strong>ephemeral</strong> skips the vault entirely &mdash;
nothing is written to disk; closing the tab loses everything.
use this on a shared machine when persistence would be a
footgun.</li>
</ul>
</details>
<input id="password" type="password" autocomplete="off" autofocus placeholder="password">
<div class="lock-row">
<button id="unlock" class="primary">unlock vault</button>
<button id="fresh" class="secondary" title="skip the vault, run ephemerally — transcripts vanish on reload">ephemeral</button>
</div>
<p class="lock-note" id="lock-note"></p>
</div>
</div>
<main id="repl-stream" class="repl-stream" hidden>
<section class="tabbar" id="tabbar">
<div class="tabs" id="tabs"></div>
<button id="new-tab" class="ghost" title="new repl session">+</button>
<span class="spacer"></span>
<button id="reset-tier" class="ghost" title="reboot the worker for the current tab — state lost">↻ reboot tier</button>
<button id="clear-log" class="ghost" title="clear the transcript">clear log</button>
<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>
<button id="portal-import" class="ghost" title="import a .portal.json file">📁 import</button>
<button id="portal-scope" class="ghost" title="toggle between this tab's checkpoints and every tab's">scope: this tab 📂</button>
<input id="portal-import-file" type="file" accept=".json,.portal,application/json" hidden>
<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>
<textarea id="input" rows="1" spellcheck="false"
placeholder="(+ 1 2)"></textarea>
<select id="tier-select" title="which tier evaluates this input">
<option value="python">python (pyodide)</option>
<option value="c" selected>c (emcc)</option>
<option value="asm">asm (wat)</option>
<option value="all">all three (race)</option>
</select>
<button id="send" class="primary">send</button>
</div>
</main>
<script type="module" src="repl.js"></script>
</body>
</html>