lumbda/wasm/asm/lumbda-asm.loader.js
russell@unturf.com cdb4fc9715
asm streaming: recycle the 64 KB output buffer after each flush
A throttled forever-counter on the asm tier ran for ~5.7 s and
then trapped 'index out of bounds' — the WAT writes output to a
fixed 64 KB region at 0x10000–0x1FFFF, and a fast (display X)
(newline) loop accumulates faster than the buffer can drain. After
about 6,400 ticks at ~10 chars each,  overflowed the
region into the source buffer at 0x20000 and the next i32.store8
fell off linear memory.

Fix is a contract change on emit_chunk: its signature picks up an
i32 return — 1 tells the WAT to recycle (zero output_len AND
flush_start), 0 keeps the original 'just advance flush_start'
semantics so callers that read lumbda_output_ptr/len after eval
still see the full buffer.

The asm loader returns 1 from emit_chunk, accumulating every
flushed slice into refs.accumulated. evalLisp's return value is now
refs.accumulated + the trailing (still-unflushed) buffer slice
rather than just the lumbda_output_ptr/len slice — caller still
gets the complete output, the WAT-side buffer just keeps recycling.

Test stubs already declared emit_chunk() {} which returns
undefined — JS->wasm i32 coercion turns that into 0, preserving
the no-recycle behavior they expected. unit/integration suites
(31 tests total) still pass.

Trailing flush in lumbda_eval also honors the return value: if the
host consumed, zero both offsets so the final lumbda_output_ptr/len
read returns 0 bytes (loader already accumulated the trailing
slice — no need to re-deliver). Earlier draft of this patch
double-emitted the trailing slice because we read it through both
the emit_chunk path and the final-buffer path.
2026-06-15 06:36:00 -04:00

138 lines
5.9 KiB
JavaScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// wasm/asm/lumbda-asm.loader.js
// Asm tier loader — instantiates lumbda-asm.wasm and exposes evalLisp.
//
// Memory layout (mirrors lumbda.wat):
// 0x10000 output buffer (read after each call)
// 0x20000 source buffer (write before each call)
// 0x40000 bend response scratch (host writes here when JS dispatches)
async function _bootstrap() {
const wasmURL = new URL("./lumbda-asm.wasm", import.meta.url).href;
const resp = await fetch(wasmURL);
const bytes = await resp.arrayBuffer();
// Closure-captured holder so the imported function can see the
// instance's memory after instantiation completes.
// accumulated holds the full eval output for evalLisp's return
// value — the WAT now recycles the 64 KB output buffer after
// each emit_chunk so a long-running printer doesn't overflow,
// but the loader must still hand the caller the complete text.
const refs = {
instance: null,
bendUrl: null,
currentOnChunk: null,
accumulated: "",
};
const importObj = {
env: {
// bend_call(src_ptr, src_len, resp_ptr) -> resp_len.
// Synchronous XHR is the only sync HTTP available in workers;
// perfect for the WAT tier's blocking eval model.
bend_call(srcPtr, srcLen, respPtr) {
if (!refs.bendUrl) {
const msg = "no bend URL configured";
const mem = new Uint8Array(refs.instance.exports.memory.buffer);
mem.set(new TextEncoder().encode(msg), respPtr);
return msg.length;
}
const mem = new Uint8Array(refs.instance.exports.memory.buffer);
const payload = new TextDecoder().decode(mem.slice(srcPtr, srcPtr + srcLen));
try {
const xhr = new XMLHttpRequest();
xhr.open("POST", refs.bendUrl, false); // sync
xhr.setRequestHeader("Content-Type", "text/plain; charset=utf-8");
xhr.send(payload);
const respText = xhr.responseText || "";
const respBytes = new TextEncoder().encode(respText);
new Uint8Array(refs.instance.exports.memory.buffer).set(respBytes, respPtr);
return respBytes.length;
} catch (e) {
const err = "bend error: " + (e.message || String(e));
new Uint8Array(refs.instance.exports.memory.buffer)
.set(new TextEncoder().encode(err), respPtr);
return err.length;
}
},
// emit_chunk(ptr, len) → i32 — WAT's out_char calls this
// on every newline (and lumbda_eval calls it once more at
// the end for trailing non-newline content) so the
// worker can postMessage a chunk to the playground panel
// as work happens, matching the C-tier Module.print +
// pyodide _StreamingStdout streams.
//
// Returns 1 to tell the WAT to RECYCLE the output region
// (zero output_len + flush_start). The output buffer is
// a fixed 64 KB slot at 0x100000x1FFFF; a tight printing
// loop (e.g. a forever counter) blew past it into the
// source buffer at 0x20000 and crashed with "index out
// of bounds" after ~6,400 ticks. The loader accumulates
// every chunk into refs.accumulated so evalLisp can still
// return the full output to its caller even though the
// WAT-side buffer keeps recycling.
emit_chunk(ptr, len) {
if (len > 0) {
const mem = new Uint8Array(
refs.instance.exports.memory.buffer, ptr, len);
const text = new TextDecoder().decode(mem);
refs.accumulated += text;
if (refs.currentOnChunk) refs.currentOnChunk(text);
}
return 1;
},
},
};
const { instance } = await WebAssembly.instantiate(bytes, importObj);
refs.instance = instance;
instance.exports.lumbda_init();
const enc = new TextEncoder();
const dec = new TextDecoder();
const exp = instance.exports;
return {
async evalLisp(src, onChunk) {
const srcBytes = enc.encode(src);
const srcPtr = exp.lumbda_source_ptr();
const mem = new Uint8Array(exp.memory.buffer);
mem.set(srcBytes, srcPtr);
refs.currentOnChunk = onChunk || null;
refs.accumulated = "";
try {
exp.lumbda_eval(srcBytes.length);
} catch (e) {
refs.currentOnChunk = null;
return `error: ${e.message}`;
}
refs.currentOnChunk = null;
// Trailing buffer content the WAT didn't already flush
// (e.g. a final value's repr without a trailing newline)
// — append to the accumulated stream so the caller still
// gets the complete output, just as if the buffer hadn't
// been recycled.
const outPtr = exp.lumbda_output_ptr();
const outLen = exp.lumbda_output_len();
const trailing = outLen > 0
? dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen))
: "";
return refs.accumulated + trailing;
},
setBendUrl(url) { refs.bendUrl = url || null; },
heapStats() {
return {
used: exp.lumbda_heap_used(),
total: exp.lumbda_heap_total(),
};
},
};
}
// Closure-encapsulated singleton (see lumbda-c.loader.js for rationale).
export const createAsmTier = (() => {
let tier = null;
return async () => {
if (!tier) tier = await _bootstrap();
return tier;
};
})();