diff --git a/wasm/app/runner.js b/wasm/app/runner.js index bad777c..aa602af 100644 --- a/wasm/app/runner.js +++ b/wasm/app/runner.js @@ -33,3 +33,9 @@ export async function evalOnTier(name, src, onLoad) { const tier = await getTier(name, onLoad); return tier.evalLisp(src); } + +export function heapStats(name) { + const tier = cache[name]; + if (!tier || !tier.heapStats) return null; + return tier.heapStats(); +} diff --git a/wasm/app/worker.mjs b/wasm/app/worker.mjs index 9fbc7a5..760c680 100644 --- a/wasm/app/worker.mjs +++ b/wasm/app/worker.mjs @@ -3,7 +3,7 @@ // live ms counter actually ticks AND so cancel works (main thread // terminates this worker via worker.terminate()). -import { evalOnTier, setBendUrl } from "./runner.js"; +import { evalOnTier, setBendUrl, heapStats } from "./runner.js"; self.onmessage = async (e) => { const { kind } = e.data; @@ -11,6 +11,12 @@ self.onmessage = async (e) => { setBendUrl(e.data.bendUrl); return; } + if (kind === "heap") { + // Synchronous read — no eval is running because the worker is + // single-threaded and main thread only sends this between evals. + self.postMessage({ kind: "heap", runId: e.data.runId, tier: e.data.tier, stats: heapStats(e.data.tier) }); + return; + } if (kind !== "eval") return; const { runId, tier, src } = e.data; try { diff --git a/wasm/asm/lumbda-asm.loader.js b/wasm/asm/lumbda-asm.loader.js index 8da41de..e724244 100644 --- a/wasm/asm/lumbda-asm.loader.js +++ b/wasm/asm/lumbda-asm.loader.js @@ -72,6 +72,12 @@ async function _bootstrap() { return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)); }, setBendUrl(url) { refs.bendUrl = url || null; }, + heapStats() { + return { + used: exp.lumbda_heap_used(), + total: exp.lumbda_heap_total(), + }; + }, }; } diff --git a/wasm/asm/lumbda.wat b/wasm/asm/lumbda.wat index bced19b..1609a43 100644 --- a/wasm/asm/lumbda.wat +++ b/wasm/asm/lumbda.wat @@ -3870,4 +3870,13 @@ (global.get $output_len)) (func $lumbda_source_ptr (export "lumbda_source_ptr") (result i32) (i32.const 0x20000)) + + ;; Heap diagnostics — JS-side memory pressure indicators. heap_used is + ;; bytes consumed by the bump allocator since boot; heap_total is the + ;; current memory.size in bytes. No GC yet so heap_used only grows; + ;; the REPL "reboot tier" button is the user-facing reclaim path. + (func $lumbda_heap_used (export "lumbda_heap_used") (result i32) + (i32.sub (global.get $heap_ptr) (i32.const 0x30000))) + (func $lumbda_heap_total (export "lumbda_heap_total") (result i32) + (i32.mul (memory.size) (i32.const 65536))) ) diff --git a/wasm/c/lumbda-c.loader.js b/wasm/c/lumbda-c.loader.js index 5331311..0a2f5e3 100644 --- a/wasm/c/lumbda-c.loader.js +++ b/wasm/c/lumbda-c.loader.js @@ -43,6 +43,14 @@ async function _bootstrap() { if (errMsg) out += errMsg + "\n"; return out; }, + heapStats() { + // Emscripten exposes the linear memory directly. There's no + // free path right now (LUMBDA_NO_BOEHM), so used = total — + // every malloc accumulates until reload. Documented and + // surfaced in the REPL so the user sees the pressure. + const total = module.HEAPU8.byteLength; + return { used: total, total }; + }, }; } diff --git a/wasm/dist-repl/asm/lumbda-asm.loader.js b/wasm/dist-repl/asm/lumbda-asm.loader.js index 8da41de..e724244 100644 --- a/wasm/dist-repl/asm/lumbda-asm.loader.js +++ b/wasm/dist-repl/asm/lumbda-asm.loader.js @@ -72,6 +72,12 @@ async function _bootstrap() { return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)); }, setBendUrl(url) { refs.bendUrl = url || null; }, + heapStats() { + return { + used: exp.lumbda_heap_used(), + total: exp.lumbda_heap_total(), + }; + }, }; } diff --git a/wasm/dist-repl/asm/lumbda-asm.wasm b/wasm/dist-repl/asm/lumbda-asm.wasm index 3a30eb5..55c8e9c 100644 Binary files a/wasm/dist-repl/asm/lumbda-asm.wasm and b/wasm/dist-repl/asm/lumbda-asm.wasm differ diff --git a/wasm/dist-repl/c/lumbda-c.loader.js b/wasm/dist-repl/c/lumbda-c.loader.js index 5331311..0a2f5e3 100644 --- a/wasm/dist-repl/c/lumbda-c.loader.js +++ b/wasm/dist-repl/c/lumbda-c.loader.js @@ -43,6 +43,14 @@ async function _bootstrap() { if (errMsg) out += errMsg + "\n"; return out; }, + heapStats() { + // Emscripten exposes the linear memory directly. There's no + // free path right now (LUMBDA_NO_BOEHM), so used = total — + // every malloc accumulates until reload. Documented and + // surfaced in the REPL so the user sees the pressure. + const total = module.HEAPU8.byteLength; + return { used: total, total }; + }, }; } diff --git a/wasm/dist-repl/python/lumbda-py.js b/wasm/dist-repl/python/lumbda-py.js index d31accd..c716882 100644 --- a/wasm/dist-repl/python/lumbda-py.js +++ b/wasm/dist-repl/python/lumbda-py.js @@ -56,6 +56,13 @@ def _lumbda_eval(src): pyodide.globals.set("_src_in", src); return await pyodide.runPythonAsync("_lumbda_eval(_src_in)"); }, + heapStats() { + // Pyodide's runtime memory is the Emscripten linear memory. + // CPython's GC reclaims behind the scenes, so this number + // rises and falls naturally as objects die. + const total = pyodide._module.HEAPU8.byteLength; + return { used: total, total }; + }, }; } diff --git a/wasm/dist-repl/repl.css b/wasm/dist-repl/repl.css index e476dac..7f79a60 100644 --- a/wasm/dist-repl/repl.css +++ b/wasm/dist-repl/repl.css @@ -1,12 +1,10 @@ /* lumbda repl — grid-only layout. Inherits palette + base from style.css. */ body.repl { - /* Body scrolls naturally. The prompt-bar is position: fixed so it - * stays glued to the viewport bottom; the transcript reserves bottom - * padding equal to the prompt-bar height so its last line isn't hidden - * underneath. */ - min-height: 100vh; - overflow: auto; + /* Plain document flow — no min-height, no overflow. The page scrolls + * naturally when content exceeds the viewport; the prompt-bar uses + * position: sticky so it stays glued to the bottom of the viewport in + * that case, and sits right under the transcript otherwise. */ } /* ─── Lock screen overlay ──────────────────────────────────────── */ @@ -115,14 +113,20 @@ body.repl { /* ─── Tab bar ──────────────────────────────────────────────────── */ .tabbar { + /* Sticks to the top of the viewport so it stays visible after the + * header scrolls away — keeps the active-session controls reachable + * even after a long transcript pushes them off-screen. */ + position: sticky; + top: 0; + z-index: 40; display: grid; grid-template-columns: auto auto 1fr auto auto auto auto; gap: 0.3rem; align-items: center; - padding: 0.2rem 0 0.4rem; - background: transparent; - border-bottom: 1px dashed var(--rule); - margin-bottom: 0.4rem; + padding: 0.4rem 0.4rem; + margin: 0 0 0.4rem; + background: var(--code-bg); + border-bottom: 1px solid var(--rule); } .tabs { display: grid; @@ -169,6 +173,13 @@ body.repl { border-color: var(--green); color: var(--green); } .tabbar .ghost:disabled { opacity: 0.4; cursor: default; } +.tabbar .heap-pressure { + color: var(--muted); + font-size: 0.72em; + font-family: var(--mono); + white-space: nowrap; + padding: 0 0.4rem; +} /* ─── Transcript ───────────────────────────────────────────────── */ @@ -177,21 +188,20 @@ body.repl { * fresh sessions show the prompt up near the top and it drifts down * with each new entry. */ .repl-stream { - padding: 0.5rem 1rem 0; background: var(--code-bg); display: grid; grid-template-rows: auto auto auto; align-content: start; - /* Leave room for the fixed prompt bar at the viewport bottom. */ - padding-bottom: calc(3.6rem + env(safe-area-inset-bottom, 0)); } .transcript { + /* Same horizontal inset as the active prompt-bar so prior λ> sigils + * line up with the live one. No max-width / margin: auto here — a + * centered transcript on wide viewports would mis-align with the + * left-flush prompt-bar. Long lines wrap inside the entry instead. */ + padding: 0.4rem 0.4rem 0; font-family: var(--mono); font-size: 0.92em; line-height: 1.45; - max-width: 90rem; - margin: 0 auto; - width: 100%; } .transcript .entry { margin: 0; @@ -234,16 +244,17 @@ body.repl { /* ─── Prompt bar ───────────────────────────────────────────────── */ .prompt-bar { - position: fixed; - left: 0; right: 0; bottom: 0; + /* Flows directly under the last transcript entry — connected, no gap. + * Sticks to the viewport bottom once the page scrolls past it. */ + position: sticky; + bottom: 0; z-index: 50; display: grid; grid-template-columns: auto 1fr auto auto; gap: 0.4rem; align-items: center; - padding: 0.5rem 1rem; + padding: 0.3rem 0.4rem 0.5rem; background: var(--code-bg); - border-top: 1px solid var(--rule); } .prompt-bar .prompt-sigil { color: var(--green); diff --git a/wasm/dist-repl/repl.js b/wasm/dist-repl/repl.js index 7b2f8d5..088dd26 100644 --- a/wasm/dist-repl/repl.js +++ b/wasm/dist-repl/repl.js @@ -287,8 +287,64 @@ function renderAll() { } transcriptEl.appendChild(block); } - // Body owns the scroll now; jump it to the latest entry. - window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); + // 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 + // sticks at the top because the transcript hasn't been laid out yet. + requestAnimationFrame(() => { + window.scrollTo({ top: document.documentElement.scrollHeight, behavior: "instant" }); + }); +} + +// ─── History navigation (readline-style up/down) ──────────────────── +// Each tab owns its own history; the active tab's draft is preserved so +// that walking back into history doesn't eat what the user was typing. +const history = { idx: null, draft: "" }; + +function historyEntries() { + // Transcript's `input` fields, deduplicated against the immediate + // predecessor — pressing up shouldn't make you hit the same line + // twice in a row when you just submitted it. + const tab = activeTab(); + if (!tab) return []; + const out = []; + for (const e of tab.transcript) { + if (out.length && out[out.length - 1] === e.input) continue; + out.push(e.input); + } + return out; +} + +function historyPrev() { + const entries = historyEntries(); + if (entries.length === 0) return; + if (history.idx === null) { + history.draft = inputEl.value; + history.idx = entries.length - 1; + } else if (history.idx > 0) { + history.idx--; + } + inputEl.value = entries[history.idx]; + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); +} + +function historyNext() { + const entries = historyEntries(); + if (history.idx === null) return; + if (history.idx >= entries.length - 1) { + history.idx = null; + inputEl.value = history.draft; + history.draft = ""; + } else { + history.idx++; + inputEl.value = entries[history.idx]; + } + inputEl.setSelectionRange(inputEl.value.length, inputEl.value.length); +} + +function resetHistory() { + history.idx = null; + history.draft = ""; } // ─── Input handling ───────────────────────────────────────────────── @@ -302,6 +358,7 @@ async function sendInput() { const entry = { input: src, results: [], kind: "ok" }; tab.transcript.push(entry); inputEl.value = ""; + resetHistory(); sendBtn.disabled = true; cancelBtn.disabled = false; renderAll(); @@ -332,6 +389,48 @@ async function sendInput() { } } +// ─── Heap pressure indicator ─────────────────────────────────────── +// Polls each loaded tier in the active tab and prints a compact +// "py 12M · c 32M · asm 4M" string next to the tabbar buttons. +const heapEl = document.createElement("span"); +heapEl.className = "heap-pressure"; +heapEl.title = "tier worker memory — \"reboot tier\" reclaims on demand"; + +function formatBytes(n) { + if (n < 1024) return `${n}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}K`; + return `${(n / (1024 * 1024)).toFixed(1)}M`; +} + +async function pollHeap() { + const tab = activeTab(); + if (!tab) return; + const tiers = ["python", "c", "asm"]; + const parts = []; + for (const t of tiers) { + const k = workerKey(tab.id, t); + const w = state.workers[k]; + if (!w) continue; + const runId = ++state.nextRunId; + const stats = await new Promise((resolve) => { + const handler = (e) => { + if (e.data.kind !== "heap" || e.data.runId !== runId) return; + w.removeEventListener("message", handler); + resolve(e.data.stats); + }; + w.addEventListener("message", handler); + w.postMessage({ kind: "heap", runId, tier: t }); + // Timeout safety — eval-busy workers won't reply. + setTimeout(() => { w.removeEventListener("message", handler); resolve(null); }, 200); + }); + if (stats && stats.used != null) { + parts.push(`${t.slice(0, 3)} ${formatBytes(stats.used)}`); + } + } + heapEl.textContent = parts.length ? parts.join(" · ") : ""; +} +setInterval(pollHeap, 2000); + // ─── Wire up ──────────────────────────────────────────────────────── unlockBtn.addEventListener("click", tryUnlock); freshBtn.addEventListener("click", enterEphemeral); @@ -350,11 +449,47 @@ clearLogBtn.addEventListener("click", () => { }); cancelBtn.addEventListener("click", cancelAllPendingInActiveTab); lockBtn.addEventListener("click", relock); +// Insert heap pressure indicator into the tabbar after the spacer. +document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn); inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { e.preventDefault(); sendInput(); + return; + } + // Up/Down navigate per-tab history when the caret is in the only + // (or first/last) row — otherwise they belong to the textarea's + // natural multi-line navigation. + if (e.key === "ArrowUp") { + const before = inputEl.value.substring(0, inputEl.selectionStart); + if (!before.includes("\n")) { + e.preventDefault(); + historyPrev(); + } + return; + } + if (e.key === "ArrowDown") { + const after = inputEl.value.substring(inputEl.selectionStart); + if (!after.includes("\n")) { + e.preventDefault(); + historyNext(); + } + return; + } +}); +inputEl.addEventListener("input", () => { + // Any keystroke that isn't an arrow drops history navigation — + // edits are now the user's own draft, not the historical entry. + if (history.idx !== null && document.activeElement === inputEl) { + // We can't reliably distinguish arrow-induced updates here, so + // we only invalidate when the buffer has actually diverged from + // the historical entry the cursor was on. + const entries = historyEntries(); + if (entries[history.idx] !== inputEl.value) { + history.draft = inputEl.value; + history.idx = null; + } } }); sendBtn.addEventListener("click", sendInput); diff --git a/wasm/dist-repl/runner.js b/wasm/dist-repl/runner.js index bad777c..aa602af 100644 --- a/wasm/dist-repl/runner.js +++ b/wasm/dist-repl/runner.js @@ -33,3 +33,9 @@ export async function evalOnTier(name, src, onLoad) { const tier = await getTier(name, onLoad); return tier.evalLisp(src); } + +export function heapStats(name) { + const tier = cache[name]; + if (!tier || !tier.heapStats) return null; + return tier.heapStats(); +} diff --git a/wasm/dist-repl/style.css b/wasm/dist-repl/style.css index bd3af40..01e4413 100644 --- a/wasm/dist-repl/style.css +++ b/wasm/dist-repl/style.css @@ -81,6 +81,9 @@ header { width: 3.2rem; height: auto; display: block; + /* Flip vertically + horizontally (= 180°) to match the inverted-λ + * convention from the homepage's hand-drawn artwork. */ + transform: scale(-1, -1); } header h1 { @@ -130,7 +133,8 @@ header code { border-bottom: 1px solid var(--rule); display: grid; grid-template-columns: auto auto auto auto 1fr; - gap: 1rem; + grid-template-rows: auto auto; + gap: 0.3rem 1rem; align-items: center; background: var(--bg); } @@ -191,9 +195,14 @@ header code { } .controls .status { + /* Tuck the live ms counter directly under the run + cancel buttons + * (columns 3-4 in the controls grid) so the eye finds the elapsed + * timer next to the action that started it. */ + grid-column: 3 / span 2; + grid-row: 2; + justify-self: start; color: var(--muted); font-size: 0.8em; - justify-self: end; } .controls .status.busy { color: var(--busy); } .controls .status.warn { color: var(--busy); } diff --git a/wasm/dist-repl/worker.mjs b/wasm/dist-repl/worker.mjs index 9fbc7a5..760c680 100644 --- a/wasm/dist-repl/worker.mjs +++ b/wasm/dist-repl/worker.mjs @@ -3,7 +3,7 @@ // live ms counter actually ticks AND so cancel works (main thread // terminates this worker via worker.terminate()). -import { evalOnTier, setBendUrl } from "./runner.js"; +import { evalOnTier, setBendUrl, heapStats } from "./runner.js"; self.onmessage = async (e) => { const { kind } = e.data; @@ -11,6 +11,12 @@ self.onmessage = async (e) => { setBendUrl(e.data.bendUrl); return; } + if (kind === "heap") { + // Synchronous read — no eval is running because the worker is + // single-threaded and main thread only sends this between evals. + self.postMessage({ kind: "heap", runId: e.data.runId, tier: e.data.tier, stats: heapStats(e.data.tier) }); + return; + } if (kind !== "eval") return; const { runId, tier, src } = e.data; try { diff --git a/wasm/python/lumbda-py.js b/wasm/python/lumbda-py.js index d31accd..c716882 100644 --- a/wasm/python/lumbda-py.js +++ b/wasm/python/lumbda-py.js @@ -56,6 +56,13 @@ def _lumbda_eval(src): pyodide.globals.set("_src_in", src); return await pyodide.runPythonAsync("_lumbda_eval(_src_in)"); }, + heapStats() { + // Pyodide's runtime memory is the Emscripten linear memory. + // CPython's GC reclaims behind the scenes, so this number + // rises and falls naturally as objects die. + const total = pyodide._module.HEAPU8.byteLength; + return { used: total, total }; + }, }; } diff --git a/wasm/repl/repl.css b/wasm/repl/repl.css index 56ce235..7f79a60 100644 --- a/wasm/repl/repl.css +++ b/wasm/repl/repl.css @@ -173,6 +173,13 @@ body.repl { border-color: var(--green); color: var(--green); } .tabbar .ghost:disabled { opacity: 0.4; cursor: default; } +.tabbar .heap-pressure { + color: var(--muted); + font-size: 0.72em; + font-family: var(--mono); + white-space: nowrap; + padding: 0 0.4rem; +} /* ─── Transcript ───────────────────────────────────────────────── */ diff --git a/wasm/repl/repl.js b/wasm/repl/repl.js index 5dfcb18..088dd26 100644 --- a/wasm/repl/repl.js +++ b/wasm/repl/repl.js @@ -389,6 +389,48 @@ async function sendInput() { } } +// ─── Heap pressure indicator ─────────────────────────────────────── +// Polls each loaded tier in the active tab and prints a compact +// "py 12M · c 32M · asm 4M" string next to the tabbar buttons. +const heapEl = document.createElement("span"); +heapEl.className = "heap-pressure"; +heapEl.title = "tier worker memory — \"reboot tier\" reclaims on demand"; + +function formatBytes(n) { + if (n < 1024) return `${n}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}K`; + return `${(n / (1024 * 1024)).toFixed(1)}M`; +} + +async function pollHeap() { + const tab = activeTab(); + if (!tab) return; + const tiers = ["python", "c", "asm"]; + const parts = []; + for (const t of tiers) { + const k = workerKey(tab.id, t); + const w = state.workers[k]; + if (!w) continue; + const runId = ++state.nextRunId; + const stats = await new Promise((resolve) => { + const handler = (e) => { + if (e.data.kind !== "heap" || e.data.runId !== runId) return; + w.removeEventListener("message", handler); + resolve(e.data.stats); + }; + w.addEventListener("message", handler); + w.postMessage({ kind: "heap", runId, tier: t }); + // Timeout safety — eval-busy workers won't reply. + setTimeout(() => { w.removeEventListener("message", handler); resolve(null); }, 200); + }); + if (stats && stats.used != null) { + parts.push(`${t.slice(0, 3)} ${formatBytes(stats.used)}`); + } + } + heapEl.textContent = parts.length ? parts.join(" · ") : ""; +} +setInterval(pollHeap, 2000); + // ─── Wire up ──────────────────────────────────────────────────────── unlockBtn.addEventListener("click", tryUnlock); freshBtn.addEventListener("click", enterEphemeral); @@ -407,6 +449,8 @@ clearLogBtn.addEventListener("click", () => { }); cancelBtn.addEventListener("click", cancelAllPendingInActiveTab); lockBtn.addEventListener("click", relock); +// Insert heap pressure indicator into the tabbar after the spacer. +document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn); inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { diff --git a/www/playground/asm/lumbda-asm.loader.js b/www/playground/asm/lumbda-asm.loader.js index 8da41de..e724244 100644 --- a/www/playground/asm/lumbda-asm.loader.js +++ b/www/playground/asm/lumbda-asm.loader.js @@ -72,6 +72,12 @@ async function _bootstrap() { return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)); }, setBendUrl(url) { refs.bendUrl = url || null; }, + heapStats() { + return { + used: exp.lumbda_heap_used(), + total: exp.lumbda_heap_total(), + }; + }, }; } diff --git a/www/playground/asm/lumbda-asm.wasm b/www/playground/asm/lumbda-asm.wasm index d03d195..55c8e9c 100644 Binary files a/www/playground/asm/lumbda-asm.wasm and b/www/playground/asm/lumbda-asm.wasm differ diff --git a/www/playground/c/lumbda-c.loader.js b/www/playground/c/lumbda-c.loader.js index 5331311..0a2f5e3 100644 --- a/www/playground/c/lumbda-c.loader.js +++ b/www/playground/c/lumbda-c.loader.js @@ -43,6 +43,14 @@ async function _bootstrap() { if (errMsg) out += errMsg + "\n"; return out; }, + heapStats() { + // Emscripten exposes the linear memory directly. There's no + // free path right now (LUMBDA_NO_BOEHM), so used = total — + // every malloc accumulates until reload. Documented and + // surfaced in the REPL so the user sees the pressure. + const total = module.HEAPU8.byteLength; + return { used: total, total }; + }, }; } diff --git a/www/playground/python/lumbda-py.js b/www/playground/python/lumbda-py.js index d31accd..c716882 100644 --- a/www/playground/python/lumbda-py.js +++ b/www/playground/python/lumbda-py.js @@ -56,6 +56,13 @@ def _lumbda_eval(src): pyodide.globals.set("_src_in", src); return await pyodide.runPythonAsync("_lumbda_eval(_src_in)"); }, + heapStats() { + // Pyodide's runtime memory is the Emscripten linear memory. + // CPython's GC reclaims behind the scenes, so this number + // rises and falls naturally as objects die. + const total = pyodide._module.HEAPU8.byteLength; + return { used: total, total }; + }, }; } diff --git a/www/playground/runner.js b/www/playground/runner.js index bad777c..aa602af 100644 --- a/www/playground/runner.js +++ b/www/playground/runner.js @@ -33,3 +33,9 @@ export async function evalOnTier(name, src, onLoad) { const tier = await getTier(name, onLoad); return tier.evalLisp(src); } + +export function heapStats(name) { + const tier = cache[name]; + if (!tier || !tier.heapStats) return null; + return tier.heapStats(); +} diff --git a/www/playground/worker.mjs b/www/playground/worker.mjs index 9fbc7a5..760c680 100644 --- a/www/playground/worker.mjs +++ b/www/playground/worker.mjs @@ -3,7 +3,7 @@ // live ms counter actually ticks AND so cancel works (main thread // terminates this worker via worker.terminate()). -import { evalOnTier, setBendUrl } from "./runner.js"; +import { evalOnTier, setBendUrl, heapStats } from "./runner.js"; self.onmessage = async (e) => { const { kind } = e.data; @@ -11,6 +11,12 @@ self.onmessage = async (e) => { setBendUrl(e.data.bendUrl); return; } + if (kind === "heap") { + // Synchronous read — no eval is running because the worker is + // single-threaded and main thread only sends this between evals. + self.postMessage({ kind: "heap", runId: e.data.runId, tier: e.data.tier, stats: heapStats(e.data.tier) }); + return; + } if (kind !== "eval") return; const { runId, tier, src } = e.data; try { diff --git a/www/repl/asm/lumbda-asm.loader.js b/www/repl/asm/lumbda-asm.loader.js index 8da41de..e724244 100644 --- a/www/repl/asm/lumbda-asm.loader.js +++ b/www/repl/asm/lumbda-asm.loader.js @@ -72,6 +72,12 @@ async function _bootstrap() { return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)); }, setBendUrl(url) { refs.bendUrl = url || null; }, + heapStats() { + return { + used: exp.lumbda_heap_used(), + total: exp.lumbda_heap_total(), + }; + }, }; } diff --git a/www/repl/asm/lumbda-asm.wasm b/www/repl/asm/lumbda-asm.wasm index d03d195..55c8e9c 100644 Binary files a/www/repl/asm/lumbda-asm.wasm and b/www/repl/asm/lumbda-asm.wasm differ diff --git a/www/repl/c/lumbda-c.loader.js b/www/repl/c/lumbda-c.loader.js index 5331311..0a2f5e3 100644 --- a/www/repl/c/lumbda-c.loader.js +++ b/www/repl/c/lumbda-c.loader.js @@ -43,6 +43,14 @@ async function _bootstrap() { if (errMsg) out += errMsg + "\n"; return out; }, + heapStats() { + // Emscripten exposes the linear memory directly. There's no + // free path right now (LUMBDA_NO_BOEHM), so used = total — + // every malloc accumulates until reload. Documented and + // surfaced in the REPL so the user sees the pressure. + const total = module.HEAPU8.byteLength; + return { used: total, total }; + }, }; } diff --git a/www/repl/python/lumbda-py.js b/www/repl/python/lumbda-py.js index d31accd..c716882 100644 --- a/www/repl/python/lumbda-py.js +++ b/www/repl/python/lumbda-py.js @@ -56,6 +56,13 @@ def _lumbda_eval(src): pyodide.globals.set("_src_in", src); return await pyodide.runPythonAsync("_lumbda_eval(_src_in)"); }, + heapStats() { + // Pyodide's runtime memory is the Emscripten linear memory. + // CPython's GC reclaims behind the scenes, so this number + // rises and falls naturally as objects die. + const total = pyodide._module.HEAPU8.byteLength; + return { used: total, total }; + }, }; } diff --git a/www/repl/repl.css b/www/repl/repl.css index 56ce235..7f79a60 100644 --- a/www/repl/repl.css +++ b/www/repl/repl.css @@ -173,6 +173,13 @@ body.repl { border-color: var(--green); color: var(--green); } .tabbar .ghost:disabled { opacity: 0.4; cursor: default; } +.tabbar .heap-pressure { + color: var(--muted); + font-size: 0.72em; + font-family: var(--mono); + white-space: nowrap; + padding: 0 0.4rem; +} /* ─── Transcript ───────────────────────────────────────────────── */ diff --git a/www/repl/repl.js b/www/repl/repl.js index 5dfcb18..088dd26 100644 --- a/www/repl/repl.js +++ b/www/repl/repl.js @@ -389,6 +389,48 @@ async function sendInput() { } } +// ─── Heap pressure indicator ─────────────────────────────────────── +// Polls each loaded tier in the active tab and prints a compact +// "py 12M · c 32M · asm 4M" string next to the tabbar buttons. +const heapEl = document.createElement("span"); +heapEl.className = "heap-pressure"; +heapEl.title = "tier worker memory — \"reboot tier\" reclaims on demand"; + +function formatBytes(n) { + if (n < 1024) return `${n}B`; + if (n < 1024 * 1024) return `${(n / 1024).toFixed(0)}K`; + return `${(n / (1024 * 1024)).toFixed(1)}M`; +} + +async function pollHeap() { + const tab = activeTab(); + if (!tab) return; + const tiers = ["python", "c", "asm"]; + const parts = []; + for (const t of tiers) { + const k = workerKey(tab.id, t); + const w = state.workers[k]; + if (!w) continue; + const runId = ++state.nextRunId; + const stats = await new Promise((resolve) => { + const handler = (e) => { + if (e.data.kind !== "heap" || e.data.runId !== runId) return; + w.removeEventListener("message", handler); + resolve(e.data.stats); + }; + w.addEventListener("message", handler); + w.postMessage({ kind: "heap", runId, tier: t }); + // Timeout safety — eval-busy workers won't reply. + setTimeout(() => { w.removeEventListener("message", handler); resolve(null); }, 200); + }); + if (stats && stats.used != null) { + parts.push(`${t.slice(0, 3)} ${formatBytes(stats.used)}`); + } + } + heapEl.textContent = parts.length ? parts.join(" · ") : ""; +} +setInterval(pollHeap, 2000); + // ─── Wire up ──────────────────────────────────────────────────────── unlockBtn.addEventListener("click", tryUnlock); freshBtn.addEventListener("click", enterEphemeral); @@ -407,6 +449,8 @@ clearLogBtn.addEventListener("click", () => { }); cancelBtn.addEventListener("click", cancelAllPendingInActiveTab); lockBtn.addEventListener("click", relock); +// Insert heap pressure indicator into the tabbar after the spacer. +document.getElementById("tabbar").insertBefore(heapEl, resetTierBtn); inputEl.addEventListener("keydown", (e) => { if (e.key === "Enter" && !e.shiftKey) { diff --git a/www/repl/runner.js b/www/repl/runner.js index bad777c..aa602af 100644 --- a/www/repl/runner.js +++ b/www/repl/runner.js @@ -33,3 +33,9 @@ export async function evalOnTier(name, src, onLoad) { const tier = await getTier(name, onLoad); return tier.evalLisp(src); } + +export function heapStats(name) { + const tier = cache[name]; + if (!tier || !tier.heapStats) return null; + return tier.heapStats(); +} diff --git a/www/repl/worker.mjs b/www/repl/worker.mjs index 9fbc7a5..760c680 100644 --- a/www/repl/worker.mjs +++ b/www/repl/worker.mjs @@ -3,7 +3,7 @@ // live ms counter actually ticks AND so cancel works (main thread // terminates this worker via worker.terminate()). -import { evalOnTier, setBendUrl } from "./runner.js"; +import { evalOnTier, setBendUrl, heapStats } from "./runner.js"; self.onmessage = async (e) => { const { kind } = e.data; @@ -11,6 +11,12 @@ self.onmessage = async (e) => { setBendUrl(e.data.bendUrl); return; } + if (kind === "heap") { + // Synchronous read — no eval is running because the worker is + // single-threaded and main thread only sends this between evals. + self.postMessage({ kind: "heap", runId: e.data.runId, tier: e.data.tier, stats: heapStats(e.data.tier) }); + return; + } if (kind !== "eval") return; const { runId, tier, src } = e.data; try {