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.
This commit is contained in:
russell@unturf.com 2026-06-15 06:36:00 -04:00
parent 25d2765ed9
commit cdb4fc9715
No known key found for this signature in database
8 changed files with 226 additions and 59 deletions

View file

@ -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 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 (!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() {

View file

@ -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.

View file

@ -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 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 (!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() {

Binary file not shown.

View file

@ -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 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 (!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() {

Binary file not shown.

View file

@ -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 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 (!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() {

Binary file not shown.