streaming: batch worker→main chunks into 64KB / 4096-line postMessages

A tight (display ...) loop used to fire one postMessage per display
call — main thread couldn't even register click events because the
message queue grew faster than it could drain. Fox saw this as
"the tab just keeps looping when I leave it" — the tab-switch click
never reached setActiveTab so autoPauseTab never fired, and the
worker kept running until it finished on its own.

Now the worker accumulates chunks into a local string buffer and
postMessages a single chunk-batch message when the buffer hits 64KB
or 4096 newlines. The 'done'/'error' path drains whatever's left
before signalling so the last lines still reach the UI. Main thread
handlers (repl + playground) split the batch back into the same
text+eol sequence the live streaming row expects.

Adds RAF coalescing on the receive side too: attachStreaming now
batches DOM textContent / appendChild updates into one
requestAnimationFrame tick so the 60 Hz repaint budget is shared
across all chunks that landed in that window. finalize() drains
the RAF buffer synchronously before the streamed-vs-expected
match check so error-on-cancel keeps the most recent lines.
This commit is contained in:
russell@unturf.com 2026-06-15 13:57:07 -04:00
parent 2ed5be124c
commit e272b6bfaa
No known key found for this signature in database
9 changed files with 361 additions and 190 deletions

View file

@ -219,6 +219,21 @@ function runOnTierInWorker(tier, src, onLoading) {
if (e.data.runId !== myRunId) return;
if (e.data.kind === "loading") {
onLoading && onLoading(e.data.tier);
} else if (e.data.kind === "chunk-batch") {
// Worker batches tight-loop output into 4KB/64-line
// postMessages. Split back into individual text+eol
// calls on the playground side too.
if (!liveBlock) liveBlock = startLiveBlock(tier);
const t = e.data.text;
let start = 0;
for (let i = 0; i < t.length; i++) {
if (t.charCodeAt(i) === 10) {
if (i > start) liveBlock.appendText(t.slice(start, i));
liveBlock.appendNewline();
start = i + 1;
}
}
if (start < t.length) liveBlock.appendText(t.slice(start));
} else if (e.data.kind === "chunk-text") {
if (!liveBlock) liveBlock = startLiveBlock(tier);
liveBlock.appendText(e.data.text);

View file

@ -39,6 +39,30 @@ self.onmessage = async (e) => {
}
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;
let chunkBuffer = "";
let chunkNewlines = 0;
function flushChunkBuffer() {
if (!chunkBuffer) return;
self.postMessage({ kind: "chunk-batch", runId, tier, text: chunkBuffer });
chunkBuffer = "";
chunkNewlines = 0;
}
try {
const output = await evalOnTier(
tier,
@ -46,55 +70,25 @@ self.onmessage = async (e) => {
(loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
},
// Stream every print/display from the tier to the main
// thread as it happens. Sync XHR inside bend!-call still
// blocks the worker, but displays BEFORE/AFTER the bend
// round-trip surface immediately instead of waiting for
// the whole eval to finish. Long demos feel alive.
//
// Defensive newline normalization: the C-tier loader
// adds the trailing \n that Emscripten's Module.print
// strips, but we kept seeing horizontal output in fox's
// Firefox tab as if the \n was lost somewhere on the
// wire. To rule out anything between here and the main
// thread, split each chunk on \n at the source and post
// one message per line — newline preserved as a flag
// rather than a byte. The main-thread receiver knows to
// re-add the line break.
(chunk) => {
if (!chunk) return;
// Split on \n at the source and post TWO separate
// message kinds: chunk-text (visible bytes, never
// containing a newline) and chunk-eol (a bare event
// marking end-of-line). Newlines no longer travel
// as bytes — they're typed messages. Whatever was
// eating the \n between Module.print and the DOM
// in fox's tab is bypassed.
let start = 0;
chunkBuffer += chunk;
for (let i = 0; i < chunk.length; i++) {
if (chunk.charCodeAt(i) === 10) {
if (i > start) {
self.postMessage({
kind: "chunk-text",
runId, tier,
text: chunk.slice(start, i),
});
}
self.postMessage({ kind: "chunk-eol", runId, tier });
start = i + 1;
}
if (chunk.charCodeAt(i) === 10) chunkNewlines++;
}
if (start < chunk.length) {
self.postMessage({
kind: "chunk-text",
runId, tier,
text: chunk.slice(start),
});
if (chunkBuffer.length >= CHUNK_FLUSH_BYTES
|| chunkNewlines >= CHUNK_FLUSH_NEWLINES) {
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) });
}
};

View file

@ -411,7 +411,24 @@ function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
state.pending[pendingKey] = { runId, reject };
const handler = (e) => {
if (e.data.runId !== runId) return;
if (e.data.kind === "chunk-text") {
if (e.data.kind === "chunk-batch") {
// Worker now batches chunks into 4KB/64-newline windows
// so a tight (display) loop doesn't drown the main
// thread in postMessage events. Split the batch text
// back into individual text + eol calls so the live
// streaming row sees the same shape as before.
if (!onChunkText && !onChunkEol) return;
const t = e.data.text;
let start = 0;
for (let i = 0; i < t.length; i++) {
if (t.charCodeAt(i) === 10) {
if (i > start && onChunkText) onChunkText(t.slice(start, i));
if (onChunkEol) onChunkEol();
start = i + 1;
}
}
if (start < t.length && onChunkText) onChunkText(t.slice(start));
} else if (e.data.kind === "chunk-text") {
onChunkText && onChunkText(e.data.text);
} else if (e.data.kind === "chunk-eol") {
onChunkEol && onChunkEol();
@ -448,21 +465,59 @@ function attachStreaming(resultSpan, metaSpan, tier) {
resultSpan.textContent = "";
resultSpan.appendChild(pendingLine);
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`;
return {
appendText(t) {
if (!t) return;
pendingText += t;
pendingLine.textContent = pendingText;
maybeAutoscroll();
},
appendNewline() {
// Batched DOM updates: a tight (display ...) loop fires
// tens of thousands of chunk messages per second. Touching
// textContent / appendChild per message blocks the main thread
// so hard the user can't even click another tab — fox saw exactly
// this and reported "the tab keeps looping when I leave it". RAF
// coalesces every chunk that lands in one ~16ms tick into a
// single DOM flush. Main thread stays responsive; the visible
// output catches up at 60fps which is plenty for human eyes.
let bufferedText = "";
let bufferedEols = 0;
let rafPending = false;
function flush() {
rafPending = false;
if (bufferedText) {
pendingText += bufferedText;
bufferedText = "";
}
let eols = bufferedEols;
bufferedEols = 0;
// First textContent set captures the trailing chars of the
// current line. Then for each eol we close that line and
// open a fresh one.
if (pendingText) pendingLine.textContent = pendingText;
while (eols-- > 0) {
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
resultSpan.appendChild(pendingLine);
maybeAutoscroll();
}
maybeAutoscroll();
}
function schedule() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(flush);
}
return {
flush, // exposed so finalize can drain synchronously
appendText(t) {
if (!t) return;
bufferedText += t;
schedule();
},
appendNewline() {
bufferedEols += 1;
schedule();
},
finalize(r) {
// Drain any buffered chunks that haven't been flushed yet
// so streamedText below sees the most recent content
// (otherwise the success-with-streaming match check is
// racy against the last RAF batch).
if (rafPending) flush();
const text = r.output != null ? r.output : "";
const errText = r.error ? "error: " + r.error : "";
const streamedText = Array.from(resultSpan.children)

View file

@ -39,6 +39,30 @@ self.onmessage = async (e) => {
}
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;
let chunkBuffer = "";
let chunkNewlines = 0;
function flushChunkBuffer() {
if (!chunkBuffer) return;
self.postMessage({ kind: "chunk-batch", runId, tier, text: chunkBuffer });
chunkBuffer = "";
chunkNewlines = 0;
}
try {
const output = await evalOnTier(
tier,
@ -46,55 +70,25 @@ self.onmessage = async (e) => {
(loadingTier) => {
self.postMessage({ kind: "loading", runId, tier: loadingTier });
},
// Stream every print/display from the tier to the main
// thread as it happens. Sync XHR inside bend!-call still
// blocks the worker, but displays BEFORE/AFTER the bend
// round-trip surface immediately instead of waiting for
// the whole eval to finish. Long demos feel alive.
//
// Defensive newline normalization: the C-tier loader
// adds the trailing \n that Emscripten's Module.print
// strips, but we kept seeing horizontal output in fox's
// Firefox tab as if the \n was lost somewhere on the
// wire. To rule out anything between here and the main
// thread, split each chunk on \n at the source and post
// one message per line — newline preserved as a flag
// rather than a byte. The main-thread receiver knows to
// re-add the line break.
(chunk) => {
if (!chunk) return;
// Split on \n at the source and post TWO separate
// message kinds: chunk-text (visible bytes, never
// containing a newline) and chunk-eol (a bare event
// marking end-of-line). Newlines no longer travel
// as bytes — they're typed messages. Whatever was
// eating the \n between Module.print and the DOM
// in fox's tab is bypassed.
let start = 0;
chunkBuffer += chunk;
for (let i = 0; i < chunk.length; i++) {
if (chunk.charCodeAt(i) === 10) {
if (i > start) {
self.postMessage({
kind: "chunk-text",
runId, tier,
text: chunk.slice(start, i),
});
}
self.postMessage({ kind: "chunk-eol", runId, tier });
start = i + 1;
}
if (chunk.charCodeAt(i) === 10) chunkNewlines++;
}
if (start < chunk.length) {
self.postMessage({
kind: "chunk-text",
runId, tier,
text: chunk.slice(start),
});
if (chunkBuffer.length >= CHUNK_FLUSH_BYTES
|| chunkNewlines >= CHUNK_FLUSH_NEWLINES) {
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) });
}
};

View file

@ -411,7 +411,24 @@ function evalInTier(tabId, tier, src, onChunkText, onChunkEol) {
state.pending[pendingKey] = { runId, reject };
const handler = (e) => {
if (e.data.runId !== runId) return;
if (e.data.kind === "chunk-text") {
if (e.data.kind === "chunk-batch") {
// Worker now batches chunks into 4KB/64-newline windows
// so a tight (display) loop doesn't drown the main
// thread in postMessage events. Split the batch text
// back into individual text + eol calls so the live
// streaming row sees the same shape as before.
if (!onChunkText && !onChunkEol) return;
const t = e.data.text;
let start = 0;
for (let i = 0; i < t.length; i++) {
if (t.charCodeAt(i) === 10) {
if (i > start && onChunkText) onChunkText(t.slice(start, i));
if (onChunkEol) onChunkEol();
start = i + 1;
}
}
if (start < t.length && onChunkText) onChunkText(t.slice(start));
} else if (e.data.kind === "chunk-text") {
onChunkText && onChunkText(e.data.text);
} else if (e.data.kind === "chunk-eol") {
onChunkEol && onChunkEol();
@ -448,21 +465,59 @@ function attachStreaming(resultSpan, metaSpan, tier) {
resultSpan.textContent = "";
resultSpan.appendChild(pendingLine);
metaSpan.textContent = `${TIER_LABEL[tier] || tier} · running…`;
return {
appendText(t) {
if (!t) return;
pendingText += t;
pendingLine.textContent = pendingText;
maybeAutoscroll();
},
appendNewline() {
// Batched DOM updates: a tight (display ...) loop fires
// tens of thousands of chunk messages per second. Touching
// textContent / appendChild per message blocks the main thread
// so hard the user can't even click another tab — fox saw exactly
// this and reported "the tab keeps looping when I leave it". RAF
// coalesces every chunk that lands in one ~16ms tick into a
// single DOM flush. Main thread stays responsive; the visible
// output catches up at 60fps which is plenty for human eyes.
let bufferedText = "";
let bufferedEols = 0;
let rafPending = false;
function flush() {
rafPending = false;
if (bufferedText) {
pendingText += bufferedText;
bufferedText = "";
}
let eols = bufferedEols;
bufferedEols = 0;
// First textContent set captures the trailing chars of the
// current line. Then for each eol we close that line and
// open a fresh one.
if (pendingText) pendingLine.textContent = pendingText;
while (eols-- > 0) {
pendingLine.textContent = pendingText || " ";
pendingText = "";
pendingLine = makeLine();
resultSpan.appendChild(pendingLine);
maybeAutoscroll();
}
maybeAutoscroll();
}
function schedule() {
if (rafPending) return;
rafPending = true;
requestAnimationFrame(flush);
}
return {
flush, // exposed so finalize can drain synchronously
appendText(t) {
if (!t) return;
bufferedText += t;
schedule();
},
appendNewline() {
bufferedEols += 1;
schedule();
},
finalize(r) {
// Drain any buffered chunks that haven't been flushed yet
// so streamedText below sees the most recent content
// (otherwise the success-with-streaming match check is
// racy against the last RAF batch).
if (rafPending) flush();
const text = r.output != null ? r.output : "";
const errText = r.error ? "error: " + r.error : "";
const streamedText = Array.from(resultSpan.children)