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.
104 lines
4.8 KiB
JavaScript
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) });
|
|
}
|
|
};
|