Skip to content

Commit b6f90bf

Browse files
committed
fix: harden transcript tail cursors
1 parent 0c67e72 commit b6f90bf

2 files changed

Lines changed: 40 additions & 29 deletions

File tree

src/server/__tests__/isolated/indexHandlers.test.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4924,9 +4924,9 @@ describe('server fetch handlers', () => {
49244924
const logPath = path.join(tempDir, 'session.jsonl')
49254925
const lines = Array.from(
49264926
{ length: 5200 },
4927-
(_, index) => `line-${String(index).padStart(4, '0')}-${'x'.repeat(1000)}`
4927+
(_, index) => `line-${String(index).padStart(4, '0')}-🙂-${'x'.repeat(1000)}`
49284928
)
4929-
await fs.writeFile(logPath, lines.join('\n'))
4929+
await fs.writeFile(logPath, `${lines.join('\r\n')}\r\n`)
49304930

49314931
seedRecord(
49324932
makeRecord({
@@ -4963,9 +4963,12 @@ describe('server fetch handlers', () => {
49634963
expect(payload.lines).toHaveLength(25)
49644964
expect(payload.lines[0]?.startsWith('line-5175-')).toBe(true)
49654965
expect(payload.lines[24]?.startsWith('line-5199-')).toBe(true)
4966+
expect(payload.lines[0]).toContain('🙂')
4967+
expect(payload.lines.join('\n')).not.toContain('�')
49664968
expect(payload.startByte).toBeLessThan(payload.endByte)
49674969
expect(payload.lineKeys).toHaveLength(25)
49684970
expect(payload.lineKeys[0]).toMatch(/^b:\d+$/)
4971+
expect(payload.lineKeys[0]).toBe(`b:${payload.startByte}`)
49694972

49704973
const earlierResponse = await fetchHandler.call(
49714974
{} as Bun.Server<unknown>,
@@ -4990,6 +4993,8 @@ describe('server fetch handlers', () => {
49904993
expect(earlierPayload.lines).toHaveLength(25)
49914994
expect(earlierPayload.lines[0]?.startsWith('line-5150-')).toBe(true)
49924995
expect(earlierPayload.lines[24]?.startsWith('line-5174-')).toBe(true)
4996+
expect(earlierPayload.lines[0]).toContain('🙂')
4997+
expect(earlierPayload.lines.join('\n')).not.toContain('�')
49934998
} finally {
49944999
await fs.rm(tempDir, { recursive: true, force: true })
49955000
}

src/server/index.ts

Lines changed: 33 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -182,14 +182,6 @@ async function readAllLogLines(logPath: string): Promise<string[]> {
182182
return lines
183183
}
184184

185-
function splitTailChunk(text: string): string[] {
186-
const lines = text.split(/\r?\n/)
187-
if (lines.length > 0 && lines[lines.length - 1] === '') {
188-
lines.pop()
189-
}
190-
return lines
191-
}
192-
193185
// Streaming fallback for compatibility with older beforeLine requests. This still
194186
// scans the whole file, so byte cursors are used for large-log pagination below.
195187
async function streamLogLineWindow(
@@ -256,7 +248,7 @@ async function readLogLineWindowFromEnd(
256248

257249
const handle = await fs.open(logPath, 'r')
258250
const chunkSize = 64 * 1024
259-
const chunks: string[] = []
251+
const chunks: Buffer[] = []
260252
let cursor = endByte
261253
let newlineCount = 0
262254
let sawNonEmptyContent = false
@@ -268,20 +260,42 @@ async function readLogLineWindowFromEnd(
268260
const buffer = Buffer.allocUnsafe(readSize)
269261
const { bytesRead } = await handle.read(buffer, 0, readSize, start)
270262
if (bytesRead <= 0) break
271-
const chunk = buffer.subarray(0, bytesRead).toString('utf8')
263+
const chunk = buffer.subarray(0, bytesRead)
272264
chunks.unshift(chunk)
273-
if (chunk.trimEnd().length > 0) sawNonEmptyContent = true
274-
newlineCount += (chunk.match(/\n/g) ?? []).length
265+
for (const byte of chunk) {
266+
if (byte === 0x0a) newlineCount += 1
267+
if (byte > 0x20) sawNonEmptyContent = true
268+
}
275269
cursor = start
276270
}
277271
} finally {
278272
await handle.close()
279273
}
280274

281-
const text = chunks.join('')
282-
const lines = splitTailChunk(text)
283-
const selectedLines = lines.slice(-limit)
284-
if (selectedLines.length === 0 && !sawNonEmptyContent) {
275+
const buffer = Buffer.concat(chunks)
276+
let contentEnd = buffer.length
277+
if (contentEnd > 0 && buffer[contentEnd - 1] === 0x0a) {
278+
contentEnd -= 1
279+
}
280+
281+
const lineStarts = [0]
282+
for (let index = 0; index < contentEnd; index += 1) {
283+
if (buffer[index] === 0x0a) {
284+
lineStarts.push(index + 1)
285+
}
286+
}
287+
288+
const selectedStarts = lineStarts.slice(-limit)
289+
const selectedLines = selectedStarts.map((startOffset, index) => {
290+
const nextStart = selectedStarts[index + 1]
291+
let endOffset = nextStart === undefined ? contentEnd : nextStart - 1
292+
if (endOffset > startOffset && buffer[endOffset - 1] === 0x0d) {
293+
endOffset -= 1
294+
}
295+
return buffer.subarray(startOffset, endOffset).toString('utf8')
296+
})
297+
298+
if (contentEnd === 0 && !sawNonEmptyContent) {
285299
return {
286300
totalLines: null,
287301
startLine: 0,
@@ -293,17 +307,9 @@ async function readLogLineWindowFromEnd(
293307
}
294308
}
295309

296-
const selectedText = selectedLines.join('\n')
297-
const encodedLength = Buffer.byteLength(selectedText, 'utf8')
298-
const hasTrailingNewline = text.endsWith('\n')
299-
const trailingBytes = hasTrailingNewline ? 1 : 0
300-
const startByte = Math.max(0, endByte - trailingBytes - encodedLength)
301-
let lineStartByte = startByte
302-
const lineKeys = selectedLines.map((line) => {
303-
const key = `b:${lineStartByte}`
304-
lineStartByte += Buffer.byteLength(line, 'utf8') + 1
305-
return key
306-
})
310+
const selectedStartByte = selectedStarts[0] ?? contentEnd
311+
const startByte = Math.max(0, cursor + selectedStartByte)
312+
const lineKeys = selectedStarts.map((startOffset) => `b:${cursor + startOffset}`)
307313

308314
return {
309315
totalLines: null,

0 commit comments

Comments
 (0)