From cf7ff2219fc027e7bc8cbd9a4612763e898a1631 Mon Sep 17 00:00:00 2001 From: "russell@unturf.com" Date: Mon, 15 Jun 2026 05:38:03 -0400 Subject: [PATCH] c-tier streaming: re-add the trailing newline emcc strips before postMessage MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Emscripten's Module.print fires once per line and hands the loader just the line content — no trailing newline; the caller is expected to add it back when joining (outBuf.join('\n') does that for the final return). Streaming path was forwarding the line straight to onChunk without the newline, so successive chunks concatenated horizontally in the playground panel — the loop (let loop ((i 0)) (cond ((= (modulo i 10000) 0) (display \"tick \") (display i) (newline))) (loop (+ i 1))) was running fine on c tier (each (newline) ended a printf line) but arriving in the UI as 'tick 0tick 10000tick 20000...' with no breaks. Loader's print/printErr callbacks now pass `line + \"\\n\"` to currentOnChunk. Final buffered join (which already adds the \\n) is untouched, so the post-eval output string keeps the same shape. Pyodide _StreamingStdout sees Python's raw write() bytes including the \\n already; asm's emit_chunk fires AFTER output_len was incremented past the \\n, so the slice [flush_start, output_len) includes it. Neither needed a change. --- wasm/c/lumbda-c.loader.js | 12 ++++++++++-- wasm/dist-repl/c/lumbda-c.loader.js | 12 ++++++++++-- www/playground/c/lumbda-c.loader.js | 12 ++++++++++-- www/repl/c/lumbda-c.loader.js | 29 ++++++++++++++++++++++++++--- 4 files changed, 56 insertions(+), 9 deletions(-) diff --git a/wasm/c/lumbda-c.loader.js b/wasm/c/lumbda-c.loader.js index 181d4fe..1e2e8e2 100644 --- a/wasm/c/lumbda-c.loader.js +++ b/wasm/c/lumbda-c.loader.js @@ -19,6 +19,14 @@ async function _bootstrap() { // through immediately so the worker can postMessage a chunk to // the UI as each line emerges (vs the user staring at a blank // panel for 18 seconds during the heavy bend call). + // + // Emscripten's print convention is "one call per line WITHOUT + // the trailing newline" — the caller is expected to add a \n + // when reassembling. Our outBuf join adds it back, but the + // streaming path needs it explicitly or successive chunks + // concatenate horizontally in the output panel (saw with + // (let loop ((i 0)) ... (display i) (newline) ... (loop ...)) + // — every tick ended up on the same line). let outBuf = []; let errBuf = []; let currentOnChunk = null; @@ -26,11 +34,11 @@ async function _bootstrap() { locateFile: (p) => wasmDir + p, print: (line) => { outBuf.push(line); - if (currentOnChunk) currentOnChunk(line); + if (currentOnChunk) currentOnChunk(line + "\n"); }, printErr: (line) => { errBuf.push(line); - if (currentOnChunk) currentOnChunk(line); + if (currentOnChunk) currentOnChunk(line + "\n"); }, }); diff --git a/wasm/dist-repl/c/lumbda-c.loader.js b/wasm/dist-repl/c/lumbda-c.loader.js index 181d4fe..1e2e8e2 100644 --- a/wasm/dist-repl/c/lumbda-c.loader.js +++ b/wasm/dist-repl/c/lumbda-c.loader.js @@ -19,6 +19,14 @@ async function _bootstrap() { // through immediately so the worker can postMessage a chunk to // the UI as each line emerges (vs the user staring at a blank // panel for 18 seconds during the heavy bend call). + // + // Emscripten's print convention is "one call per line WITHOUT + // the trailing newline" — the caller is expected to add a \n + // when reassembling. Our outBuf join adds it back, but the + // streaming path needs it explicitly or successive chunks + // concatenate horizontally in the output panel (saw with + // (let loop ((i 0)) ... (display i) (newline) ... (loop ...)) + // — every tick ended up on the same line). let outBuf = []; let errBuf = []; let currentOnChunk = null; @@ -26,11 +34,11 @@ async function _bootstrap() { locateFile: (p) => wasmDir + p, print: (line) => { outBuf.push(line); - if (currentOnChunk) currentOnChunk(line); + if (currentOnChunk) currentOnChunk(line + "\n"); }, printErr: (line) => { errBuf.push(line); - if (currentOnChunk) currentOnChunk(line); + if (currentOnChunk) currentOnChunk(line + "\n"); }, }); diff --git a/www/playground/c/lumbda-c.loader.js b/www/playground/c/lumbda-c.loader.js index 181d4fe..1e2e8e2 100644 --- a/www/playground/c/lumbda-c.loader.js +++ b/www/playground/c/lumbda-c.loader.js @@ -19,6 +19,14 @@ async function _bootstrap() { // through immediately so the worker can postMessage a chunk to // the UI as each line emerges (vs the user staring at a blank // panel for 18 seconds during the heavy bend call). + // + // Emscripten's print convention is "one call per line WITHOUT + // the trailing newline" — the caller is expected to add a \n + // when reassembling. Our outBuf join adds it back, but the + // streaming path needs it explicitly or successive chunks + // concatenate horizontally in the output panel (saw with + // (let loop ((i 0)) ... (display i) (newline) ... (loop ...)) + // — every tick ended up on the same line). let outBuf = []; let errBuf = []; let currentOnChunk = null; @@ -26,11 +34,11 @@ async function _bootstrap() { locateFile: (p) => wasmDir + p, print: (line) => { outBuf.push(line); - if (currentOnChunk) currentOnChunk(line); + if (currentOnChunk) currentOnChunk(line + "\n"); }, printErr: (line) => { errBuf.push(line); - if (currentOnChunk) currentOnChunk(line); + if (currentOnChunk) currentOnChunk(line + "\n"); }, }); diff --git a/www/repl/c/lumbda-c.loader.js b/www/repl/c/lumbda-c.loader.js index 64e78c2..1e2e8e2 100644 --- a/www/repl/c/lumbda-c.loader.js +++ b/www/repl/c/lumbda-c.loader.js @@ -13,12 +13,33 @@ async function _bootstrap() { const wasmDir = new URL("./", import.meta.url).href; const { default: createLumbdaC } = await import(/* @vite-ignore */ factoryURL); + // Streaming output: every printf in the wasm fires Module.print + // synchronously. We buffer normally for the final return, but if + // the current eval registered an onChunk callback we also call + // through immediately so the worker can postMessage a chunk to + // the UI as each line emerges (vs the user staring at a blank + // panel for 18 seconds during the heavy bend call). + // + // Emscripten's print convention is "one call per line WITHOUT + // the trailing newline" — the caller is expected to add a \n + // when reassembling. Our outBuf join adds it back, but the + // streaming path needs it explicitly or successive chunks + // concatenate horizontally in the output panel (saw with + // (let loop ((i 0)) ... (display i) (newline) ... (loop ...)) + // — every tick ended up on the same line). let outBuf = []; let errBuf = []; + let currentOnChunk = null; const module = await createLumbdaC({ locateFile: (p) => wasmDir + p, - print: (line) => outBuf.push(line), - printErr: (line) => errBuf.push(line), + print: (line) => { + outBuf.push(line); + if (currentOnChunk) currentOnChunk(line + "\n"); + }, + printErr: (line) => { + errBuf.push(line); + if (currentOnChunk) currentOnChunk(line + "\n"); + }, }); const _init = module.cwrap("lumbda_wasm_init", null, []); @@ -56,10 +77,12 @@ async function _bootstrap() { const path = `/tmp/${name}.portal`; module.FS.writeFile(path, blob); }, - async evalLisp(src) { + async evalLisp(src, onChunk) { outBuf = []; errBuf = []; + currentOnChunk = onChunk || null; const errPtr = _eval(src); + currentOnChunk = null; let errMsg = ""; if (errPtr) { errMsg = module.UTF8ToString(errPtr);