Skip to content

Commit 9d91e76

Browse files
committed
fix: replace O(n²) string concatenation in readLines with array buffering
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
1 parent f326249 commit 9d91e76

File tree

1 file changed

+30
-14
lines changed

1 file changed

+30
-14
lines changed

js/src/utils.ts

Lines changed: 30 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -22,32 +22,48 @@ export function formatExecutionTimeoutError(error: unknown) {
2222

2323
export async function* readLines(stream: ReadableStream<Uint8Array>) {
2424
const reader = stream.getReader()
25-
let buffer = ''
25+
const decoder = new TextDecoder()
26+
const pending: string[] = []
2627

2728
try {
2829
while (true) {
2930
const { done, value } = await reader.read()
3031

31-
if (value !== undefined) {
32-
buffer += new TextDecoder().decode(value)
33-
}
34-
3532
if (done) {
36-
if (buffer.length > 0) {
37-
yield buffer
33+
if (pending.length > 0) {
34+
yield pending.join('')
3835
}
3936
break
4037
}
4138

42-
let newlineIdx = -1
39+
if (value !== undefined) {
40+
const chunk = decoder.decode(value, { stream: true })
4341

44-
do {
45-
newlineIdx = buffer.indexOf('\n')
46-
if (newlineIdx !== -1) {
47-
yield buffer.slice(0, newlineIdx)
48-
buffer = buffer.slice(newlineIdx + 1)
42+
if (chunk.indexOf('\n') === -1) {
43+
// No newline — accumulate in O(1)
44+
pending.push(chunk)
45+
continue
4946
}
50-
} while (newlineIdx !== -1)
47+
48+
// Chunk contains newline(s) — split and yield complete lines
49+
const parts = chunk.split('\n')
50+
51+
// First part completes the pending line
52+
pending.push(parts[0])
53+
yield pending.join('')
54+
pending.length = 0
55+
56+
// Middle parts are already complete lines
57+
for (let i = 1; i < parts.length - 1; i++) {
58+
yield parts[i]
59+
}
60+
61+
// Last part starts a new pending line (may be empty)
62+
const last = parts[parts.length - 1]
63+
if (last.length > 0) {
64+
pending.push(last)
65+
}
66+
}
5167
}
5268
} finally {
5369
reader.releaseLock()

0 commit comments

Comments
 (0)