From 9d91e76a1d9257f8f9f63a0d12a3168e25aafa21 Mon Sep 17 00:00:00 2001 From: Tomas Beran Date: Tue, 14 Apr 2026 15:52:41 +0200 Subject: [PATCH 1/2] =?UTF-8?q?fix:=20replace=20O(n=C2=B2)=20string=20conc?= =?UTF-8?q?atenation=20in=20readLines=20with=20array=20buffering?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The readLines() function used `buffer += chunk` followed by `buffer.indexOf('\n')` on every iteration. The indexOf forces V8 to flatten its internal cons-string representation, defeating the optimization that makes += amortized. This results in O(n²) total string copies — for 22 MB of stdout (~1,400 chunks), approximately 15.7 GB of allocations, 1.5 GB+ peak heap, and 10-20s event loop stalls. Replace with an array-based pending[] buffer: - No-newline chunks: O(1) array push, no string copying - Newline chunks: O(line_length) join, once per complete line - Worst case (no newlines): single join() at stream end Fixes #251 --- js/src/utils.ts | 44 ++++++++++++++++++++++++++++++-------------- 1 file changed, 30 insertions(+), 14 deletions(-) diff --git a/js/src/utils.ts b/js/src/utils.ts index 0bf73c3c..15da3643 100644 --- a/js/src/utils.ts +++ b/js/src/utils.ts @@ -22,32 +22,48 @@ export function formatExecutionTimeoutError(error: unknown) { export async function* readLines(stream: ReadableStream) { const reader = stream.getReader() - let buffer = '' + const decoder = new TextDecoder() + const pending: string[] = [] try { while (true) { const { done, value } = await reader.read() - if (value !== undefined) { - buffer += new TextDecoder().decode(value) - } - if (done) { - if (buffer.length > 0) { - yield buffer + if (pending.length > 0) { + yield pending.join('') } break } - let newlineIdx = -1 + if (value !== undefined) { + const chunk = decoder.decode(value, { stream: true }) - do { - newlineIdx = buffer.indexOf('\n') - if (newlineIdx !== -1) { - yield buffer.slice(0, newlineIdx) - buffer = buffer.slice(newlineIdx + 1) + if (chunk.indexOf('\n') === -1) { + // No newline — accumulate in O(1) + pending.push(chunk) + continue } - } while (newlineIdx !== -1) + + // Chunk contains newline(s) — split and yield complete lines + const parts = chunk.split('\n') + + // First part completes the pending line + pending.push(parts[0]) + yield pending.join('') + pending.length = 0 + + // Middle parts are already complete lines + for (let i = 1; i < parts.length - 1; i++) { + yield parts[i] + } + + // Last part starts a new pending line (may be empty) + const last = parts[parts.length - 1] + if (last.length > 0) { + pending.push(last) + } + } } } finally { reader.releaseLock() From 735cf65cea9a457c074f196f76aceb46deb0354d Mon Sep 17 00:00:00 2001 From: Tomas Beran Date: Tue, 14 Apr 2026 16:31:29 +0200 Subject: [PATCH 2/2] fix: flush TextDecoder on stream end to prevent silent data loss MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit When using decoder.decode(value, { stream: true }), the TextDecoder may hold incomplete multi-byte UTF-8 bytes internally. Without flushing on EOF, those trailing bytes were silently dropped — a regression from the previous behavior which would at minimum emit replacement characters. --- js/src/utils.ts | 2 ++ 1 file changed, 2 insertions(+) diff --git a/js/src/utils.ts b/js/src/utils.ts index 15da3643..48abe9fb 100644 --- a/js/src/utils.ts +++ b/js/src/utils.ts @@ -30,6 +30,8 @@ export async function* readLines(stream: ReadableStream) { const { done, value } = await reader.read() if (done) { + const trailing = decoder.decode() + if (trailing) pending.push(trailing) if (pending.length > 0) { yield pending.join('') }