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:
parent
88e16c0ce2
commit
ff2ef382c7
31 changed files with 426 additions and 28 deletions
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
};
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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)))
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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); }
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────── */
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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.
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 ───────────────────────────────────────────────── */
|
||||
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue