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.
138 lines
5.9 KiB
JavaScript
138 lines
5.9 KiB
JavaScript
// 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 0x10000–0x1FFFF; 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;
|
||
};
|
||
})();
|