Files
orca/src/shared/search-subprocess-lines.ts
T

123 lines
3.6 KiB
TypeScript

export const SEARCH_SUBPROCESS_MAX_LINE_BYTES = 64 * 1024 * 1024
const SEARCH_SUBPROCESS_INITIAL_LINE_BUFFER_BYTES = 4 * 1024
export class SearchSubprocessLineAccumulator {
private buffer: Buffer | null = null
private bytes = 0
private discardingLine = false
constructor(
private readonly maxLineBytes = SEARCH_SUBPROCESS_MAX_LINE_BYTES,
private readonly oversizedLine: 'reject' | 'discard' = 'reject'
) {
if (!Number.isSafeInteger(maxLineBytes) || maxLineBytes < 0) {
throw new RangeError('Search line limit must be a non-negative safe integer')
}
}
/** False reports an oversized line, even when discard mode continues with later lines. */
push(rawChunk: Buffer | string, onLine: (line: string) => void): boolean {
// Three bytes per UTF-16 code unit bounds UTF-8 size without re-encoding complete batches.
if (
typeof rawChunk === 'string' &&
this.bytes === 0 &&
!this.discardingLine &&
rawChunk.endsWith('\n') &&
rawChunk.length * 3 <= this.maxLineBytes
) {
const lines = rawChunk.split('\n')
lines.pop()
for (const line of lines) {
onLine(line)
}
return true
}
const chunk = Buffer.isBuffer(rawChunk) ? rawChunk : Buffer.from(rawChunk, 'utf8')
let cursor = 0
let overflowed = false
while (cursor < chunk.length) {
const newline = chunk.indexOf(0x0a, cursor)
const end = newline === -1 ? chunk.length : newline
const segmentBytes = end - cursor
if (this.discardingLine) {
if (newline === -1) {
return !overflowed
}
this.discardingLine = false
cursor = newline + 1
continue
}
if (this.bytes + segmentBytes > this.maxLineBytes) {
this.clear()
if (this.oversizedLine === 'reject') {
return false
}
overflowed = true
this.discardingLine = newline === -1
if (newline === -1) {
return false
}
cursor = newline + 1
continue
}
if (newline !== -1 && this.bytes === 0) {
onLine(chunk.toString('utf8', cursor, end))
} else if (segmentBytes > 0) {
this.append(chunk.subarray(cursor, end))
if (newline !== -1) {
onLine(this.takeLine())
}
} else if (newline !== -1) {
onLine(this.takeLine())
}
if (newline === -1) {
return !overflowed
}
cursor = newline + 1
}
return !overflowed
}
finish(): string | null {
if (this.discardingLine) {
this.clear()
}
return this.bytes > 0 ? this.takeLine() : null
}
clear(): void {
this.buffer = null
this.bytes = 0
this.discardingLine = false
}
/** Capacity of the retained growable buffer, or null once it has been released. */
retainedCapacityBytes(): number | null {
return this.buffer?.length ?? null
}
private append(segment: Buffer): void {
const requiredBytes = this.bytes + segment.length
if (!this.buffer || this.buffer.length < requiredBytes) {
const doubledCapacity = this.buffer?.length ? this.buffer.length * 2 : 0
const nextCapacity = Math.min(
this.maxLineBytes,
Math.max(SEARCH_SUBPROCESS_INITIAL_LINE_BUFFER_BYTES, doubledCapacity, requiredBytes)
)
const next = Buffer.allocUnsafe(nextCapacity)
this.buffer?.copy(next, 0, 0, this.bytes)
this.buffer = next
}
segment.copy(this.buffer, this.bytes)
this.bytes = requiredBytes
}
private takeLine(): string {
const line = this.buffer?.toString('utf8', 0, this.bytes) ?? ''
this.clear()
return line
}
}