Skip to content

Commit 75e23ec

Browse files
psypealclaude
andcommitted
fix: crash prevention with bounded caches and session-scoped invalidation
- Add LRU eviction (500 entries) for ProjectScanner caches to prevent unbounded growth over long sessions - Add invalidateCachesForSession() for single-file cache invalidation instead of purging entire project on every file-change event - Add renderer memory monitoring (5-min interval, 2GB warning threshold) to detect leaks before they crash the renderer - Add crash recovery with retry caps, unresponsive watchdog, and crash logging to ~/.claude/claude-devtools-crash.log - Add SubagentMessageCache (LRU, 10 entries, 10min TTL) for lazy-loaded subagent message bodies, distinct from the main DataCache - Add IPC + HTTP handlers for on-demand subagent message loading so the renderer can fetch bodies only when a subagent is expanded - Fix SubagentDetailBuilder path construction to use buildSubagentsPath() Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1 parent 34b0aad commit 75e23ec

16 files changed

Lines changed: 680 additions & 22 deletions

File tree

src/main/http/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,7 @@ import type {
2828
UpdaterService,
2929
} from '../services';
3030
import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager';
31+
import type { SubagentMessageCache } from '../services/infrastructure/SubagentMessageCache';
3132
import type { FastifyInstance } from 'fastify';
3233

3334
const logger = createLogger('HTTP:routes');
@@ -38,6 +39,7 @@ export interface HttpServices {
3839
subagentResolver: SubagentResolver;
3940
chunkBuilder: ChunkBuilder;
4041
dataCache: DataCache;
42+
subagentMessageCache: SubagentMessageCache;
4143
updaterService: UpdaterService;
4244
sshConnectionManager: SshConnectionManager;
4345
}

src/main/http/subagents.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,12 +2,19 @@
22
* HTTP route handlers for Subagent Operations.
33
*
44
* Routes:
5-
* - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId - Subagent detail
5+
* - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId
6+
* → Subagent detail (drill-down modal payload).
7+
* - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId/messages
8+
* → Lazy-load full message body for inline expansion. Mirrors the IPC
9+
* handler so browser mode works the same as Electron mode.
610
*/
711

812
import { createLogger } from '@shared/utils/logger';
13+
import * as path from 'path';
914

1015
import { validateProjectId, validateSessionId, validateSubagentId } from '../ipc/guards';
16+
import { SubagentMessageCache } from '../services/infrastructure/SubagentMessageCache';
17+
import { buildSubagentsPath } from '../utils/pathDecoder';
1118

1219
import type { HttpServices } from './index';
1320
import type { FastifyInstance } from 'fastify';
@@ -74,4 +81,52 @@ export function registerSubagentRoutes(app: FastifyInstance, services: HttpServi
7481
}
7582
}
7683
);
84+
85+
// Lazy-load subagent message bodies (mirrors the IPC handler).
86+
app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>(
87+
'/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId/messages',
88+
async (request) => {
89+
try {
90+
const validatedProject = validateProjectId(request.params.projectId);
91+
const validatedSession = validateSessionId(request.params.sessionId);
92+
const validatedSubagent = validateSubagentId(request.params.subagentId);
93+
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
94+
logger.error(
95+
`GET subagent-messages rejected: ${
96+
validatedProject.error ??
97+
validatedSession.error ??
98+
validatedSubagent.error ??
99+
'Invalid parameters'
100+
}`
101+
);
102+
return [];
103+
}
104+
const safeProjectId = validatedProject.value!;
105+
const safeSessionId = validatedSession.value!;
106+
const safeSubagentId = validatedSubagent.value!;
107+
108+
const cacheKey = SubagentMessageCache.buildKey(
109+
safeProjectId,
110+
safeSessionId,
111+
safeSubagentId
112+
);
113+
const cached = services.subagentMessageCache.get(cacheKey);
114+
if (cached) {
115+
return cached;
116+
}
117+
118+
// Layout: {projectsDir}/{baseProjectId}/{sessionId}/subagents/agent-X.jsonl
119+
const projectsDir = services.projectScanner.getProjectsDir();
120+
const subagentsDir = buildSubagentsPath(projectsDir, safeProjectId, safeSessionId);
121+
const subagentPath = path.join(subagentsDir, `agent-${safeSubagentId}.jsonl`);
122+
123+
const parsed = await services.sessionParser.parseSessionFile(subagentPath);
124+
services.subagentMessageCache.set(cacheKey, parsed.messages);
125+
return parsed.messages;
126+
} catch (error) {
127+
logger.error(`Error in GET subagent-messages for ${request.params.subagentId}:`, error);
128+
return [];
129+
}
130+
}
131+
);
77132
}

src/main/index.ts

Lines changed: 201 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -18,10 +18,31 @@ import {
1818
} from '@shared/constants';
1919
import { createLogger } from '@shared/utils/logger';
2020
import { app, BrowserWindow, ipcMain } from 'electron';
21-
import { existsSync } from 'fs';
22-
import { totalmem } from 'os';
21+
import { appendFileSync, existsSync, mkdirSync } from 'fs';
22+
import { homedir, totalmem } from 'os';
2323
import { join } from 'path';
2424

25+
/**
26+
* Append a timestamped entry to ~/.claude/claude-devtools-crash.log.
27+
* Uses sync I/O because crashes may happen in unstable states.
28+
*/
29+
function writeCrashLog(label: string, details: Record<string, unknown>): void {
30+
try {
31+
const dir = join(homedir(), '.claude');
32+
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
33+
const logPath = join(dir, 'claude-devtools-crash.log');
34+
const entry =
35+
`[${new Date().toISOString()}] ${label}\n` +
36+
Object.entries(details)
37+
.map(([k, v]) => ` ${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
38+
.join('\n') +
39+
'\n\n';
40+
appendFileSync(logPath, entry, 'utf-8');
41+
} catch {
42+
// Best-effort — don't throw during crash handling
43+
}
44+
}
45+
2546
import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
2647
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';
2748

@@ -60,10 +81,17 @@ const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';
6081

6182
process.on('unhandledRejection', (reason) => {
6283
logger.error('Unhandled promise rejection in main process:', reason);
84+
writeCrashLog('UNHANDLED_REJECTION (main)', {
85+
reason: reason instanceof Error ? reason.stack ?? reason.message : String(reason),
86+
});
6387
});
6488

65-
process.on('uncaughtException', (error) => {
89+
process.on('uncaughtException', (error: Error) => {
6690
logger.error('Uncaught exception in main process:', error);
91+
writeCrashLog('UNCAUGHT_EXCEPTION (main)', {
92+
message: error.message,
93+
stack: error.stack ?? '',
94+
});
6795
});
6896

6997
import { HttpServer } from './services/infrastructure/HttpServer';
@@ -83,6 +111,7 @@ import {
83111
// =============================================================================
84112

85113
let mainWindow: BrowserWindow | null = null;
114+
let isQuitting = false;
86115

87116
// Service registry and global services
88117
let contextRegistry: ServiceContextRegistry;
@@ -366,6 +395,7 @@ async function startHttpServer(
366395
subagentResolver: activeContext.subagentResolver,
367396
chunkBuilder: activeContext.chunkBuilder,
368397
dataCache: activeContext.dataCache,
398+
subagentMessageCache: activeContext.subagentMessageCache,
369399
updaterService,
370400
sshConnectionManager,
371401
},
@@ -546,10 +576,137 @@ function createWindow(): void {
546576
}
547577
});
548578

549-
// Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event)
579+
// Handle renderer process crashes with retry cap to prevent crash loops.
580+
// Only auto-reload for recoverable reasons (crashed, oom, memory-eviction).
581+
// After 3 failures within 60s, stop reloading to avoid infinite loops.
582+
let crashCount = 0;
583+
let crashWindowStart = Date.now();
584+
const MAX_CRASHES = 3;
585+
const CRASH_WINDOW_MS = 60_000;
586+
const RECOVERABLE_REASONS = new Set(['crashed', 'oom', 'memory-eviction']);
587+
550588
mainWindow.webContents.on('render-process-gone', (_event, details) => {
589+
const memUsage = process.memoryUsage();
551590
logger.error('Renderer process gone:', details.reason, details.exitCode);
552-
// Could show an error dialog or attempt to reload the window
591+
writeCrashLog('RENDERER_PROCESS_GONE', {
592+
reason: details.reason,
593+
exitCode: details.exitCode,
594+
mainProcessRssMB: Math.round(memUsage.rss / 1024 / 1024),
595+
mainProcessHeapUsedMB: Math.round(memUsage.heapUsed / 1024 / 1024),
596+
mainProcessHeapTotalMB: Math.round(memUsage.heapTotal / 1024 / 1024),
597+
uptime: `${Math.round(process.uptime())}s`,
598+
});
599+
600+
if (isQuitting || !mainWindow || mainWindow.isDestroyed()) return;
601+
if (!RECOVERABLE_REASONS.has(details.reason)) return;
602+
603+
// Reset crash counter if outside window
604+
const now = Date.now();
605+
if (now - crashWindowStart > CRASH_WINDOW_MS) {
606+
crashCount = 0;
607+
crashWindowStart = now;
608+
}
609+
crashCount++;
610+
611+
if (crashCount > MAX_CRASHES) {
612+
logger.error(
613+
`Renderer crashed ${crashCount} times in ${CRASH_WINDOW_MS / 1000}s — not reloading`
614+
);
615+
return;
616+
}
617+
618+
if (process.env.NODE_ENV === 'development') {
619+
void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`);
620+
} else {
621+
void mainWindow.loadFile(getRendererIndexPath());
622+
}
623+
});
624+
625+
// Log renderer console errors (captures uncaught errors from the renderer process).
626+
// ResizeObserver loop errors are benign Chromium noise — skip them to keep the log clean.
627+
mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => {
628+
// level 3 = error
629+
if (level >= 3) {
630+
if (message.includes('ResizeObserver loop')) return;
631+
writeCrashLog('RENDERER_CONSOLE_ERROR', {
632+
message,
633+
source: `${sourceId}:${line}`,
634+
});
635+
}
636+
});
637+
638+
// Proactive unresponsive recovery.
639+
// When the renderer freezes, the Linux desktop environment (GNOME/KDE) may show its
640+
// own "Force Quit" dialog and kill the entire process tree. We race that by
641+
// force-reloading the renderer after UNRESPONSIVE_RELOAD_MS. If the renderer
642+
// becomes responsive again before the timer fires, we cancel the reload.
643+
// Capped at MAX_UNRESPONSIVE_RELOADS within UNRESPONSIVE_WINDOW_MS to prevent
644+
// infinite reload loops when a large session freezes the renderer on every load.
645+
const UNRESPONSIVE_RELOAD_MS = 10_000;
646+
const MAX_UNRESPONSIVE_RELOADS = 3;
647+
const UNRESPONSIVE_WINDOW_MS = 120_000; // 2 minutes
648+
let unresponsiveTimer: ReturnType<typeof setTimeout> | null = null;
649+
let unresponsiveReloadCount = 0;
650+
let unresponsiveWindowStart = Date.now();
651+
652+
mainWindow.on('unresponsive', () => {
653+
const memUsage = process.memoryUsage();
654+
logger.error('Renderer became unresponsive');
655+
writeCrashLog('RENDERER_UNRESPONSIVE', {
656+
note: 'Window stopped responding — will force-reload in 10s unless it recovers',
657+
mainProcessRssMB: Math.round(memUsage.rss / 1024 / 1024),
658+
mainProcessHeapUsedMB: Math.round(memUsage.heapUsed / 1024 / 1024),
659+
mainProcessHeapTotalMB: Math.round(memUsage.heapTotal / 1024 / 1024),
660+
uptime: `${Math.round(process.uptime())}s`,
661+
});
662+
663+
// Don't stack multiple timers
664+
if (unresponsiveTimer) return;
665+
666+
unresponsiveTimer = setTimeout(() => {
667+
unresponsiveTimer = null;
668+
if (isQuitting || !mainWindow || mainWindow.isDestroyed()) return;
669+
670+
// Reset counter if outside the window
671+
const now = Date.now();
672+
if (now - unresponsiveWindowStart > UNRESPONSIVE_WINDOW_MS) {
673+
unresponsiveReloadCount = 0;
674+
unresponsiveWindowStart = now;
675+
}
676+
unresponsiveReloadCount++;
677+
678+
if (unresponsiveReloadCount > MAX_UNRESPONSIVE_RELOADS) {
679+
logger.error(
680+
`Renderer unresponsive ${unresponsiveReloadCount} times in ${UNRESPONSIVE_WINDOW_MS / 1000}s — not reloading`
681+
);
682+
writeCrashLog('RENDERER_RELOAD_CAP_REACHED', {
683+
reason: `${unresponsiveReloadCount} unresponsive reloads in ${UNRESPONSIVE_WINDOW_MS / 1000}s`,
684+
uptime: `${Math.round(process.uptime())}s`,
685+
});
686+
return;
687+
}
688+
689+
logger.error('Renderer still unresponsive after 10s — force-reloading');
690+
writeCrashLog('RENDERER_FORCE_RELOAD', {
691+
reason: 'Unresponsive timeout expired',
692+
attempt: unresponsiveReloadCount,
693+
uptime: `${Math.round(process.uptime())}s`,
694+
});
695+
696+
if (process.env.NODE_ENV === 'development') {
697+
void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`);
698+
} else {
699+
void mainWindow.loadFile(getRendererIndexPath());
700+
}
701+
}, UNRESPONSIVE_RELOAD_MS);
702+
});
703+
704+
mainWindow.on('responsive', () => {
705+
if (unresponsiveTimer) {
706+
clearTimeout(unresponsiveTimer);
707+
unresponsiveTimer = null;
708+
logger.info('Renderer became responsive again — cancelled force-reload');
709+
}
553710
});
554711

555712
// Set main window reference for notification manager and updater
@@ -560,6 +717,43 @@ function createWindow(): void {
560717
updaterService.setMainWindow(mainWindow);
561718
}
562719

720+
// Periodic memory monitoring via app.getAppMetrics().
721+
// Logs all-process memory every 5 minutes so we have data leading up to crashes.
722+
// Warns when the renderer exceeds 2 GB.
723+
const MEMORY_CHECK_INTERVAL_MS = 5 * 60_000;
724+
const RENDERER_MEMORY_WARNING_KB = 2048 * 1024; // 2 GB in KB
725+
const memoryMonitorInterval = setInterval(() => {
726+
if (!mainWindow || mainWindow.isDestroyed()) return;
727+
try {
728+
const metrics = app.getAppMetrics();
729+
const mainMem = process.memoryUsage();
730+
const mainRssMB = Math.round(mainMem.rss / 1024 / 1024);
731+
const mainHeapMB = Math.round(mainMem.heapUsed / 1024 / 1024);
732+
733+
// Find the renderer process (type 'Tab' or matching the window's pid)
734+
const rendererPid = mainWindow.webContents.getOSProcessId();
735+
const rendererMetric = metrics.find((m) => m.pid === rendererPid);
736+
const rendererMemKB = rendererMetric?.memory?.workingSetSize ?? 0;
737+
const rendererMB = Math.round(rendererMemKB / 1024);
738+
739+
logger.info(
740+
`Memory: renderer=${rendererMB}MB, main RSS=${mainRssMB}MB heap=${mainHeapMB}MB, uptime=${Math.round(process.uptime())}s`
741+
);
742+
743+
if (rendererMemKB > RENDERER_MEMORY_WARNING_KB) {
744+
writeCrashLog('RENDERER_MEMORY_WARNING', {
745+
rendererMB,
746+
mainRssMB,
747+
mainHeapMB,
748+
uptime: `${Math.round(process.uptime())}s`,
749+
});
750+
}
751+
} catch {
752+
// Renderer might be crashed/reloading — skip this check
753+
}
754+
}, MEMORY_CHECK_INTERVAL_MS);
755+
memoryMonitorInterval.unref(); // Don't prevent app exit
756+
563757
logger.info('Main window created');
564758
}
565759

@@ -626,8 +820,9 @@ app.on('window-all-closed', () => {
626820
});
627821

628822
/**
629-
* Before quit handler - cleanup.
823+
* Before quit handler - set flag and cleanup services.
630824
*/
631825
app.on('before-quit', () => {
826+
isQuitting = true;
632827
shutdownServices();
633828
});

0 commit comments

Comments
 (0)