@@ -18,10 +18,31 @@ import {
1818} from '@shared/constants' ;
1919import { createLogger } from '@shared/utils/logger' ;
2020import { 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' ;
2323import { 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+
2546import { initializeIpcHandlers , removeIpcHandlers } from './ipc/handlers' ;
2647import { getProjectsBasePath , getTodosBasePath } from './utils/pathDecoder' ;
2748
@@ -60,10 +81,17 @@ const HTTP_SERVER_GET_STATUS = 'httpServer:getStatus';
6081
6182process . 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
6997import { HttpServer } from './services/infrastructure/HttpServer' ;
@@ -83,6 +111,7 @@ import {
83111// =============================================================================
84112
85113let mainWindow : BrowserWindow | null = null ;
114+ let isQuitting = false ;
86115
87116// Service registry and global services
88117let 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 */
631825app . on ( 'before-quit' , ( ) => {
826+ isQuitting = true ;
632827 shutdownServices ( ) ;
633828} ) ;
0 commit comments