Skip to content

Commit 3fe1222

Browse files
fix(usage): stop retaining full session-file text forever, root cause of the crash
Root cause of the "closes for no reason, kills every session" crash, found while the crash was reproducing live: usageScan's per-file cache stored the FULL TEXT of every Claude session .jsonl file forever (keyed by path, replaced on mtime change, but never evicted otherwise, and never bounded by size). projectsView.ts calls usage:report(0) (all-time, unconditionally) on every deck load to fill in each card's cost badge — so on a cold start this cache gets populated with EVERY BYTE of the user's entire history. Measured on the real data: ~/.claude/projects held 5,070 session files totaling 2.5GB on disk (one single transcript was 347MB). Live process inspection during an actual crash cycle showed the main process ballooning to ~3.8GB within ~2 minutes of a cold start — a V8 "JavaScript heap out of memory" abort at that size bypasses uncaughtException/ unhandledRejection entirely (nothing was ever logged to devdeck-errors.log despite v1.12.1's new traps) and takes every cockpit terminal down with the main process. Fix: files over MAX_CACHED_FILE_BYTES (5MB) are still read and aggregated correctly, but never RETAINED in the cache — trading a bounded, transient re-parse cost on future calls for eliminating the multi-GB permanent footprint. Verified against the real 2.5GB dataset (same repro as the live crash): peak transient rss during a full all-time scan is ~933MB (vs. ~3.8GB+ permanently held before), settling to ~263MB after GC. Global totals unchanged (249M tokens, matching the pre-fix count) — correctness preserved, only retention changed. 342 tests (+2, TDD). Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent ae189c0 commit 3fe1222

4 files changed

Lines changed: 46 additions & 8 deletions

File tree

README.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,7 @@ See every repo's state at a glance — git status, how long it's been neglected,
1111
![License](https://img.shields.io/badge/license-MIT-blue)
1212
![Platform](https://img.shields.io/badge/platform-Windows%20%7C%20macOS%20%7C%20Linux-0078D6)
1313
![Built with Electron](https://img.shields.io/badge/Electron-31-47848F)
14-
![Tests](https://img.shields.io/badge/tests-340%20passing-3fb950)
14+
![Tests](https://img.shields.io/badge/tests-342%20passing-3fb950)
1515
![CI](https://github.com/writingdeveloper/devdeck/actions/workflows/ci.yml/badge.svg)
1616

1717
<img src="docs/demo/demo.gif" width="820" alt="DevDeck demo" />

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "devdeck",
3-
"version": "1.12.1",
3+
"version": "1.12.2",
44
"description": "Project command deck — at-a-glance state + claude -c resume",
55
"main": "dist/main/main.js",
66
"type": "commonjs",

src/main/usageScan.test.ts

Lines changed: 27 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,10 +2,10 @@ import { describe, it, expect, beforeEach, afterEach } from 'vitest';
22
import { mkdtempSync, mkdirSync, writeFileSync, rmSync } from 'node:fs';
33
import { tmpdir } from 'node:os';
44
import { join } from 'node:path';
5-
import { scanUsage } from './usageScan';
5+
import { scanUsage, _cacheHasFile, _clearFileCache, MAX_CACHED_FILE_BYTES } from './usageScan';
66

77
let root: string;
8-
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'devdeck-usage-')); });
8+
beforeEach(() => { root = mkdtempSync(join(tmpdir(), 'devdeck-usage-')); _clearFileCache(); });
99
afterEach(() => rmSync(root, { recursive: true, force: true }));
1010

1111
function asst(model: string, u: Record<string, number>, ts = '2026-06-01T10:00:00.000Z') {
@@ -78,6 +78,31 @@ describe('scanUsage', () => {
7878
expect(r.global.input).toBe(10); // deleted project's tokens included in the honest total
7979
});
8080

81+
it('caches a small session file (memory-bounded perf cache still works for the common case)', () => {
82+
const d = join(root, 'C--g-small');
83+
mkdirSync(d, { recursive: true });
84+
const f = join(d, 's.jsonl');
85+
writeFileSync(f, asst('claude-opus-4-8', { input_tokens: 1 }));
86+
scanUsage([{ path: 'C:\\g\\small', name: 'small' }], root, Infinity);
87+
expect(_cacheHasFile(f)).toBe(true);
88+
});
89+
90+
it('does NOT cache a session file over MAX_CACHED_FILE_BYTES — a huge transcript must never be held in memory forever', () => {
91+
// Real-world trigger: a multi-hundred-MB Claude session file, held forever in a module-level Map,
92+
// ballooned the main process to multiple GB within ~1 minute of a cold start (the eager,
93+
// unfiltered projectsView.ts per-project cost fill calls usage:report(0) = every file, all time)
94+
// and crashed it with no catchable exception. Oversized files must be processed but NOT retained.
95+
const d = join(root, 'C--g-huge');
96+
mkdirSync(d, { recursive: true });
97+
const f = join(d, 's.jsonl');
98+
const line = asst('claude-opus-4-8', { input_tokens: 1 });
99+
const pad = 'x'.repeat(MAX_CACHED_FILE_BYTES); // one line alone already exceeds the cap
100+
writeFileSync(f, line + '\n// ' + pad);
101+
const r = scanUsage([{ path: 'C:\\g\\huge', name: 'huge' }], root, Infinity);
102+
expect(_cacheHasFile(f)).toBe(false);
103+
expect(r.global.input).toBe(1); // still processed correctly even though not cached
104+
});
105+
81106
it('sums active time from message gaps, capping idle stretches', () => {
82107
const d = join(root, 'C--g-time');
83108
mkdirSync(d, { recursive: true });

src/main/usageScan.ts

Lines changed: 17 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,21 @@ import type { UsageReport, ProjectUsage, ModelUsage } from '../shared/types';
66

77
// Cache: filepath -> { mtime, parsed lines }. Keyed by PATH (not path+mtime), with the mtime stored
88
// in the value, so a modified file REPLACES its entry instead of leaking a new key per modification
9-
// in this long-lived process. One entry per session file; bounded by the number of distinct files.
9+
// in this long-lived process. Bounded by the number of distinct files — BUT that bound is only on
10+
// entry COUNT, not total bytes: a power user's ~/.claude/projects can hold thousands of session
11+
// files, some hundreds of MB (one real transcript was 347MB). Caching every one's full text forever
12+
// ballooned the main process to several GB within ~1 minute of a cold start (projectsView.ts's
13+
// per-project cost fill calls usage:report(0) = every file, all time, unconditionally on every deck
14+
// load) and crashed it with a V8 "out of memory" abort that bypasses every JS exception handler.
15+
// Files over MAX_CACHED_FILE_BYTES are still read and aggregated correctly — just never RETAINED.
16+
export const MAX_CACHED_FILE_BYTES = 5 * 1024 * 1024; // 5MB — generous for the vast majority of sessions
1017
const _fileCache = new Map<string, { mtimeMs: number; lines: string[] }>();
1118

19+
/** Test-only introspection: does the cache currently hold an entry for this file path? */
20+
export function _cacheHasFile(path: string): boolean { return _fileCache.has(path); }
21+
/** Test-only: reset cache state between tests so assertions aren't affected by cross-test leakage. */
22+
export function _clearFileCache(): void { _fileCache.clear(); }
23+
1224
interface RepoRef { path: string; name: string; status?: 'active' | 'deleted'; }
1325

1426
function dayKey(ts: string | undefined, fallbackMs: number): string {
@@ -46,8 +58,8 @@ export function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sinceMs:
4658
try { files = readdirSync(dir).filter((f) => f.endsWith('.jsonl')); } catch { files = []; }
4759
for (const f of files) {
4860
const full = join(dir, f);
49-
let fileMs = Date.now();
50-
try { fileMs = statSync(full).mtimeMs; } catch { /* keep now */ }
61+
let fileMs = Date.now(), fileSize = 0;
62+
try { const st = statSync(full); fileMs = st.mtimeMs; fileSize = st.size; } catch { /* keep defaults */ }
5163
const cached = _fileCache.get(full);
5264
let lines: string[];
5365
if (cached && cached.mtimeMs === fileMs) {
@@ -56,7 +68,8 @@ export function scanUsage(repos: RepoRef[], claudeProjectsDir: string, sinceMs:
5668
let text = '';
5769
try { text = readFileSync(full, 'utf8'); } catch { continue; }
5870
lines = text.split('\n');
59-
_fileCache.set(full, { mtimeMs: fileMs, lines }); // replaces any stale entry for this path
71+
if (fileSize <= MAX_CACHED_FILE_BYTES) _fileCache.set(full, { mtimeMs: fileMs, lines }); // replaces any stale entry
72+
else _fileCache.delete(full); // a previously-small, now-grown file must not linger in the cache
6073
}
6174
projSessions++;
6275
const stamps: number[] = []; // in-range message timestamps, for active-time gaps

0 commit comments

Comments
 (0)