Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions src/main/http/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import type {
UpdaterService,
} from '../services';
import type { SshConnectionManager } from '../services/infrastructure/SshConnectionManager';
import type { SubagentMessageCache } from '../services/infrastructure/SubagentMessageCache';
import type { FastifyInstance } from 'fastify';

const logger = createLogger('HTTP:routes');
Expand All @@ -38,6 +39,7 @@ export interface HttpServices {
subagentResolver: SubagentResolver;
chunkBuilder: ChunkBuilder;
dataCache: DataCache;
subagentMessageCache: SubagentMessageCache;
updaterService: UpdaterService;
sshConnectionManager: SshConnectionManager;
}
Expand Down
57 changes: 56 additions & 1 deletion src/main/http/subagents.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,19 @@
* HTTP route handlers for Subagent Operations.
*
* Routes:
* - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId - Subagent detail
* - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId
* → Subagent detail (drill-down modal payload).
* - GET /api/projects/:projectId/sessions/:sessionId/subagents/:subagentId/messages
* → Lazy-load full message body for inline expansion. Mirrors the IPC
* handler so browser mode works the same as Electron mode.
*/

import { createLogger } from '@shared/utils/logger';
import * as path from 'path';

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

import type { HttpServices } from './index';
import type { FastifyInstance } from 'fastify';
Expand Down Expand Up @@ -74,4 +81,52 @@ export function registerSubagentRoutes(app: FastifyInstance, services: HttpServi
}
}
);

// Lazy-load subagent message bodies (mirrors the IPC handler).
app.get<{ Params: { projectId: string; sessionId: string; subagentId: string } }>(
'/api/projects/:projectId/sessions/:sessionId/subagents/:subagentId/messages',
async (request) => {
try {
const validatedProject = validateProjectId(request.params.projectId);
const validatedSession = validateSessionId(request.params.sessionId);
const validatedSubagent = validateSubagentId(request.params.subagentId);
if (!validatedProject.valid || !validatedSession.valid || !validatedSubagent.valid) {
logger.error(
`GET subagent-messages rejected: ${
validatedProject.error ??
validatedSession.error ??
validatedSubagent.error ??
'Invalid parameters'
}`
);
return [];
}
const safeProjectId = validatedProject.value!;
const safeSessionId = validatedSession.value!;
const safeSubagentId = validatedSubagent.value!;

const cacheKey = SubagentMessageCache.buildKey(
safeProjectId,
safeSessionId,
safeSubagentId
);
const cached = services.subagentMessageCache.get(cacheKey);
if (cached) {
return cached;
}

// Layout: {projectsDir}/{baseProjectId}/{sessionId}/subagents/agent-X.jsonl
const projectsDir = services.projectScanner.getProjectsDir();
const subagentsDir = buildSubagentsPath(projectsDir, safeProjectId, safeSessionId);
const subagentPath = path.join(subagentsDir, `agent-${safeSubagentId}.jsonl`);

const parsed = await services.sessionParser.parseSessionFile(subagentPath);
services.subagentMessageCache.set(cacheKey, parsed.messages);
return parsed.messages;
} catch (error) {
logger.error(`Error in GET subagent-messages for ${request.params.subagentId}:`, error);
return [];
}
}
);
}
207 changes: 201 additions & 6 deletions src/main/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,31 @@ import {
} from '@shared/constants';
import { createLogger } from '@shared/utils/logger';
import { app, BrowserWindow, ipcMain } from 'electron';
import { existsSync } from 'fs';
import { totalmem } from 'os';
import { appendFileSync, existsSync, mkdirSync } from 'fs';
import { homedir, totalmem } 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 @@ -60,10 +81,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 @@ -83,6 +111,7 @@ import {
// =============================================================================

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

// Service registry and global services
let contextRegistry: ServiceContextRegistry;
Expand Down Expand Up @@ -366,6 +395,7 @@ async function startHttpServer(
subagentResolver: activeContext.subagentResolver,
chunkBuilder: activeContext.chunkBuilder,
dataCache: activeContext.dataCache,
subagentMessageCache: activeContext.subagentMessageCache,
updaterService,
sshConnectionManager,
},
Expand Down Expand Up @@ -546,10 +576,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 @@ -560,6 +717,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 @@ -626,8 +820,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();
});
Loading
Loading