diff --git a/wasm/asm/lumbda-asm.loader.js b/wasm/asm/lumbda-asm.loader.js index 852e411..d7ab8bb 100644 --- a/wasm/asm/lumbda-asm.loader.js +++ b/wasm/asm/lumbda-asm.loader.js @@ -13,7 +13,16 @@ async function _bootstrap() { // Closure-captured holder so the imported function can see the // instance's memory after instantiation completes. - const refs = { instance: null, bendUrl: null, currentOnChunk: null }; + // 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: { @@ -45,15 +54,31 @@ async function _bootstrap() { return err.length; } }, - // emit_chunk(ptr, len) — 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. + // 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 (!refs.currentOnChunk || len <= 0) return; - const mem = new Uint8Array(refs.instance.exports.memory.buffer, ptr, len); - refs.currentOnChunk(new TextDecoder().decode(mem)); + 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; }, }, }; @@ -73,6 +98,7 @@ async function _bootstrap() { 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) { @@ -80,9 +106,17 @@ async function _bootstrap() { 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(); - return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)); + const trailing = outLen > 0 + ? dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)) + : ""; + return refs.accumulated + trailing; }, setBendUrl(url) { refs.bendUrl = url || null; }, heapStats() { diff --git a/wasm/asm/lumbda.wat b/wasm/asm/lumbda.wat index 61aa4a3..09103f3 100644 --- a/wasm/asm/lumbda.wat +++ b/wasm/asm/lumbda.wat @@ -46,14 +46,22 @@ (import "env" "bend_call" (func $js_bend_call (param i32 i32 i32) (result i32))) - ;; emit_chunk(ptr, len) — flush a slice of the output buffer to JS - ;; for streaming. Called from out_char whenever a newline is emitted - ;; (and from lumbda_eval at the very end for any trailing content - ;; without a newline). Loader's host function forwards the bytes to - ;; the current onChunk callback so the worker can postMessage the - ;; chunk to the playground panel as work happens. + ;; emit_chunk(ptr, len) → i32 — flush a slice of the output buffer + ;; to JS for streaming. Called from out_char whenever a newline is + ;; emitted (and from lumbda_eval at the very end for any trailing + ;; content without a newline). Loader's host function forwards the + ;; bytes to the current onChunk callback so the worker can + ;; postMessage the chunk to the playground panel as work happens. + ;; + ;; Returns 1 if the host consumed the chunk and the WAT should + ;; recycle the buffer (zero output_len + flush_start so a long + ;; running loop doesn't overflow the 64 KB output region into the + ;; source buffer at 0x20000). Returns 0 (or undefined → coerced + ;; to 0) if the host stub didn't consume — tests + node harnesses + ;; that read lumbda_output_ptr/len after eval keep their full + ;; buffer behavior, the playground loader recycles aggressively. (import "env" "emit_chunk" - (func $js_emit_chunk (param i32 i32))) + (func $js_emit_chunk (param i32 i32) (result i32))) ;; ─── Memory & exports ────────────────────────────────────────── (memory (export "memory") 32 4096) ;; 32 pages = 2 MB initial, grow to 256 MB @@ -1290,19 +1298,32 @@ ;; ─── Output buffer ───────────────────────────────────────────── (func $out_char (param $c i32) + (local $consumed i32) (i32.store8 (i32.add (i32.const 0x10000) (global.get $output_len)) (local.get $c)) (global.set $output_len (i32.add (global.get $output_len) (i32.const 1))) ;; Newline flushes the slice [flush_start, output_len) to JS so ;; the playground panel can render line-by-line during eval - ;; instead of waiting for the full evalLisp to return. + ;; instead of waiting for the full evalLisp to return. If the + ;; host consumed the chunk (return value = 1) we recycle the + ;; output region by zeroing both offsets — a tight printing + ;; loop overflowed the 64 KB buffer at 0x20000 otherwise. If + ;; the host returned 0 (e.g. test stubs that don't drain), we + ;; just advance flush_start so a later lumbda_output_ptr/len + ;; read still sees the full accumulated buffer. (if (i32.eq (local.get $c) (i32.const 10)) (then - (call $js_emit_chunk - (i32.add (i32.const 0x10000) (global.get $flush_start)) - (i32.sub (global.get $output_len) (global.get $flush_start))) - (global.set $flush_start (global.get $output_len))))) + (local.set $consumed + (call $js_emit_chunk + (i32.add (i32.const 0x10000) (global.get $flush_start)) + (i32.sub (global.get $output_len) (global.get $flush_start)))) + (if (local.get $consumed) + (then + (global.set $output_len (i32.const 0)) + (global.set $flush_start (i32.const 0))) + (else + (global.set $flush_start (global.get $output_len))))))) (func $out_str (param $ptr i32) (param $len i32) (local $i i32) @@ -4256,13 +4277,23 @@ ;; Trailing-content flush — print_value's repr doesn't end with a ;; newline, so any bytes past $flush_start would otherwise miss ;; the streaming path and only reach the UI through the final - ;; lumbda_output_ptr / lumbda_output_len read. - (if (i32.gt_u (global.get $output_len) (global.get $flush_start)) - (then - (call $js_emit_chunk - (i32.add (i32.const 0x10000) (global.get $flush_start)) - (i32.sub (global.get $output_len) (global.get $flush_start))) - (global.set $flush_start (global.get $output_len)))) + ;; lumbda_output_ptr / lumbda_output_len read. If the host + ;; consumed it, recycle the buffer so the final output_ptr/len + ;; read returns 0 bytes — the loader already accumulated the + ;; trailing slice via emit_chunk, no need to re-deliver. If the + ;; host didn't consume (return 0), advance flush_start so a + ;; later final-buffer read still sees the content. + (block $skip_trail + (br_if $skip_trail + (i32.le_u (global.get $output_len) (global.get $flush_start))) + (if (call $js_emit_chunk + (i32.add (i32.const 0x10000) (global.get $flush_start)) + (i32.sub (global.get $output_len) (global.get $flush_start))) + (then + (global.set $output_len (i32.const 0)) + (global.set $flush_start (i32.const 0))) + (else + (global.set $flush_start (global.get $output_len))))) ;; Trigger GC when the heap exceeds 60% of available memory. This is ;; the only safe collection point — the eval call stack has unwound, ;; so the only roots are the globals the collector knows about. diff --git a/wasm/dist-repl/asm/lumbda-asm.loader.js b/wasm/dist-repl/asm/lumbda-asm.loader.js index 852e411..d7ab8bb 100644 --- a/wasm/dist-repl/asm/lumbda-asm.loader.js +++ b/wasm/dist-repl/asm/lumbda-asm.loader.js @@ -13,7 +13,16 @@ async function _bootstrap() { // Closure-captured holder so the imported function can see the // instance's memory after instantiation completes. - const refs = { instance: null, bendUrl: null, currentOnChunk: null }; + // 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: { @@ -45,15 +54,31 @@ async function _bootstrap() { return err.length; } }, - // emit_chunk(ptr, len) — 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. + // 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 (!refs.currentOnChunk || len <= 0) return; - const mem = new Uint8Array(refs.instance.exports.memory.buffer, ptr, len); - refs.currentOnChunk(new TextDecoder().decode(mem)); + 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; }, }, }; @@ -73,6 +98,7 @@ async function _bootstrap() { 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) { @@ -80,9 +106,17 @@ async function _bootstrap() { 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(); - return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)); + const trailing = outLen > 0 + ? dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)) + : ""; + return refs.accumulated + trailing; }, setBendUrl(url) { refs.bendUrl = url || null; }, heapStats() { diff --git a/wasm/dist-repl/asm/lumbda-asm.wasm b/wasm/dist-repl/asm/lumbda-asm.wasm index 3563981..9ae4ce3 100644 Binary files a/wasm/dist-repl/asm/lumbda-asm.wasm and b/wasm/dist-repl/asm/lumbda-asm.wasm differ diff --git a/www/playground/asm/lumbda-asm.loader.js b/www/playground/asm/lumbda-asm.loader.js index 852e411..d7ab8bb 100644 --- a/www/playground/asm/lumbda-asm.loader.js +++ b/www/playground/asm/lumbda-asm.loader.js @@ -13,7 +13,16 @@ async function _bootstrap() { // Closure-captured holder so the imported function can see the // instance's memory after instantiation completes. - const refs = { instance: null, bendUrl: null, currentOnChunk: null }; + // 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: { @@ -45,15 +54,31 @@ async function _bootstrap() { return err.length; } }, - // emit_chunk(ptr, len) — 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. + // 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 (!refs.currentOnChunk || len <= 0) return; - const mem = new Uint8Array(refs.instance.exports.memory.buffer, ptr, len); - refs.currentOnChunk(new TextDecoder().decode(mem)); + 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; }, }, }; @@ -73,6 +98,7 @@ async function _bootstrap() { 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) { @@ -80,9 +106,17 @@ async function _bootstrap() { 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(); - return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)); + const trailing = outLen > 0 + ? dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)) + : ""; + return refs.accumulated + trailing; }, setBendUrl(url) { refs.bendUrl = url || null; }, heapStats() { diff --git a/www/playground/asm/lumbda-asm.wasm b/www/playground/asm/lumbda-asm.wasm index 3563981..9ae4ce3 100644 Binary files a/www/playground/asm/lumbda-asm.wasm and b/www/playground/asm/lumbda-asm.wasm differ diff --git a/www/repl/asm/lumbda-asm.loader.js b/www/repl/asm/lumbda-asm.loader.js index 852e411..d7ab8bb 100644 --- a/www/repl/asm/lumbda-asm.loader.js +++ b/www/repl/asm/lumbda-asm.loader.js @@ -13,7 +13,16 @@ async function _bootstrap() { // Closure-captured holder so the imported function can see the // instance's memory after instantiation completes. - const refs = { instance: null, bendUrl: null, currentOnChunk: null }; + // 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: { @@ -45,15 +54,31 @@ async function _bootstrap() { return err.length; } }, - // emit_chunk(ptr, len) — 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. + // 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 (!refs.currentOnChunk || len <= 0) return; - const mem = new Uint8Array(refs.instance.exports.memory.buffer, ptr, len); - refs.currentOnChunk(new TextDecoder().decode(mem)); + 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; }, }, }; @@ -73,6 +98,7 @@ async function _bootstrap() { 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) { @@ -80,9 +106,17 @@ async function _bootstrap() { 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(); - return dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)); + const trailing = outLen > 0 + ? dec.decode(new Uint8Array(exp.memory.buffer, outPtr, outLen)) + : ""; + return refs.accumulated + trailing; }, setBendUrl(url) { refs.bendUrl = url || null; }, heapStats() { diff --git a/www/repl/asm/lumbda-asm.wasm b/www/repl/asm/lumbda-asm.wasm index 3563981..9ae4ce3 100644 Binary files a/www/repl/asm/lumbda-asm.wasm and b/www/repl/asm/lumbda-asm.wasm differ