Skip to content

Commit bc54f85

Browse files
authored
Merge pull request #132 from maucher/fix/streaming-oom-readSessionLines
fix: switch scanJsonlFile and parseSessionFile to readSessionLines to prevent OOM
2 parents d4e07de + 5e49f17 commit bc54f85

3 files changed

Lines changed: 43 additions & 10 deletions

File tree

src/optimize.ts

Lines changed: 2 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { existsSync, statSync } from 'fs'
44
import { basename, join } from 'path'
55
import { homedir } from 'os'
66

7-
import { readSessionFile, readSessionFileSync } from './fs-utils.js'
7+
import { readSessionLines, readSessionFileSync } from './fs-utils.js'
88
import { discoverAllSessions } from './providers/index.js'
99
import type { DateRange, ProjectSummary } from './types.js'
1010
import { formatCost } from './currency.js'
@@ -224,17 +224,14 @@ export async function scanJsonlFile(
224224
dateRange: DateRange | undefined,
225225
recentCutoffMs = Date.now() - RECENT_WINDOW_MS,
226226
): Promise<ScanFileResult> {
227-
const content = await readSessionFile(filePath)
228-
if (content === null) return { calls: [], cwds: [], apiCalls: [], userMessages: [] }
229-
230227
const calls: ToolCall[] = []
231228
const cwds: string[] = []
232229
const apiCalls: ApiCallMeta[] = []
233230
const userMessages: string[] = []
234231
const sessionId = basename(filePath, '.jsonl')
235232
let lastVersion = ''
236233

237-
for (const line of content.split('\n')) {
234+
for await (const line of readSessionLines(filePath)) {
238235
if (!line.trim()) continue
239236
let entry: Record<string, unknown>
240237
try { entry = JSON.parse(line) } catch { continue }

src/parser.ts

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { readdir, stat } from 'fs/promises'
22
import { basename, join } from 'path'
3-
import { readSessionFile } from './fs-utils.js'
3+
import { readSessionLines } from './fs-utils.js'
44
import { calculateCost, getShortModelName } from './models.js'
55
import { discoverAllSessions, getProvider } from './providers/index.js'
66
import type { ParsedProviderCall } from './providers/types.js'
@@ -275,16 +275,17 @@ async function parseSessionFile(
275275
if (s.mtimeMs < dateRange.start.getTime()) return null
276276
} catch { /* fall through to normal read; missing stat shouldn't break parsing */ }
277277
}
278-
const content = await readSessionFile(filePath)
279-
if (content === null) return null
280-
const lines = content.split('\n').filter(l => l.trim())
281278
const entries: JournalEntry[] = []
279+
let hasLines = false
282280

283-
for (const line of lines) {
281+
for await (const line of readSessionLines(filePath)) {
282+
hasLines = true
284283
const entry = parseJsonlLine(line)
285284
if (entry) entries.push(entry)
286285
}
287286

287+
if (!hasLines) return null
288+
288289
if (entries.length === 0) return null
289290

290291
const sessionId = basename(filePath, '.jsonl')

tests/optimize-fs.test.ts

Lines changed: 35 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import { describe, it, expect, afterAll, beforeEach, vi } from 'vitest'
22
import { mkdtempSync, rmSync, mkdirSync, writeFileSync, utimesSync } from 'fs'
33
import { tmpdir } from 'os'
44
import { join } from 'path'
5+
import * as fsUtils from '../src/fs-utils.js'
56

67
vi.mock('os', async () => {
78
const actual = await vi.importActual<typeof import('os')>('os')
@@ -313,6 +314,40 @@ describe('scanJsonlFile', () => {
313314
expect(result.calls).toEqual([])
314315
})
315316

317+
it('uses readSessionLines (streaming) rather than readSessionFile (full-string load)', async () => {
318+
const readSessionLinesSpy = vi.spyOn(fsUtils, 'readSessionLines')
319+
const readSessionFileSpy = vi.spyOn(fsUtils, 'readSessionFile')
320+
const root = makeFixtureRoot()
321+
const filePath = join(root, 'session.jsonl')
322+
const now = new Date().toISOString()
323+
writeFile(filePath, JSON.stringify({
324+
type: 'assistant', timestamp: now,
325+
message: { content: [{ type: 'tool_use', name: 'Bash', input: {} }] },
326+
}))
327+
await scanJsonlFile(filePath, 'p1', undefined)
328+
expect(readSessionLinesSpy).toHaveBeenCalledWith(filePath)
329+
expect(readSessionFileSpy).not.toHaveBeenCalled()
330+
readSessionLinesSpy.mockRestore()
331+
readSessionFileSpy.mockRestore()
332+
})
333+
334+
it('processes all entries in a large multi-line file without truncation', async () => {
335+
const root = makeFixtureRoot()
336+
const filePath = join(root, 'session.jsonl')
337+
const now = new Date().toISOString()
338+
const ENTRY_COUNT = 500
339+
const lines = Array.from({ length: ENTRY_COUNT }, (_, i) =>
340+
JSON.stringify({
341+
type: 'assistant',
342+
timestamp: now,
343+
message: { content: [{ type: 'tool_use', name: 'Read', input: { file_path: `/file-${i}.ts` } }] },
344+
}),
345+
)
346+
writeFile(filePath, lines.join('\n'))
347+
const result = await scanJsonlFile(filePath, 'p1', undefined)
348+
expect(result.calls).toHaveLength(ENTRY_COUNT)
349+
})
350+
316351
it('respects date-range filter for assistant entries', async () => {
317352
const root = makeFixtureRoot()
318353
const filePath = join(root, 'session.jsonl')

0 commit comments

Comments
 (0)