Skip to content
222 changes: 213 additions & 9 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,31 @@ import {
} from '@shared/constants';
import { createLogger } from '@shared/utils/logger';
import { app, BrowserWindow, ipcMain } from 'electron';
import { existsSync } from 'fs';
import { appendFileSync, existsSync, mkdirSync } from 'fs';
import { homedir } from 'os';
import { join } from 'path';

/**
* Append a timestamped entry to ~/.claude/claude-devtools-crash.log.
* Uses sync I/O because crashes may happen in unstable states.
*/
function writeCrashLog(label: string, details: Record<string, unknown>): void {
try {
const dir = join(homedir(), '.claude');
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
const logPath = join(dir, 'claude-devtools-crash.log');
const entry =
`[${new Date().toISOString()}] ${label}\n` +
Object.entries(details)
.map(([k, v]) => ` ${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Using JSON.stringify(v) for error details will result in an empty object {} if v is an Error instance, as error properties like message and stack are non-enumerable. It's better to explicitly handle Error objects to ensure useful information is logged.

Suggested change
.map(([k, v]) => ` ${k}: ${typeof v === 'string' ? v : JSON.stringify(v)}`)
.map(([k, v]) => ` ${k}: ${v instanceof Error ? v.stack ?? v.message : (typeof v === 'string' ? v : JSON.stringify(v))}`)

.join('\n') +
'\n\n';
appendFileSync(logPath, entry, 'utf-8');
} catch {
// Best-effort — don't throw during crash handling
}
}

import { initializeIpcHandlers, removeIpcHandlers } from './ipc/handlers';
import { getProjectsBasePath, getTodosBasePath } from './utils/pathDecoder';

Expand Down Expand Up @@ -52,10 +74,17 @@ const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';

process.on('unhandledRejection', (reason) => {
logger.error('Unhandled promise rejection in main process:', reason);
writeCrashLog('UNHANDLED_REJECTION (main)', {
reason: reason instanceof Error ? reason.stack ?? reason.message : String(reason),
});
});

process.on('uncaughtException', (error) => {
process.on('uncaughtException', (error: Error) => {
logger.error('Uncaught exception in main process:', error);
writeCrashLog('UNCAUGHT_EXCEPTION (main)', {
message: error.message,
stack: error.stack ?? '',
});
});

import { HttpServer } from './services/infrastructure/HttpServer';
Expand All @@ -74,6 +103,7 @@ import {
// =============================================================================

let mainWindow: BrowserWindow | null = null;
let isQuitting = false;

// Service registry and global services
let contextRegistry: ServiceContextRegistry;
Expand Down Expand Up @@ -481,12 +511,21 @@ function createWindow(): void {
const ZOOM_OUT_KEYS = new Set(['-', '_']);
mainWindow.webContents.on('before-input-event', (event, input) => {
if (!mainWindow || mainWindow.isDestroyed()) return;

if (input.type !== 'keyDown') return;

// Prevent Electron's default Ctrl+R / Cmd+R page reload so the renderer
// keyboard handler can use it as "Refresh Session" (fixes #58).
// Also prevent Ctrl+Shift+R / Cmd+Shift+R (hard reload).
if ((input.control || input.meta) && input.key.toLowerCase() === 'r') {
// Intercept Ctrl+R / Cmd+R to prevent Chromium's built-in page reload,
// then notify the renderer via IPC so it can refresh the session (fixes #58, #85).
// We must preventDefault here because Chromium handles Ctrl+R at the browser
// engine level, which also blocks the keydown from reaching the renderer —
// hence the IPC bridge.
if ((input.control || input.meta) && !input.shift && input.key.toLowerCase() === 'r') {
event.preventDefault();
mainWindow.webContents.send('session:refresh');
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The IPC channel name 'session:refresh' is hardcoded here as a string literal. It should use the SESSION_REFRESH constant defined in src/preload/constants/ipcChannels.ts to maintain consistency and avoid potential typos. You should add it to the duplicated constants list at the top of the file to follow the existing pattern.

Suggested change
mainWindow.webContents.send('session:refresh');
mainWindow.webContents.send(SESSION_REFRESH);

return;
}
// Also block Ctrl+Shift+R (hard reload)
if ((input.control || input.meta) && input.shift && input.key.toLowerCase() === 'r') {
event.preventDefault();
return;
}
Expand Down Expand Up @@ -527,10 +566,137 @@ function createWindow(): void {
}
});

// Handle renderer process crashes (render-process-gone replaces deprecated 'crashed' event)
// Handle renderer process crashes with retry cap to prevent crash loops.
// Only auto-reload for recoverable reasons (crashed, oom, memory-eviction).
// After 3 failures within 60s, stop reloading to avoid infinite loops.
let crashCount = 0;
let crashWindowStart = Date.now();
const MAX_CRASHES = 3;
const CRASH_WINDOW_MS = 60_000;
const RECOVERABLE_REASONS = new Set(['crashed', 'oom', 'memory-eviction']);

mainWindow.webContents.on('render-process-gone', (_event, details) => {
const memUsage = process.memoryUsage();
logger.error('Renderer process gone:', details.reason, details.exitCode);
// Could show an error dialog or attempt to reload the window
writeCrashLog('RENDERER_PROCESS_GONE', {
reason: details.reason,
exitCode: details.exitCode,
mainProcessRssMB: Math.round(memUsage.rss / 1024 / 1024),
mainProcessHeapUsedMB: Math.round(memUsage.heapUsed / 1024 / 1024),
mainProcessHeapTotalMB: Math.round(memUsage.heapTotal / 1024 / 1024),
uptime: `${Math.round(process.uptime())}s`,
});

if (isQuitting || !mainWindow || mainWindow.isDestroyed()) return;
if (!RECOVERABLE_REASONS.has(details.reason)) return;

// Reset crash counter if outside window
const now = Date.now();
if (now - crashWindowStart > CRASH_WINDOW_MS) {
crashCount = 0;
crashWindowStart = now;
}
crashCount++;

if (crashCount > MAX_CRASHES) {
logger.error(
`Renderer crashed ${crashCount} times in ${CRASH_WINDOW_MS / 1000}s — not reloading`
);
return;
}

if (process.env.NODE_ENV === 'development') {
void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`);
} else {
void mainWindow.loadFile(getRendererIndexPath());
}
});

// Log renderer console errors (captures uncaught errors from the renderer process).
// ResizeObserver loop errors are benign Chromium noise — skip them to keep the log clean.
mainWindow.webContents.on('console-message', (_event, level, message, line, sourceId) => {
// level 3 = error
if (level >= 3) {
if (message.includes('ResizeObserver loop')) return;
writeCrashLog('RENDERER_CONSOLE_ERROR', {
message,
source: `${sourceId}:${line}`,
});
}
});

// Proactive unresponsive recovery.
// When the renderer freezes, the Linux desktop environment (GNOME/KDE) may show its
// own "Force Quit" dialog and kill the entire process tree. We race that by
// force-reloading the renderer after UNRESPONSIVE_RELOAD_MS. If the renderer
// becomes responsive again before the timer fires, we cancel the reload.
// Capped at MAX_UNRESPONSIVE_RELOADS within UNRESPONSIVE_WINDOW_MS to prevent
// infinite reload loops when a large session freezes the renderer on every load.
const UNRESPONSIVE_RELOAD_MS = 10_000;
const MAX_UNRESPONSIVE_RELOADS = 3;
const UNRESPONSIVE_WINDOW_MS = 120_000; // 2 minutes
let unresponsiveTimer: ReturnType<typeof setTimeout> | null = null;
let unresponsiveReloadCount = 0;
let unresponsiveWindowStart = Date.now();

mainWindow.on('unresponsive', () => {
const memUsage = process.memoryUsage();
logger.error('Renderer became unresponsive');
writeCrashLog('RENDERER_UNRESPONSIVE', {
note: 'Window stopped responding — will force-reload in 10s unless it recovers',
mainProcessRssMB: Math.round(memUsage.rss / 1024 / 1024),
mainProcessHeapUsedMB: Math.round(memUsage.heapUsed / 1024 / 1024),
mainProcessHeapTotalMB: Math.round(memUsage.heapTotal / 1024 / 1024),
uptime: `${Math.round(process.uptime())}s`,
});

// Don't stack multiple timers
if (unresponsiveTimer) return;

unresponsiveTimer = setTimeout(() => {
unresponsiveTimer = null;
if (isQuitting || !mainWindow || mainWindow.isDestroyed()) return;

// Reset counter if outside the window
const now = Date.now();
if (now - unresponsiveWindowStart > UNRESPONSIVE_WINDOW_MS) {
unresponsiveReloadCount = 0;
unresponsiveWindowStart = now;
}
unresponsiveReloadCount++;

if (unresponsiveReloadCount > MAX_UNRESPONSIVE_RELOADS) {
logger.error(
`Renderer unresponsive ${unresponsiveReloadCount} times in ${UNRESPONSIVE_WINDOW_MS / 1000}s — not reloading`
);
writeCrashLog('RENDERER_RELOAD_CAP_REACHED', {
reason: `${unresponsiveReloadCount} unresponsive reloads in ${UNRESPONSIVE_WINDOW_MS / 1000}s`,
uptime: `${Math.round(process.uptime())}s`,
});
return;
}

logger.error('Renderer still unresponsive after 10s — force-reloading');
writeCrashLog('RENDERER_FORCE_RELOAD', {
reason: 'Unresponsive timeout expired',
attempt: unresponsiveReloadCount,
uptime: `${Math.round(process.uptime())}s`,
});

if (process.env.NODE_ENV === 'development') {
void mainWindow.loadURL(`http://localhost:${DEV_SERVER_PORT}`);
} else {
void mainWindow.loadFile(getRendererIndexPath());
}
}, UNRESPONSIVE_RELOAD_MS);
});

mainWindow.on('responsive', () => {
if (unresponsiveTimer) {
clearTimeout(unresponsiveTimer);
unresponsiveTimer = null;
logger.info('Renderer became responsive again — cancelled force-reload');
}
});

// Set main window reference for notification manager and updater
Expand All @@ -541,6 +707,43 @@ function createWindow(): void {
updaterService.setMainWindow(mainWindow);
}

// Periodic memory monitoring via app.getAppMetrics().
// Logs all-process memory every 5 minutes so we have data leading up to crashes.
// Warns when the renderer exceeds 2 GB.
const MEMORY_CHECK_INTERVAL_MS = 5 * 60_000;
const RENDERER_MEMORY_WARNING_KB = 2048 * 1024; // 2 GB in KB
const memoryMonitorInterval = setInterval(() => {
if (!mainWindow || mainWindow.isDestroyed()) return;
try {
const metrics = app.getAppMetrics();
const mainMem = process.memoryUsage();
const mainRssMB = Math.round(mainMem.rss / 1024 / 1024);
const mainHeapMB = Math.round(mainMem.heapUsed / 1024 / 1024);

// Find the renderer process (type 'Tab' or matching the window's pid)
const rendererPid = mainWindow.webContents.getOSProcessId();
const rendererMetric = metrics.find((m) => m.pid === rendererPid);
const rendererMemKB = rendererMetric?.memory?.workingSetSize ?? 0;
const rendererMB = Math.round(rendererMemKB / 1024);

logger.info(
`Memory: renderer=${rendererMB}MB, main RSS=${mainRssMB}MB heap=${mainHeapMB}MB, uptime=${Math.round(process.uptime())}s`
);

if (rendererMemKB > RENDERER_MEMORY_WARNING_KB) {
writeCrashLog('RENDERER_MEMORY_WARNING', {
rendererMB,
mainRssMB,
mainHeapMB,
uptime: `${Math.round(process.uptime())}s`,
});
}
} catch {
// Renderer might be crashed/reloading — skip this check
}
}, MEMORY_CHECK_INTERVAL_MS);
memoryMonitorInterval.unref(); // Don't prevent app exit

logger.info('Main window created');
}

Expand Down Expand Up @@ -604,8 +807,9 @@ app.on('window-all-closed', () => {
});

/**
* Before quit handler - cleanup.
* Before quit handler - set flag and cleanup services.
*/
app.on('before-quit', () => {
isQuitting = true;
shutdownServices();
});
36 changes: 36 additions & 0 deletions src/main/services/discovery/ProjectScanner.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,9 @@ import type { FileSystemProvider, FsDirent } from '../infrastructure/FileSystemP
const logger = createLogger('Discovery:ProjectScanner');

export class ProjectScanner {
/** Maximum number of entries per cache before LRU eviction kicks in. */
private static readonly MAX_CACHE_ENTRIES = 500;

private readonly projectsDir: string;
private readonly todosDir: string;
private readonly contentPresenceCache = new Map<
Expand Down Expand Up @@ -99,6 +102,36 @@ export class ProjectScanner {
this.projectPathResolver = new ProjectPathResolver(this.projectsDir, this.fsProvider);
}

// ===========================================================================
// Cache Management
// ===========================================================================

/**
* Evicts the oldest entries from a Map when it exceeds MAX_CACHE_ENTRIES.
* Relies on Map insertion order (oldest entries are iterated first).
*/
private pruneCache<V>(cache: Map<string, V>): void {
if (cache.size <= ProjectScanner.MAX_CACHE_ENTRIES) return;
const excess = cache.size - ProjectScanner.MAX_CACHE_ENTRIES;
let removed = 0;
for (const key of cache.keys()) {
if (removed >= excess) break;
cache.delete(key);
removed++;
}
}
Comment on lines +113 to +122
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The pruneCache implementation follows a FIFO (First-In-First-Out) eviction strategy rather than LRU (Least Recently Used), as Map.keys() iterates in insertion order and the order is never updated when an entry is accessed via get(). If LRU behavior is intended, you should move the accessed key to the end of the map on every get operation. Otherwise, the documentation should be updated to reflect FIFO behavior.


/**
* Invalidates all scanner caches for a single session file path.
* Called by FileWatcher when a session file changes, so only the changed
* session is evicted instead of the entire project.
*/
invalidateCachesForSession(sessionFilePath: string): void {
this.contentPresenceCache.delete(sessionFilePath);
this.sessionMetadataCache.delete(sessionFilePath);
this.sessionPreviewCache.delete(sessionFilePath);
}

// ===========================================================================
// Project Scanning
// ===========================================================================
Expand Down Expand Up @@ -734,6 +767,7 @@ export class ProjectScanner {
size: effectiveSize,
metadata,
});
this.pruneCache(this.sessionMetadataCache);
}

// Check for subagents and load task list data in parallel
Expand Down Expand Up @@ -799,6 +833,7 @@ export class ProjectScanner {
size: effectiveSize,
preview,
});
this.pruneCache(this.sessionPreviewCache);
}
const metadataLevel: SessionMetadataLevel = 'light';
const previewTimestampMs = this.parseTimestampMs(preview?.timestamp);
Expand Down Expand Up @@ -1304,6 +1339,7 @@ export class ProjectScanner {
size: effectiveSize,
hasContent,
});
this.pruneCache(this.contentPresenceCache);
return hasContent;
} catch {
return false;
Expand Down
14 changes: 13 additions & 1 deletion src/main/services/infrastructure/FileWatcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import * as fs from 'fs';
import * as path from 'path';

import { projectPathResolver } from '../discovery/ProjectPathResolver';
import { type ProjectScanner } from '../discovery/ProjectScanner';
import { errorDetector } from '../error/ErrorDetector';

import { ConfigManager } from './ConfigManager';
Expand Down Expand Up @@ -60,6 +61,7 @@ export class FileWatcher extends EventEmitter {
private dataCache: DataCache;
private fsProvider: FileSystemProvider;
private notificationManager: NotificationManager | null = null;
private projectScanner: ProjectScanner | null = null;
private isWatching: boolean = false;
private debounceTimers = new Map<string, NodeJS.Timeout>();
/** Track last processed line count per file for incremental error detection */
Expand Down Expand Up @@ -108,6 +110,15 @@ export class FileWatcher extends EventEmitter {
this.notificationManager = manager;
}

/**
* Sets the ProjectScanner for session-scoped cache invalidation.
* When set, file change events invalidate only the changed session file's
* cached metadata/presence/preview instead of nothing at all.
*/
setProjectScanner(scanner: ProjectScanner): void {
this.projectScanner = scanner;
}

/**
* Sets the filesystem provider. Used when switching between local and SSH modes.
*/
Expand Down Expand Up @@ -533,8 +544,9 @@ export class FileWatcher extends EventEmitter {
}

if (sessionId) {
// Invalidate cache
// Invalidate caches — session-scoped for ProjectScanner, keyed for DataCache
this.dataCache.invalidateSession(projectId, sessionId);
this.projectScanner?.invalidateCachesForSession(fullPath);
projectPathResolver.invalidateProject(projectId);
if (changeType === 'unlink') {
this.clearErrorTracking(fullPath);
Expand Down
Loading