lumbda/www/repl/worker.mjs
russell@unturf.com f66124b0bc
streaming: wall-clock flush so low-rate emissions appear immediately
Worker batching used to wait for 64KB / 4096 newlines before
flushing. A program like

  (let loop ((n 0))
    (when (zero? (modulo n 10000))
      (display "Emitted at iteration: ") (display n) (newline))
    (loop (+ n 1)))

emits ~25 bytes every 10K iterations, so the user saw nothing for
several seconds — fox reported "this used to work and doesn't seem
to anymore" because the first emission was buried in the batch
buffer.

Added a 100ms wall-clock check alongside the existing size /
newline thresholds. The first chunk to arrive triggers a flush
(lastFlushTime starts at 0), and low-rate streams cap at ~10
flushes/sec. High-rate streams still hit the byte / newline ceilings
first so chunk batching for tight (display) loops is unaffected.

Verified headlessly: the infinite-emitter program now shows the
first emission within a frame, subsequent ones streaming live as
the loop iterates.
2026-06-15 20:19:39 -04:00

104 lines
4.8 KiB
JavaScript

// wasm/app/worker.mjs
// Tier-evaluation Web Worker. Keeps the main thread responsive so the
// live ms counter actually ticks AND so cancel works (main thread
// terminates this worker via worker.terminate()).
import { evalOnTier, setBendUrl, setPauseFlag, heapStats, portalSave, portalLoad } from "./runner.js";
self.onmessage = async (e) => {
const { kind } = e.data;
if (kind === "config") {
// Each field is independently optional so a config message can
// tweak one knob without resetting the others — REPL spawns
// workers with just { pauseFlag }; playground updates bendUrl
// alone, etc.
if (e.data.bendUrl !== undefined) setBendUrl(e.data.bendUrl);
if (e.data.pauseFlag !== undefined) setPauseFlag(e.data.pauseFlag);
return;
}
if (kind === "heap") {
// Synchronous read — no eval is running because the worker is
// single-threaded and main thread only sends this between evals.
self.postMessage({ kind: "heap", runId: e.data.runId, tier: e.data.tier, stats: heapStats(e.data.tier) });
return;
}
if (kind === "portal-save") {
// Pulls the most-recent /tmp/<name>.portal out of the tier's
// MEMFS after the lisp side ran (portal-snapshot! NAME). Main
// thread encrypts and stuffs it into the vault.
const blob = portalSave(e.data.tier, e.data.name);
self.postMessage({ kind: "portal-save", runId: e.data.runId, name: e.data.name, blob });
return;
}
if (kind === "portal-load") {
// Hydrates MEMFS from a vault-decrypted blob so a subsequent
// (portal-load! NAME) eval finds the file ready.
const ok = portalLoad(e.data.tier, e.data.name, e.data.blob);
self.postMessage({ kind: "portal-load", runId: e.data.runId, name: e.data.name, ok });
return;
}
if (kind !== "eval") return;
const { runId, tier, src } = e.data;
// Buffer chunks on the worker side so a tight (display ...) loop
// doesn't fire one postMessage per glyph. Without batching, a
// 100M-iteration display loop generated hundreds of millions of
// messages that left the main thread too busy to even register
// click events — fox reported the tab "kept looping when I left
// it" because the tab-switch click couldn't be dispatched. The
// 64KB / 4096-line thresholds coalesce the tight-loop case down
// by ~50000x while still flushing fast enough that short outputs
// feel real-time (the first flush fires once the buffer crosses
// the threshold, so a 50-line print appears as soon as the eval
// finishes via the drain-at-end path). We can't use a wall-clock
// timer to flush because the wasm eval runs synchronously inside
// the worker — no event loop tick happens between Module.print
// calls.
const CHUNK_FLUSH_BYTES = 65536;
const CHUNK_FLUSH_NEWLINES = 4096;
// Wall-clock flush — guarantees the first emission lands on the UI
// immediately and caps low-rate streams at ~10 flushes/sec. Without
// this, a (let loop ...) that emits every 10K iterations would
// accumulate 30 bytes per emit, take 2000+ emits to hit the 64KB
// threshold, and the user sees nothing for seconds. lastFlushTime
// starts at 0 so the first chunk to land always triggers a flush.
const FLUSH_INTERVAL_MS = 100;
let chunkBuffer = "";
let chunkNewlines = 0;
let lastFlushTime = 0;
function flushChunkBuffer() {
if (!chunkBuffer) return;
self.postMessage({ kind: "chunk-batch", runId, tier, text: chunkBuffer });
chunkBuffer = "";
chunkNewlines = 0;
lastFlushTime = Date.now();
}
try {
const output = await evalOnTier(
tier,
src,
(loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
},
(chunk) => {
if (!chunk) return;
chunkBuffer += chunk;
for (let i = 0; i < chunk.length; i++) {
if (chunk.charCodeAt(i) === 10) chunkNewlines++;
}
if (chunkBuffer.length >= CHUNK_FLUSH_BYTES
|| chunkNewlines >= CHUNK_FLUSH_NEWLINES
|| (Date.now() - lastFlushTime) >= FLUSH_INTERVAL_MS) {
flushChunkBuffer();
}
},
);
// Drain whatever the chunk callback left in the buffer before
// the eval returned — last lines of a program would otherwise
// never reach the UI.
flushChunkBuffer();
self.postMessage({ kind: "done", runId, output });
} catch (err) {
flushChunkBuffer();
self.postMessage({ kind: "error", runId, message: err && err.message ? err.message : String(err) });
}
};