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.
This commit is contained in:
russell@unturf.com 2026-06-15 20:19:39 -04:00
parent 63808f72a5
commit f66124b0bc
No known key found for this signature in database
4 changed files with 44 additions and 4 deletions

View file

@ -55,13 +55,22 @@ self.onmessage = async (e) => {
// 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(
@ -77,7 +86,8 @@ self.onmessage = async (e) => {
if (chunk.charCodeAt(i) === 10) chunkNewlines++;
}
if (chunkBuffer.length >= CHUNK_FLUSH_BYTES
|| chunkNewlines >= CHUNK_FLUSH_NEWLINES) {
|| chunkNewlines >= CHUNK_FLUSH_NEWLINES
|| (Date.now() - lastFlushTime) >= FLUSH_INTERVAL_MS) {
flushChunkBuffer();
}
},

View file

@ -55,13 +55,22 @@ self.onmessage = async (e) => {
// 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(
@ -77,7 +86,8 @@ self.onmessage = async (e) => {
if (chunk.charCodeAt(i) === 10) chunkNewlines++;
}
if (chunkBuffer.length >= CHUNK_FLUSH_BYTES
|| chunkNewlines >= CHUNK_FLUSH_NEWLINES) {
|| chunkNewlines >= CHUNK_FLUSH_NEWLINES
|| (Date.now() - lastFlushTime) >= FLUSH_INTERVAL_MS) {
flushChunkBuffer();
}
},

View file

@ -55,13 +55,22 @@ self.onmessage = async (e) => {
// 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(
@ -77,7 +86,8 @@ self.onmessage = async (e) => {
if (chunk.charCodeAt(i) === 10) chunkNewlines++;
}
if (chunkBuffer.length >= CHUNK_FLUSH_BYTES
|| chunkNewlines >= CHUNK_FLUSH_NEWLINES) {
|| chunkNewlines >= CHUNK_FLUSH_NEWLINES
|| (Date.now() - lastFlushTime) >= FLUSH_INTERVAL_MS) {
flushChunkBuffer();
}
},

View file

@ -55,13 +55,22 @@ self.onmessage = async (e) => {
// 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(
@ -77,7 +86,8 @@ self.onmessage = async (e) => {
if (chunk.charCodeAt(i) === 10) chunkNewlines++;
}
if (chunkBuffer.length >= CHUNK_FLUSH_BYTES
|| chunkNewlines >= CHUNK_FLUSH_NEWLINES) {
|| chunkNewlines >= CHUNK_FLUSH_NEWLINES
|| (Date.now() - lastFlushTime) >= FLUSH_INTERVAL_MS) {
flushChunkBuffer();
}
},