Skip to content

Commit c59788c

Browse files
Sheraffanonrig
andauthored
perf(start-client-core): O(1) buffer drain in client frame decoder (#8009)
The frame decoder dropped consumed chunks from its buffer with bufferList.shift(), which is O(n). When a single large frame (e.g. a big RawStream payload) is assembled from many small network reads, the extract loop calls shift() once per chunk, making reassembly O(n^2). Track the first un-consumed chunk with a head pointer and advance it in O(1) instead of shifting. Consumed slots are released for GC, and the buffer is compacted when fully drained (O(1) reset) or once the consumed prefix grows past a small threshold (amortized O(1) per chunk). A micro-benchmark draining 1000 small chunks is ~11x faster. Co-authored-by: Yagiz Nizipli <yagiz@nizipli.com>
1 parent 5253e70 commit c59788c

3 files changed

Lines changed: 126 additions & 10 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
'@tanstack/start-client-core': patch
3+
---
4+
5+
perf: drop consumed chunks from the client frame decoder buffer with an O(1) head pointer instead of `Array.prototype.shift()` (O(n)). The previous approach degraded to O(n^2) when a single large frame (e.g. a big `RawStream` payload) was assembled from many small network reads.

packages/start-client-core/src/client-rpc/frame-decoder.ts

Lines changed: 27 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -133,8 +133,26 @@ export function createFrameDecoder(
133133
inputReader = reader
134134

135135
const bufferList: Array<Uint8Array> = []
136+
// Index of the first un-consumed chunk in bufferList. Advancing this
137+
// pointer is O(1); using bufferList.shift() to drop a consumed chunk is
138+
// O(n) and degrades to O(n^2) when a single large frame is assembled from
139+
// many small chunks (e.g. a big RawStream payload split across reads).
140+
let bufferHead = 0
136141
let totalLength = 0
137142

143+
function advanceBufferHead(): void {
144+
bufferList[bufferHead++] = EMPTY_BUFFER
145+
146+
// Reset drained buffers immediately and compact long-lived buffers in batches.
147+
if (bufferHead === bufferList.length) {
148+
bufferList.length = 0
149+
bufferHead = 0
150+
} else if (bufferHead >= 32) {
151+
bufferList.splice(0, bufferHead)
152+
bufferHead = 0
153+
}
154+
}
155+
138156
/**
139157
* Reads header bytes from buffer chunks without flattening.
140158
* Returns header data or null if not enough bytes available.
@@ -146,7 +164,7 @@ export function createFrameDecoder(
146164
} | null {
147165
if (totalLength < FRAME_HEADER_SIZE) return null
148166

149-
const first = bufferList[0]!
167+
const first = bufferList[bufferHead]!
150168

151169
// Fast path: header fits entirely in first chunk (common case)
152170
if (first.length >= FRAME_HEADER_SIZE) {
@@ -170,7 +188,7 @@ export function createFrameDecoder(
170188
const headerBytes = new Uint8Array(FRAME_HEADER_SIZE)
171189
let offset = 0
172190
let remaining = FRAME_HEADER_SIZE
173-
for (let i = 0; i < bufferList.length && remaining > 0; i++) {
191+
for (let i = bufferHead; i < bufferList.length && remaining > 0; i++) {
174192
const chunk = bufferList[i]!
175193
const toCopy = Math.min(chunk.length, remaining)
176194
headerBytes.set(chunk.subarray(0, toCopy), offset)
@@ -207,13 +225,13 @@ export function createFrameDecoder(
207225
// copying `count` bytes. The view shares the chunk's backing ArrayBuffer,
208226
// which is safe because buffered chunks are never mutated in place after
209227
// being read from the network.
210-
const first = bufferList[0]
228+
const first = bufferList[bufferHead]
211229
if (first && first.length >= count) {
212230
const result = first.subarray(0, count)
213231
if (first.length === count) {
214-
bufferList.shift()
232+
advanceBufferHead()
215233
} else {
216-
bufferList[0] = first.subarray(count)
234+
bufferList[bufferHead] = first.subarray(count)
217235
}
218236
totalLength -= count
219237
return result
@@ -224,19 +242,18 @@ export function createFrameDecoder(
224242
let offset = 0
225243
let remaining = count
226244

227-
while (remaining > 0 && bufferList.length > 0) {
228-
const chunk = bufferList[0]
229-
if (!chunk) break
245+
while (remaining > 0 && bufferHead < bufferList.length) {
246+
const chunk = bufferList[bufferHead]!
230247
const toCopy = Math.min(chunk.length, remaining)
231248
result.set(chunk.subarray(0, toCopy), offset)
232249

233250
offset += toCopy
234251
remaining -= toCopy
235252

236253
if (toCopy === chunk.length) {
237-
bufferList.shift()
254+
advanceBufferHead()
238255
} else {
239-
bufferList[0] = chunk.subarray(toCopy)
256+
bufferList[bufferHead] = chunk.subarray(toCopy)
240257
}
241258
}
242259

packages/start-client-core/tests/frame-decoder.test.ts

Lines changed: 94 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -610,5 +610,99 @@ describe('frame-decoder', () => {
610610
}
611611
expect(received).toEqual(Array.from(payload))
612612
})
613+
614+
it('reassembles a large chunk payload delivered one byte at a time', async () => {
615+
// Forces the header slow path AND many whole-chunk consumptions within a
616+
// single extract, exercising the head-pointer advance + fully-drained
617+
// reset. With the previous bufferList.shift() this path was O(n^2).
618+
const payload = new Uint8Array(200)
619+
for (let i = 0; i < payload.length; i++) {
620+
payload[i] = (i * 7) % 256
621+
}
622+
623+
const jsonFrame = encodeJSONFrame('{"ref":21}')
624+
const chunkFrame = encodeChunkFrame(21, payload)
625+
const endFrame = encodeEndFrame(21)
626+
627+
const combined = new Uint8Array(
628+
jsonFrame.length + chunkFrame.length + endFrame.length,
629+
)
630+
combined.set(jsonFrame, 0)
631+
combined.set(chunkFrame, jsonFrame.length)
632+
combined.set(endFrame, jsonFrame.length + chunkFrame.length)
633+
634+
const input = new ReadableStream<Uint8Array>({
635+
start(controller) {
636+
for (let i = 0; i < combined.length; i++) {
637+
controller.enqueue(combined.subarray(i, i + 1))
638+
}
639+
controller.close()
640+
},
641+
})
642+
643+
const { getStream: getOrCreateStream, chunks: jsonChunks } =
644+
createFrameDecoder(input)
645+
const stream21 = getOrCreateStream(21)
646+
647+
const jsonReader = jsonChunks.getReader()
648+
const { value: jsonValue } = await jsonReader.read()
649+
expect(jsonValue).toBe('{"ref":21}')
650+
651+
const rawReader = stream21.getReader()
652+
const received: Array<number> = []
653+
while (true) {
654+
const { done, value } = await rawReader.read()
655+
if (done) {
656+
break
657+
}
658+
if (value) {
659+
received.push(...value)
660+
}
661+
}
662+
expect(received).toEqual(Array.from(payload))
663+
})
664+
665+
it('decodes many frames when reads never align with frame boundaries', async () => {
666+
// 100-byte frames fed in 7-byte reads never align until the very end, so
667+
// consumed chunks accumulate and the head pointer climbs past the
668+
// compaction threshold repeatedly, exercising the splice() prefix drop.
669+
const FRAME_COUNT = 7
670+
const expected: Array<string> = []
671+
const frames: Array<Uint8Array> = []
672+
for (let i = 0; i < FRAME_COUNT; i++) {
673+
const payload = `frame-${i}`.padEnd(91, '.') // 91 bytes => 100-byte frame
674+
expected.push(payload)
675+
frames.push(encodeJSONFrame(payload))
676+
}
677+
678+
const totalLen = frames.reduce((acc, f) => acc + f.length, 0)
679+
const combined = new Uint8Array(totalLen)
680+
let offset = 0
681+
for (const f of frames) {
682+
combined.set(f, offset)
683+
offset += f.length
684+
}
685+
686+
const input = new ReadableStream<Uint8Array>({
687+
start(controller) {
688+
for (let i = 0; i < combined.length; i += 7) {
689+
controller.enqueue(combined.subarray(i, i + 7))
690+
}
691+
controller.close()
692+
},
693+
})
694+
695+
const { chunks: jsonChunks } = createFrameDecoder(input)
696+
const reader = jsonChunks.getReader()
697+
const received: Array<string> = []
698+
while (true) {
699+
const { done, value } = await reader.read()
700+
if (done) {
701+
break
702+
}
703+
received.push(value)
704+
}
705+
expect(received).toEqual(expected)
706+
})
613707
})
614708
})

0 commit comments

Comments
 (0)