From ff2ef382c740f065781b824bfdadc862451fd52f Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Sun, 14 Jun 2026 17:32:48 -0400 Subject: [PATCH] gc diagnostics: per-tier heap pressure surfaced in repl tabbar MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Step 1 of the GC effort. Each tier loader now exposes heapStats(): - asm-wasm — lumbda_heap_used / lumbda_heap_total wat exports - c-wasm — emscripten linear memory size (no free path right now, so used = total; documented in the loader) - python — pyodide module linear memory size; CPython GC cycles this naturally Worker handles a "heap" message kind that round-trips the active tab's loaded tiers; repl tabbar shows a compact "py 12M · c 32M · asm 4M" strip next to the buttons. Polls every 2s. Doesn't solve the leak — just makes pressure visible so the user knows when to use "reboot tier". Real GC (Cheney over the WAT bump allocator, Boehm-em or custom mark-sweep for c-wasm) coming next. --- wasm/app/runner.js | 6 + wasm/app/worker.mjs | 8 +- wasm/asm/lumbda-asm.loader.js | 6 + wasm/asm/lumbda.wat | 9 ++ wasm/c/lumbda-c.loader.js | 8 ++ wasm/dist-repl/asm/lumbda-asm.loader.js | 6 + wasm/dist-repl/asm/lumbda-asm.wasm | Bin 18067 -> 19948 bytes wasm/dist-repl/c/lumbda-c.loader.js | 8 ++ wasm/dist-repl/python/lumbda-py.js | 7 ++ wasm/dist-repl/repl.css | 51 +++++---- wasm/dist-repl/repl.js | 139 +++++++++++++++++++++++- wasm/dist-repl/runner.js | 6 + wasm/dist-repl/style.css | 13 ++- wasm/dist-repl/worker.mjs | 8 +- wasm/python/lumbda-py.js | 7 ++ wasm/repl/repl.css | 7 ++ wasm/repl/repl.js | 44 ++++++++ www/playground/asm/lumbda-asm.loader.js | 6 + www/playground/asm/lumbda-asm.wasm | Bin 19884 -> 19948 bytes www/playground/c/lumbda-c.loader.js | 8 ++ www/playground/python/lumbda-py.js | 7 ++ www/playground/runner.js | 6 + www/playground/worker.mjs | 8 +- www/repl/asm/lumbda-asm.loader.js | 6 + www/repl/asm/lumbda-asm.wasm | Bin 19884 -> 19948 bytes www/repl/c/lumbda-c.loader.js | 8 ++ www/repl/python/lumbda-py.js | 7 ++ www/repl/repl.css | 7 ++ www/repl/repl.js | 44 ++++++++ www/repl/runner.js | 6 + www/repl/worker.mjs | 8 +- 31 files changed, 426 insertions(+), 28 deletions(-) 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 3a30eb53fa7f474df59e31b9ef11da6740f5275b..55c8e9cae89ef0ae50ba09d7ddf57a1fd8fe95b4 100644 GIT binary patch literal 19948 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 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 d03d195626e745f6dfbcc8cef40b8dc96d789806..55c8e9cae89ef0ae50ba09d7ddf57a1fd8fe95b4 100644 GIT binary patch delta 102 zcmZ28oAJ$T#tHt+(-@~r44ORg@iyjpjGU8aGnVKI4Q_FG { 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 d03d195626e745f6dfbcc8cef40b8dc96d789806..55c8e9cae89ef0ae50ba09d7ddf57a1fd8fe95b4 100644 GIT binary patch delta 102 zcmZ28oAJ$T#tHt+(-@~r44ORg@iyjpjGU8aGnVKI4Q_FG { + 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 {