gc diagnostics: per-tier heap pressure surfaced in repl tabbar

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.
This commit is contained in:
russell@unturf.com 2026-06-14 17:32:48 -04:00
parent 88e16c0ce2
commit ff2ef382c7
No known key found for this signature in database
31 changed files with 426 additions and 28 deletions

View file

@ -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();
}

View file

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

View file

@ -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(),
};
},
};
}

View file

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

View file

@ -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 };
},
};
}

View file

@ -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(),
};
},
};
}

Binary file not shown.

View file

@ -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 };
},
};
}

View file

@ -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 };
},
};
}

View file

@ -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);

View file

@ -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);

View file

@ -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();
}

View file

@ -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); }

View file

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

View file

@ -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 };
},
};
}

View file

@ -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 ───────────────────────────────────────────────── */

View file

@ -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) {

View file

@ -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(),
};
},
};
}

Binary file not shown.

View file

@ -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 };
},
};
}

View file

@ -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 };
},
};
}

View file

@ -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();
}

View file

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

View file

@ -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(),
};
},
};
}

Binary file not shown.

View file

@ -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 };
},
};
}

View file

@ -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 };
},
};
}

View file

@ -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 ───────────────────────────────────────────────── */

View file

@ -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) {

View file

@ -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();
}

View file

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