From cdb4fc9715261c2002f5f7ab8d3731f1d15e0ffc Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 15 Jun 2026 06:36:00 -0400 Subject: [PATCH] asm streaming: recycle the 64 KB output buffer after each flush MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- wasm/asm/lumbda-asm.loader.js | 54 +++++++++++++++---- wasm/asm/lumbda.wat | 69 +++++++++++++++++------- wasm/dist-repl/asm/lumbda-asm.loader.js | 54 +++++++++++++++---- wasm/dist-repl/asm/lumbda-asm.wasm | Bin 21610 -> 21642 bytes www/playground/asm/lumbda-asm.loader.js | 54 +++++++++++++++---- www/playground/asm/lumbda-asm.wasm | Bin 21610 -> 21642 bytes www/repl/asm/lumbda-asm.loader.js | 54 +++++++++++++++---- www/repl/asm/lumbda-asm.wasm | Bin 21610 -> 21642 bytes 8 files changed, 226 insertions(+), 59 deletions(-) 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 356398162381f7c77607fdc4c62b597db50f3e16..9ae4ce37705e4e4653251610989f16bc9791af41 100644 GIT binary patch delta 256 zcmZWhI|{-;6nrz;%?cuD4Ax2n1wBOe0Cs|HDhuretJT)Z&lv2?x3`$2P#M$R9u^IRWzj5P^cX}dn=#_B-U|- zh&0ADOqKgp)mjT6BK{Wgp}eB{M=^SQw&32C8jxFvAdnhxC(CF{*RpPEBOKlWgfl& delta 224 zcmXX;y$%6E82rAuZ+DekM511jD~MNcwblb%B|$II*sE3K3a>!Pm0m=v^$HsMW19KN z%p7n0x$w9&%!>OM@k)q?Ec*|-iLOFwuc!t(|#eAM`+jm=Y%iKxLge_TP$Q=*0oq{ Ng4=dEmLBCR{{boK9FYJ3 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 356398162381f7c77607fdc4c62b597db50f3e16..9ae4ce37705e4e4653251610989f16bc9791af41 100644 GIT binary patch delta 256 zcmZWhI|{-;6nrz;%?cuD4Ax2n1wBOe0Cs|HDhuretJT)Z&lv2?x3`$2P#M$R9u^IRWzj5P^cX}dn=#_B-U|- zh&0ADOqKgp)mjT6BK{Wgp}eB{M=^SQw&32C8jxFvAdnhxC(CF{*RpPEBOKlWgfl& delta 224 zcmXX;y$%6E82rAuZ+DekM511jD~MNcwblb%B|$II*sE3K3a>!Pm0m=v^$HsMW19KN z%p7n0x$w9&%!>OM@k)q?Ec*|-iLOFwuc!t(|#eAM`+jm=Y%iKxLge_TP$Q=*0oq{ Ng4=dEmLBCR{{boK9FYJ3 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 356398162381f7c77607fdc4c62b597db50f3e16..9ae4ce37705e4e4653251610989f16bc9791af41 100644 GIT binary patch delta 256 zcmZWhI|{-;6nrz;%?cuD4Ax2n1wBOe0Cs|HDhuretJT)Z&lv2?x3`$2P#M$R9u^IRWzj5P^cX}dn=#_B-U|- zh&0ADOqKgp)mjT6BK{Wgp}eB{M=^SQw&32C8jxFvAdnhxC(CF{*RpPEBOKlWgfl& delta 224 zcmXX;y$%6E82rAuZ+DekM511jD~MNcwblb%B|$II*sE3K3a>!Pm0m=v^$HsMW19KN z%p7n0x$w9&%!>OM@k)q?Ec*|-iLOFwuc!t(|#eAM`+jm=Y%iKxLge_TP$Q=*0oq{ Ng4=dEmLBCR{{boK9FYJ3