diff --git a/apps/frontend/src/main/app-logger.ts b/apps/frontend/src/main/app-logger.ts index 07429c1953..dc984d417d 100644 --- a/apps/frontend/src/main/app-logger.ts +++ b/apps/frontend/src/main/app-logger.ts @@ -38,6 +38,18 @@ log.transports.file.fileName = 'main.log'; // Console transport - always show warnings and errors, debug only in dev mode log.transports.console.level = process.env.NODE_ENV === 'development' ? 'debug' : 'warn'; log.transports.console.format = '[{h}:{i}:{s}] [{level}] {text}'; +// Guard console transport writes so broken stdio streams do not crash the app. +{ + const originalConsoleWriteFn = log.transports.console.writeFn as (...args: unknown[]) => void; + log.transports.console.writeFn = (...args: unknown[]) => { + try { + originalConsoleWriteFn(...args); + } catch (error) { + const err = error instanceof Error ? `${error.name}: ${error.message}` : String(error); + safeStderrWrite(`[app-logger] console transport write failed: ${err}`); + } + }; +} // Determine if this is a beta version function isBetaVersion(): boolean { @@ -204,14 +216,44 @@ export const appLog = { log: (...args: unknown[]) => log.info(...args), }; +/** + * Best-effort stderr fallback used when electron-log itself throws (e.g. EIO). + * Must never throw, especially inside uncaught exception handlers. + */ +function safeStderrWrite(message: string): void { + try { + process.stderr.write(`${message}\n`); + } catch { + // Ignore - nothing else we can safely do here. + } +} + +/** + * Log an unhandled error without risking recursive crashes if logger transport fails. + */ +function safeLogUnhandled(prefix: string, value: unknown): void { + try { + log.error(prefix, value); + } catch (loggingError) { + const loggingFailure = loggingError instanceof Error + ? `${loggingError.name}: ${loggingError.message}` + : String(loggingError); + const original = value instanceof Error + ? (value.stack || `${value.name}: ${value.message}`) + : String(value); + safeStderrWrite(`[app-logger] ${prefix} (logger failed: ${loggingFailure})`); + safeStderrWrite(original); + } +} + // Log unhandled errors export function setupErrorLogging(): void { process.on('uncaughtException', (error) => { - log.error('Uncaught exception:', error); + safeLogUnhandled('Uncaught exception:', error); }); process.on('unhandledRejection', (reason) => { - log.error('Unhandled rejection:', reason); + safeLogUnhandled('Unhandled rejection:', reason); }); log.info('Error logging initialized'); diff --git a/apps/frontend/src/main/index.ts b/apps/frontend/src/main/index.ts index f98725d36c..c8644ed8a9 100644 --- a/apps/frontend/src/main/index.ts +++ b/apps/frontend/src/main/index.ts @@ -49,7 +49,7 @@ import { initializeAppUpdater, stopPeriodicUpdates } from './app-updater'; import { DEFAULT_APP_SETTINGS, IPC_CHANNELS, SPELL_CHECK_LANGUAGE_MAP, DEFAULT_SPELL_CHECK_LANGUAGE, ADD_TO_DICTIONARY_LABELS } from '../shared/constants'; import { getAppLanguage, initAppLanguage } from './app-language'; import { readSettingsFile } from './settings-utils'; -import { setupErrorLogging } from './app-logger'; +import { appLog, setupErrorLogging } from './app-logger'; import { initSentryMain } from './sentry'; import { preWarmToolCache } from './cli-tool-manager'; import { initializeClaudeProfileManager, getClaudeProfileManager } from './claude-profile-manager'; @@ -143,6 +143,11 @@ let mainWindow: BrowserWindow | null = null; let agentManager: AgentManager | null = null; let terminalManager: TerminalManager | null = null; +// Capture child process exits (renderer/GPU/utility) for crash diagnostics. +app.on('child-process-gone', (_event, details) => { + appLog.error('[main] child-process-gone:', details); +}); + // Re-entrancy guard for before-quit handler. // The first before-quit call pauses quit for async cleanup, then calls app.quit() again. // The second call sees isQuitting=true and allows quit to proceed immediately. @@ -214,6 +219,11 @@ function createWindow(): void { mainWindow?.show(); }); + // Capture renderer process crashes/termination reasons for diagnostics. + mainWindow.webContents.on('render-process-gone', (_event, details) => { + appLog.error('[main] render-process-gone:', details); + }); + // Configure initial spell check languages with proper fallback logic // Uses shared constant for consistency with the IPC handler const defaultLanguage = 'en'; diff --git a/apps/frontend/src/main/terminal-session-store.ts b/apps/frontend/src/main/terminal-session-store.ts index 3dcc598b07..317abf4b07 100644 --- a/apps/frontend/src/main/terminal-session-store.ts +++ b/apps/frontend/src/main/terminal-session-store.ts @@ -195,8 +195,20 @@ export class TerminalSessionStore { * 1. Write to temp file * 2. Rotate current file to backup * 3. Rename temp to target (atomic on most filesystems) + * + * If an async write is in progress, defers to the async writer to avoid + * both operations competing for the same temp file (ENOENT race condition). */ private save(): void { + // If an async write is in progress, don't write synchronously — the async + // writer shares the same temp file path. Instead, mark a pending write so + // saveAsync() will re-save with the latest in-memory data when it finishes. + if (this.writeInProgress) { + this.writePending = true; + debugLog('[TerminalSessionStore] Deferring sync save — async write in progress'); + return; + } + try { const content = JSON.stringify(this.data, null, 2); @@ -366,7 +378,9 @@ export class TerminalSessionStore { private updateSessionInMemory(session: TerminalSession): boolean { // Check if session was deleted - skip if pending deletion if (this.pendingDelete.has(session.id)) { - console.warn('[TerminalSessionStore] Skipping save for deleted session:', session.id); + debugLog('[TerminalSessionStore] Skipping save for deleted session:', session.id, + 'pendingDelete size:', this.pendingDelete.size, + 'all pending IDs:', [...this.pendingDelete].join(', ')); return false; } @@ -596,6 +610,27 @@ export class TerminalSessionStore { return sessions.find(s => s.id === sessionId); } + /** + * Clear a session ID from pendingDelete, allowing saves to proceed. + * + * Called when a terminal is legitimately re-created with the same ID + * (e.g., worktree switching, terminal restart after exit). Without this, + * the 5-second pendingDelete window blocks session persistence for the + * new terminal. + */ + clearPendingDelete(sessionId: string): void { + if (this.pendingDelete.has(sessionId)) { + this.pendingDelete.delete(sessionId); + // Also clear the cleanup timer since we're explicitly clearing + const timer = this.pendingDeleteTimers.get(sessionId); + if (timer) { + clearTimeout(timer); + this.pendingDeleteTimers.delete(sessionId); + } + debugLog('[TerminalSessionStore] Cleared pendingDelete for re-created terminal:', sessionId); + } + } + /** * Remove a session (from today's sessions) * @@ -606,6 +641,9 @@ export class TerminalSessionStore { // Mark as pending delete BEFORE modifying data to prevent race condition // with in-flight saveSessionAsync() calls this.pendingDelete.add(sessionId); + debugLog('[TerminalSessionStore] removeSession: added to pendingDelete:', sessionId, + 'pendingDelete size:', this.pendingDelete.size, + 'all pending IDs:', [...this.pendingDelete].join(', ')); const todaySessions = this.getTodaysSessions(); if (todaySessions[projectPath]) { @@ -627,6 +665,8 @@ export class TerminalSessionStore { const timer = setTimeout(() => { this.pendingDelete.delete(sessionId); this.pendingDeleteTimers.delete(sessionId); + debugLog('[TerminalSessionStore] Cleanup timer fired for:', sessionId, + 'removing from pendingDelete. Remaining:', this.pendingDelete.size); }, 5000); this.pendingDeleteTimers.set(sessionId, timer); } diff --git a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts index 5126fd6045..7ed5d600e5 100644 --- a/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts +++ b/apps/frontend/src/main/terminal/__tests__/claude-integration-handler.test.ts @@ -109,7 +109,16 @@ function mockPlatform(platform: 'win32' | 'darwin' | 'linux') { /** * Helper to get platform-specific expectations for PATH prefix */ -function getPathPrefixExpectation(platform: 'win32' | 'darwin' | 'linux', pathValue: string): string { +function getPathPrefixExpectation( + platform: 'win32' | 'darwin' | 'linux', + pathValue: string, + command: string +): string { + // Absolute executable commands no longer need PATH prefix injection. + if (path.isAbsolute(command)) { + return ''; + } + if (platform === 'win32') { // Windows: set "PATH=value" && return `set "PATH=${pathValue}" && `; @@ -118,6 +127,20 @@ function getPathPrefixExpectation(platform: 'win32' | 'darwin' | 'linux', pathVa return `PATH='${pathValue}' `; } +function expectPathPrefix( + written: string, + platform: 'win32' | 'darwin' | 'linux', + pathValue: string, + command: string +): void { + const expectedPrefix = getPathPrefixExpectation(platform, pathValue, command); + if (expectedPrefix) { + expect(written).toContain(expectedPrefix); + } else { + expect(written).not.toContain('PATH='); + } +} + /** * Helper to get platform-specific expectations for command quoting */ @@ -241,7 +264,7 @@ describe('claude-integration-handler', () => { const written = mockWriteToPty.mock.calls[0][1] as string; expect(written).toContain(buildCdCommand('/tmp/project')); - expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); + expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', "/opt/claude bin/claude's"); expect(written).toContain(getQuotedCommand(platform, "/opt/claude bin/claude's")); expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1'); expect(mockPersistSession).toHaveBeenCalledWith(terminal); @@ -402,7 +425,7 @@ describe('claude-integration-handler', () => { expect(written).toContain(histPrefix); expect(written).toContain(configDir); - expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); + expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', command); expect(written).toContain(getQuotedCommand(platform, command)); expect(written).toContain(clearCmd); expect(profileManager.getProfile).toHaveBeenCalledWith('prof-2'); @@ -436,7 +459,7 @@ describe('claude-integration-handler', () => { const written = mockWriteToPty.mock.calls[0][1] as string; expect(written).toContain(getQuotedCommand(platform, command)); - expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); + expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', command); expect(profileManager.getProfile).toHaveBeenCalledWith('prof-3'); expect(profileManager.markProfileUsed).toHaveBeenCalledWith('prof-3'); expect(mockPersistSession).toHaveBeenCalledWith(terminal); @@ -460,7 +483,7 @@ describe('claude-integration-handler', () => { resumeClaude(terminal, 'abc123', () => null); const resumeCall = mockWriteToPty.mock.calls[0][1] as string; - expect(resumeCall).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); + expectPathPrefix(resumeCall, platform, '/opt/claude/bin:/usr/bin', '/opt/claude/bin/claude'); expect(resumeCall).toContain(getQuotedCommand(platform, '/opt/claude/bin/claude') + ' --continue'); expect(resumeCall).not.toContain('--resume'); // sessionId is cleared because --continue doesn't track specific sessions @@ -656,7 +679,7 @@ describe('invokeClaudeAsync', () => { const written = mockWriteToPty.mock.calls[0][1] as string; expect(written).toContain(buildCdCommand('/tmp/project')); - expect(written).toContain(getPathPrefixExpectation(platform, '/opt/claude/bin:/usr/bin')); + expectPathPrefix(written, platform, '/opt/claude/bin:/usr/bin', '/opt/claude/bin/claude'); expect(mockReleaseSessionId).toHaveBeenCalledWith('term-1'); expect(mockPersistSession).toHaveBeenCalledWith(terminal); expect(profileManager.markProfileUsed).toHaveBeenCalledWith('default'); @@ -914,7 +937,8 @@ describe('claude-integration-handler - Helper Functions', () => { // Use a default terminal name pattern so renaming logic kicks in const terminal = createMockTerminal({ title: 'Terminal 1' }); const mockWindow = { - webContents: { send: vi.fn() } + isDestroyed: () => false, + webContents: { send: vi.fn(), isDestroyed: () => false } }; finalizeClaudeInvoke( @@ -934,7 +958,8 @@ describe('claude-integration-handler - Helper Functions', () => { // Use a default terminal name pattern so renaming logic kicks in const terminal = createMockTerminal({ title: 'Terminal 2' }); const mockWindow = { - webContents: { send: vi.fn() } + isDestroyed: () => false, + webContents: { send: vi.fn(), isDestroyed: () => false } }; finalizeClaudeInvoke( @@ -955,7 +980,8 @@ describe('claude-integration-handler - Helper Functions', () => { const terminal = createMockTerminal({ title: 'Terminal 3' }); const mockSend = vi.fn(); const mockWindow = { - webContents: { send: mockSend } + isDestroyed: () => false, + webContents: { send: mockSend, isDestroyed: () => false } }; finalizeClaudeInvoke( @@ -980,7 +1006,8 @@ describe('claude-integration-handler - Helper Functions', () => { const terminal = createMockTerminal({ title: 'Claude' }); const mockSend = vi.fn(); const mockWindow = { - webContents: { send: mockSend } + isDestroyed: () => false, + webContents: { send: mockSend, isDestroyed: () => false } }; finalizeClaudeInvoke( @@ -1004,7 +1031,8 @@ describe('claude-integration-handler - Helper Functions', () => { const terminal = createMockTerminal({ title: 'My Custom Terminal' }); const mockSend = vi.fn(); const mockWindow = { - webContents: { send: mockSend } + isDestroyed: () => false, + webContents: { send: mockSend, isDestroyed: () => false } }; finalizeClaudeInvoke( diff --git a/apps/frontend/src/main/terminal/claude-integration-handler.ts b/apps/frontend/src/main/terminal/claude-integration-handler.ts index ef4c92b903..3f46397bcb 100644 --- a/apps/frontend/src/main/terminal/claude-integration-handler.ts +++ b/apps/frontend/src/main/terminal/claude-integration-handler.ts @@ -16,6 +16,7 @@ import { getEmailFromConfigDir } from '../claude-profile/profile-utils'; import * as OutputParser from './output-parser'; import * as SessionHandler from './session-handler'; import * as PtyManager from './pty-manager'; +import { safeSendToRenderer } from '../ipc-handlers/utils'; import { debugLog, debugError } from '../../shared/utils/debug-logger'; import { escapeShellArg, escapeForWindowsDoubleQuote, buildCdCommand } from '../../shared/utils/shell-escape'; import { getClaudeCliInvocation, getClaudeCliInvocationAsync } from '../claude-cli-utils'; @@ -116,6 +117,19 @@ function normalizePathForBash(envPath: string): string { return isWindows() ? envPath.replace(/;/g, ':') : envPath; } +/** + * Determine whether a command already resolves via an absolute executable path. + * + * When true, we should avoid prefixing PATH=... into the typed shell command because: + * 1) PATH is not needed to locate the executable + * 2) very long PATH prefixes create huge echoed command lines that can stress terminal rendering + */ +function isAbsoluteExecutableCommand(command: string): boolean { + const trimmed = command.trim(); + if (!trimmed) return false; + return path.isAbsolute(trimmed); +} + /** * Generate temp file content for OAuth token based on platform * @@ -380,11 +394,8 @@ export function finalizeClaudeInvoke( : 'Claude'; terminal.title = title; - // Notify renderer of title change - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); - } + // Notify renderer of title change (use safeSendToRenderer to prevent SIGABRT on disposed frame) + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, title); } // Persist session if project path is available @@ -434,18 +445,15 @@ export function handleRateLimit( const autoSwitchSettings = profileManager.getAutoSwitchSettings(); const bestProfile = profileManager.getBestAvailableProfile(currentProfileId); - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_RATE_LIMIT, { - terminalId: terminal.id, - resetTime, - detectedAt: new Date().toISOString(), - profileId: currentProfileId, - suggestedProfileId: bestProfile?.id, - suggestedProfileName: bestProfile?.name, - autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit - } as RateLimitEvent); - } + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_RATE_LIMIT, { + terminalId: terminal.id, + resetTime, + detectedAt: new Date().toISOString(), + profileId: currentProfileId, + suggestedProfileId: bestProfile?.id, + suggestedProfileName: bestProfile?.name, + autoSwitchEnabled: autoSwitchSettings.autoSwitchOnRateLimit + } as RateLimitEvent); if (autoSwitchSettings.enabled && autoSwitchSettings.autoSwitchOnRateLimit && bestProfile) { console.warn('[ClaudeIntegration] Auto-switching to profile:', bestProfile.name); @@ -535,19 +543,16 @@ export function handleOAuthToken( // Set flag to watch for Claude's ready state (onboarding complete) terminal.awaitingOnboardingComplete = true; - const win = getWindow(); - if (win) { - // needsOnboarding: true tells the UI to show "complete setup" message - // instead of "success" - user should finish Claude's onboarding before closing - win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { - terminalId: terminal.id, - profileId, - email: emailFromOutput || keychainCreds.email || profile?.email, - success: true, - needsOnboarding: true, - detectedAt: new Date().toISOString() - } as OAuthTokenEvent); - } + // needsOnboarding: true tells the UI to show "complete setup" message + // instead of "success" - user should finish Claude's onboarding before closing + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { + terminalId: terminal.id, + profileId, + email: emailFromOutput || keychainCreds.email || profile?.email, + success: true, + needsOnboarding: true, + detectedAt: new Date().toISOString() + } as OAuthTokenEvent); } else { // Token not in Keychain yet, but profile may still be authenticated via configDir // Check if profile has valid auth (credentials exist in configDir) @@ -559,19 +564,16 @@ export function handleOAuthToken( // Set flag to watch for Claude's ready state (onboarding complete) terminal.awaitingOnboardingComplete = true; - const win = getWindow(); - if (win) { - // needsOnboarding: true tells the UI to show "complete setup" message - // instead of "success" - user should finish Claude's onboarding before closing - win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { - terminalId: terminal.id, - profileId, - email: emailFromOutput || profile?.email, - success: true, - needsOnboarding: true, - detectedAt: new Date().toISOString() - } as OAuthTokenEvent); - } + // needsOnboarding: true tells the UI to show "complete setup" message + // instead of "success" - user should finish Claude's onboarding before closing + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { + terminalId: terminal.id, + profileId, + email: emailFromOutput || profile?.email, + success: true, + needsOnboarding: true, + detectedAt: new Date().toISOString() + } as OAuthTokenEvent); } else { console.warn('[ClaudeIntegration] Login successful but Keychain token not found and no credentials in configDir - user may need to complete authentication manually'); } @@ -622,16 +624,13 @@ export function handleOAuthToken( clearKeychainCache(profile.configDir); console.warn('[ClaudeIntegration] Profile credentials verified (not caching token):', profileId); - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { - terminalId: terminal.id, - profileId, - email, - success: true, - detectedAt: new Date().toISOString() - } as OAuthTokenEvent); - } + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { + terminalId: terminal.id, + profileId, + email, + success: true, + detectedAt: new Date().toISOString() + } as OAuthTokenEvent); } else { console.error('[ClaudeIntegration] Profile not found for OAuth token:', profileId); } @@ -645,17 +644,14 @@ export function handleOAuthToken( // Defensive null check for active profile if (!activeProfile) { console.error('[ClaudeIntegration] Failed to update profile: no active profile found'); - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { - terminalId: terminal.id, - profileId: undefined, - email, - success: false, - message: 'No active profile found', - detectedAt: new Date().toISOString() - } as OAuthTokenEvent); - } + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { + terminalId: terminal.id, + profileId: undefined, + email, + success: false, + message: 'No active profile found', + detectedAt: new Date().toISOString() + } as OAuthTokenEvent); return; } @@ -686,16 +682,13 @@ export function handleOAuthToken( clearKeychainCache(activeProfile.configDir); console.warn('[ClaudeIntegration] Active profile credentials verified (not caching token):', activeProfile.name); - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { - terminalId: terminal.id, - profileId: activeProfile.id, - email, - success: true, - detectedAt: new Date().toISOString() - } as OAuthTokenEvent); - } + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_OAUTH_TOKEN, { + terminalId: terminal.id, + profileId: activeProfile.id, + email, + success: true, + detectedAt: new Date().toISOString() + } as OAuthTokenEvent); } } @@ -785,14 +778,11 @@ export function handleOnboardingComplete( } } - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, { - terminalId: terminal.id, - profileId, - detectedAt: new Date().toISOString() - } as OnboardingCompleteEvent); - } + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_ONBOARDING_COMPLETE, { + terminalId: terminal.id, + profileId, + detectedAt: new Date().toISOString() + } as OnboardingCompleteEvent); // Trigger immediate usage fetch after successful re-authentication // This gives the user immediate feedback that their account is working @@ -845,10 +835,7 @@ export function handleClaudeSessionId( SessionHandler.updateClaudeSessionId(terminal.projectPath, terminal.id, sessionId); } - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminal.id, sessionId); - } + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_CLAUDE_SESSION, terminal.id, sessionId); } /** @@ -879,10 +866,7 @@ export function handleClaudeExit( } // Notify renderer to update UI - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, terminal.id); - } + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_CLAUDE_EXIT, terminal.id); } /** @@ -1109,7 +1093,9 @@ export function invokeClaude( const cwdCommand = buildCdCommand(cwd, terminal.shellType); const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); const escapedClaudeCmd = escapeShellCommand(claudeCmd); - const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + const pathPrefix = isAbsoluteExecutableCommand(claudeCmd) + ? '' + : buildPathPrefix(claudeEnv.PATH || ''); const needsEnvOverride: boolean = !!(profileId && profileId !== previousProfileId); debugLog('[ClaudeIntegration:invokeClaude] Environment override check:', { @@ -1196,7 +1182,9 @@ export function resumeClaude( const { command: claudeCmd, env: claudeEnv } = getClaudeCliInvocation(); const escapedClaudeCmd = escapeShellCommand(claudeCmd); - const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + const pathPrefix = isAbsoluteExecutableCommand(claudeCmd) + ? '' + : buildPathPrefix(claudeEnv.PATH || ''); // Always use --continue which resumes the most recent session in the current directory. // This is more reliable than --resume with session IDs since Auto Claude already restores @@ -1220,10 +1208,7 @@ export function resumeClaude( // This preserves user-customized names and prevents renaming on every resume if (shouldAutoRenameTerminal(terminal.title)) { terminal.title = 'Claude'; - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude'); - } + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude'); } // Persist session @@ -1313,7 +1298,9 @@ export async function invokeClaudeAsync( }); const escapedClaudeCmd = escapeShellCommand(claudeCmd); - const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + const pathPrefix = isAbsoluteExecutableCommand(claudeCmd) + ? '' + : buildPathPrefix(claudeEnv.PATH || ''); const needsEnvOverride: boolean = !!(profileId && profileId !== previousProfileId); debugLog('[ClaudeIntegration:invokeClaudeAsync] Environment override check:', { @@ -1409,7 +1396,9 @@ export async function resumeClaudeAsync( }); const escapedClaudeCmd = escapeShellCommand(claudeCmd); - const pathPrefix = buildPathPrefix(claudeEnv.PATH || ''); + const pathPrefix = isAbsoluteExecutableCommand(claudeCmd) + ? '' + : buildPathPrefix(claudeEnv.PATH || ''); // Always use --continue which resumes the most recent session in the current directory. // This is more reliable than --resume with session IDs since Auto Claude already restores @@ -1433,10 +1422,7 @@ export async function resumeClaudeAsync( // This preserves user-customized names and prevents renaming on every resume if (shouldAutoRenameTerminal(terminal.title)) { terminal.title = 'Claude'; - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude'); - } + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_TITLE_CHANGE, terminal.id, 'Claude'); } // Persist session (async, fire-and-forget to prevent main process blocking) diff --git a/apps/frontend/src/main/terminal/pty-manager.ts b/apps/frontend/src/main/terminal/pty-manager.ts index 4a188e341b..551cc090c3 100644 --- a/apps/frontend/src/main/terminal/pty-manager.ts +++ b/apps/frontend/src/main/terminal/pty-manager.ts @@ -209,18 +209,25 @@ export function setupPtyHandlers( onExitCallback: (terminal: TerminalProcess) => void ): void { const { id, pty: ptyProcess } = terminal; + terminal.hasExited = false; // Handle data from terminal ptyProcess.onData((data) => { // Shutdown guard (GitHub #1469): skip processing to avoid accessing // destroyed BrowserWindow.webContents, which triggers pty.node SIGABRT if (isShuttingDown) return; + if (terminal.hasExited) return; // Append to output buffer (limit to 100KB) terminal.outputBuffer = (terminal.outputBuffer + data).slice(-100000); - // Call custom data handler - onDataCallback(terminal, data); + // Call custom data handler. This must never crash the main process: + // parser logic in higher layers can throw on unexpected output. + try { + onDataCallback(terminal, data); + } catch (error) { + debugError('[PtyManager] onData callback failed for terminal:', id, 'error:', error); + } // Send to renderer with isDestroyed() check to prevent crashes // when the window is closed during terminal activity @@ -229,6 +236,9 @@ export function setupPtyHandlers( // Handle terminal exit ptyProcess.onExit(({ exitCode }) => { + terminal.hasExited = true; + // Drop any queued writes for this terminal to avoid writing to dead PTYs. + pendingWrites.delete(id); debugLog('[PtyManager] Terminal exited:', id, 'code:', exitCode); // Always resolve pending exit promises, even during shutdown @@ -248,8 +258,13 @@ export function setupPtyHandlers( // when the window is closed during terminal exit safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_EXIT, id, exitCode); - // Call custom exit handler - onExitCallback(terminal); + // Call custom exit handler. Guard against unexpected exceptions so PTY exit + // handling remains robust and doesn't take down the main process. + try { + onExitCallback(terminal); + } catch (error) { + debugError('[PtyManager] onExit callback failed for terminal:', id, 'error:', error); + } // Only delete if this is the SAME terminal object (not a newly created one with same ID). // This prevents a race where destroyTerminal() awaits PTY exit, a new terminal is created @@ -285,6 +300,11 @@ const pendingWrites = new Map>(); */ function performWrite(terminal: TerminalProcess, data: string): Promise { return new Promise((resolve) => { + if (terminal.hasExited) { + resolve(); + return; + } + // For large commands, write in chunks to prevent blocking if (data.length > CHUNKED_WRITE_THRESHOLD) { debugLog('[PtyManager:writeToPty] Large write detected, using chunked write'); @@ -293,7 +313,7 @@ function performWrite(terminal: TerminalProcess, data: string): Promise { const writeChunk = () => { // Check if terminal is still valid before writing - if (!terminal.pty) { + if (!terminal.pty || terminal.hasExited) { debugError('[PtyManager:writeToPty] Terminal PTY no longer valid, aborting chunked write'); resolve(); return; @@ -339,6 +359,10 @@ function performWrite(terminal: TerminalProcess, data: string): Promise { */ export function writeToPty(terminal: TerminalProcess, data: string): void { debugLog('[PtyManager:writeToPty] About to write to pty, data length:', data.length); + if (terminal.hasExited) { + debugError('[PtyManager:writeToPty] Skipping write to exited terminal:', terminal.id); + return; + } // Get the previous write Promise for this terminal (if any) const previousWrite = pendingWrites.get(terminal.id) || Promise.resolve(); @@ -366,6 +390,11 @@ export function writeToPty(terminal: TerminalProcess, data: string): void { * @returns true if resize was successful, false otherwise */ export function resizePty(terminal: TerminalProcess, cols: number, rows: number): boolean { + if (terminal.hasExited) { + debugError('[PtyManager] Resize skipped for exited terminal:', terminal.id); + return false; + } + // Validate dimensions if (cols <= 0 || rows <= 0 || !Number.isFinite(cols) || !Number.isFinite(rows)) { debugError('[PtyManager] Invalid resize dimensions - terminal:', terminal.id, 'cols:', cols, 'rows:', rows); @@ -394,6 +423,10 @@ export function resizePty(terminal: TerminalProcess, cols: number, rows: number) export function killPty(terminal: TerminalProcess, waitForExit: true): Promise; export function killPty(terminal: TerminalProcess, waitForExit?: false): void; export function killPty(terminal: TerminalProcess, waitForExit?: boolean): Promise | void { + if (terminal.hasExited) { + return waitForExit ? Promise.resolve() : undefined; + } + if (waitForExit) { const exitPromise = waitForPtyExit(terminal.id); try { diff --git a/apps/frontend/src/main/terminal/session-handler.ts b/apps/frontend/src/main/terminal/session-handler.ts index f04e4a8548..2be49c61a0 100644 --- a/apps/frontend/src/main/terminal/session-handler.ts +++ b/apps/frontend/src/main/terminal/session-handler.ts @@ -225,6 +225,18 @@ export function persistAllSessions(terminals: Map): voi }); } +/** + * Clear a terminal ID from pendingDelete, allowing session saves to proceed. + * + * Must be called when re-creating a terminal with a previously-used ID + * (e.g., worktree switching, terminal restart after shell exit). Without this, + * the pendingDelete guard blocks persistence for the new terminal. + */ +export function clearPendingDelete(terminalId: string): void { + const store = getTerminalSessionStore(); + store.clearPendingDelete(terminalId); +} + /** * Remove a session from persistent storage */ diff --git a/apps/frontend/src/main/terminal/terminal-event-handler.ts b/apps/frontend/src/main/terminal/terminal-event-handler.ts index d6d3ca2f35..4f5569d877 100644 --- a/apps/frontend/src/main/terminal/terminal-event-handler.ts +++ b/apps/frontend/src/main/terminal/terminal-event-handler.ts @@ -7,6 +7,7 @@ import * as OutputParser from './output-parser'; import * as ClaudeIntegration from './claude-integration-handler'; import type { TerminalProcess, WindowGetter } from './types'; import { IPC_CHANNELS } from '../../shared/constants'; +import { safeSendToRenderer } from '../ipc-handlers/utils'; /** * Event handler callbacks @@ -109,10 +110,7 @@ export function createEventCallbacks( ClaudeIntegration.handleOnboardingComplete(terminal, data, getWindow); }, onClaudeBusyChange: (terminal, isBusy) => { - const win = getWindow(); - if (win) { - win.webContents.send(IPC_CHANNELS.TERMINAL_CLAUDE_BUSY, terminal.id, isBusy); - } + safeSendToRenderer(getWindow, IPC_CHANNELS.TERMINAL_CLAUDE_BUSY, terminal.id, isBusy); }, onClaudeExit: (terminal) => { ClaudeIntegration.handleClaudeExit(terminal, getWindow); diff --git a/apps/frontend/src/main/terminal/terminal-lifecycle.ts b/apps/frontend/src/main/terminal/terminal-lifecycle.ts index 0105f6aecc..7573402f02 100644 --- a/apps/frontend/src/main/terminal/terminal-lifecycle.ts +++ b/apps/frontend/src/main/terminal/terminal-lifecycle.ts @@ -54,6 +54,13 @@ export async function createTerminal( return { success: true }; } + // Clear any pendingDelete for this terminal ID. This handles the case where + // a terminal is destroyed and immediately re-created with the same ID (e.g., + // worktree switching, terminal restart after shell exit). Without this, the + // pendingDelete guard (5-second window) blocks session persistence for the + // new terminal, causing it to be invisible to the session store. + SessionHandler.clearPendingDelete(id); + try { // For auth terminals, don't inject existing OAuth token - we want a fresh login const profileEnv = skipOAuthToken ? {} : PtyManager.getActiveProfileEnv(); @@ -101,6 +108,7 @@ export async function createTerminal( id, pty: ptyProcess, isClaudeMode: false, + hasExited: false, projectPath, cwd: terminalCwd, outputBuffer: '', diff --git a/apps/frontend/src/main/terminal/types.ts b/apps/frontend/src/main/terminal/types.ts index d68ca2c12d..357c55b629 100644 --- a/apps/frontend/src/main/terminal/types.ts +++ b/apps/frontend/src/main/terminal/types.ts @@ -28,6 +28,8 @@ export interface TerminalProcess { shellType?: WindowsShellType; /** Whether this terminal is waiting for Claude onboarding to complete (login flow) */ awaitingOnboardingComplete?: boolean; + /** Whether PTY has emitted exit; used to avoid writes/resizes on dead PTYs */ + hasExited?: boolean; } /** diff --git a/apps/frontend/src/renderer/components/settings/DisplaySettings.tsx b/apps/frontend/src/renderer/components/settings/DisplaySettings.tsx index 13bd0695be..13d3d4a0ef 100644 --- a/apps/frontend/src/renderer/components/settings/DisplaySettings.tsx +++ b/apps/frontend/src/renderer/components/settings/DisplaySettings.tsx @@ -6,7 +6,7 @@ import { Label } from '../ui/label'; import { SettingsSection } from './SettingsSection'; import { useSettingsStore } from '../../stores/settings-store'; import { UI_SCALE_MIN, UI_SCALE_MAX, UI_SCALE_DEFAULT, UI_SCALE_STEP } from '../../../shared/constants'; -import type { AppSettings } from '../../../shared/types'; +import type { AppSettings, GpuAcceleration } from '../../../shared/types'; import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from '../ui/select'; interface DisplaySettingsProps { @@ -274,6 +274,45 @@ export function DisplaySettings({ settings, onSettingsChange }: DisplaySettingsP + + {/* GPU Acceleration Setting */} +
+
+
+ +

+ {t('gpuAcceleration.description')} +

+
+ +
+

+ {t('gpuAcceleration.helperText')} +

+
); diff --git a/apps/frontend/src/renderer/components/settings/__tests__/DisplaySettings.test.tsx b/apps/frontend/src/renderer/components/settings/__tests__/DisplaySettings.test.tsx new file mode 100644 index 0000000000..b19f6481ea --- /dev/null +++ b/apps/frontend/src/renderer/components/settings/__tests__/DisplaySettings.test.tsx @@ -0,0 +1,157 @@ +/** + * @vitest-environment jsdom + */ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { render, screen } from '@testing-library/react'; +import '@testing-library/jest-dom'; +import '../../../../shared/i18n'; +import { DisplaySettings } from '../DisplaySettings'; +import type { AppSettings } from '../../../../shared/types'; + +// Mock the settings store +vi.mock('../../../stores/settings-store', () => ({ + useSettingsStore: vi.fn(() => ({ + updateSettings: vi.fn() + })) +})); + +// Track onValueChange callbacks per Select instance, keyed by the SelectTrigger id +let selectCallbacks: Map void> = new Map(); +let currentSelectCallback: ((v: string) => void) | null = null; + +// Mock Radix Select to make it testable in jsdom (portals don't work in jsdom) +vi.mock('../../ui/select', () => { + return { + Select: ({ value, onValueChange, children }: { value: string; onValueChange: (v: string) => void; children: React.ReactNode }) => { + currentSelectCallback = onValueChange; + return
{children}
; + }, + SelectTrigger: ({ id, children }: { id?: string; className?: string; children: React.ReactNode }) => { + if (id && currentSelectCallback) { + selectCallbacks.set(id, currentSelectCallback); + currentSelectCallback = null; + } + return ; + }, + SelectValue: () => null, + SelectContent: ({ children }: { className?: string; children: React.ReactNode }) => ( +
{children}
+ ), + SelectItem: ({ value, children }: { value: string; children: React.ReactNode }) => ( +
+ {children} +
+ ) + }; +}); + +const defaultSettings: AppSettings = { + uiScale: 100, + logOrder: 'chronological', + gpuAcceleration: 'auto' +} as AppSettings; + +describe('DisplaySettings - GPU Acceleration Dropdown', () => { + let mockOnSettingsChange: (settings: AppSettings) => void; + + beforeEach(() => { + vi.clearAllMocks(); + selectCallbacks = new Map(); + currentSelectCallback = null; + mockOnSettingsChange = vi.fn(); + }); + + it('should render the GPU acceleration dropdown with all 3 options', () => { + render( + + ); + + expect(screen.getByText('GPU Acceleration')).toBeInTheDocument(); + expect(screen.getByTestId('select-item-auto')).toBeInTheDocument(); + expect(screen.getByTestId('select-item-on')).toBeInTheDocument(); + expect(screen.getByTestId('select-item-off')).toBeInTheDocument(); + }); + + it('should display the correct translated labels for each option', () => { + render( + + ); + + expect(screen.getByText('Auto (use WebGL when supported)')).toBeInTheDocument(); + expect(screen.getByText('Always on')).toBeInTheDocument(); + expect(screen.getByText('Off (default)')).toBeInTheDocument(); + }); + + it('should display the current GPU acceleration value from settings', () => { + const settingsWithOn: AppSettings = { ...defaultSettings, gpuAcceleration: 'on' }; + + render( + + ); + + // The GPU acceleration select is identified by its trigger id + const gpuTrigger = screen.getByTestId('select-trigger-gpuAcceleration'); + const gpuSelect = gpuTrigger.closest('[data-value]'); + expect(gpuSelect).toHaveAttribute('data-value', 'on'); + }); + + it('should default to "off" when gpuAcceleration is not set', () => { + const settingsWithoutGpu: AppSettings = { ...defaultSettings, gpuAcceleration: undefined }; + + render( + + ); + + const gpuTrigger = screen.getByTestId('select-trigger-gpuAcceleration'); + const gpuSelect = gpuTrigger.closest('[data-value]'); + expect(gpuSelect).toHaveAttribute('data-value', 'off'); + }); + + it('should call onSettingsChange with gpuAcceleration "on" when selected', () => { + render( + + ); + + selectCallbacks.get('gpuAcceleration')!('on'); + + expect(mockOnSettingsChange).toHaveBeenCalledWith( + expect.objectContaining({ gpuAcceleration: 'on' }) + ); + }); + + it('should call onSettingsChange with gpuAcceleration "off" when selected', () => { + render( + + ); + + selectCallbacks.get('gpuAcceleration')!('off'); + + expect(mockOnSettingsChange).toHaveBeenCalledWith( + expect.objectContaining({ gpuAcceleration: 'off' }) + ); + }); + + it('should call onSettingsChange with gpuAcceleration "auto" when selected', () => { + const settingsWithOff: AppSettings = { ...defaultSettings, gpuAcceleration: 'off' }; + + render( + + ); + + selectCallbacks.get('gpuAcceleration')!('auto'); + + expect(mockOnSettingsChange).toHaveBeenCalledWith( + expect.objectContaining({ gpuAcceleration: 'auto' }) + ); + }); + + it('should render the GPU acceleration description text', () => { + render( + + ); + + expect( + screen.getByText('Use WebGL for terminal rendering (experimental, faster with many terminals)') + ).toBeInTheDocument(); + }); +}); diff --git a/apps/frontend/src/renderer/components/terminal/__tests__/useXterm.test.ts b/apps/frontend/src/renderer/components/terminal/__tests__/useXterm.test.ts index 49494676b2..cf0dc40f75 100644 --- a/apps/frontend/src/renderer/components/terminal/__tests__/useXterm.test.ts +++ b/apps/frontend/src/renderer/components/terminal/__tests__/useXterm.test.ts @@ -66,11 +66,35 @@ vi.mock('@xterm/addon-serialize', () => ({ vi.mock('../../../../lib/terminal-buffer-manager', () => ({ terminalBufferManager: { get: vi.fn(() => ''), + getAndClear: vi.fn(() => ''), set: vi.fn(), clear: vi.fn() } })); +// Mock WebGL context manager +const mockWebglRegister = vi.fn(); +const mockWebglAcquire = vi.fn(); +const mockWebglUnregister = vi.fn(); +vi.mock('../../../lib/webgl-context-manager', () => ({ + webglContextManager: { + register: (...args: unknown[]) => mockWebglRegister(...args), + acquire: (...args: unknown[]) => mockWebglAcquire(...args), + unregister: (...args: unknown[]) => mockWebglUnregister(...args), + } +})); + +// Mock settings store (for gpuAcceleration setting) +const mockSettingsStoreState = { + settings: { gpuAcceleration: 'auto' as string | undefined } +}; +vi.mock('../../../stores/settings-store', () => ({ + useSettingsStore: Object.assign(vi.fn(), { + getState: () => mockSettingsStoreState, + subscribe: vi.fn(() => vi.fn()), + }) +})); + // Mock navigator.platform for platform detection const originalNavigatorPlatform = navigator.platform; @@ -837,3 +861,186 @@ describe('useXterm keyboard handlers', () => { }); }); }); + +describe('useXterm WebGL context management', () => { + // Mock requestAnimationFrame for jsdom environment + const originalRequestAnimationFrame = global.requestAnimationFrame; + const originalCancelAnimationFrame = global.cancelAnimationFrame; + + beforeAll(() => { + global.requestAnimationFrame = vi.fn((cb: FrameRequestCallback) => setTimeout(cb, 0) as unknown as number); + global.cancelAnimationFrame = vi.fn((id: number) => clearTimeout(id)); + }); + + afterAll(() => { + global.requestAnimationFrame = originalRequestAnimationFrame; + global.cancelAnimationFrame = originalCancelAnimationFrame; + }); + + beforeEach(() => { + vi.useFakeTimers(); + vi.clearAllMocks(); + + // Reset gpuAcceleration to default + mockSettingsStoreState.settings.gpuAcceleration = 'auto'; + + // Mock ResizeObserver + global.ResizeObserver = vi.fn().mockImplementation(function() { + return { observe: vi.fn(), unobserve: vi.fn(), disconnect: vi.fn() }; + }); + + // Mock window.electronAPI + (window as unknown as { electronAPI: unknown }).electronAPI = { + sendTerminalInput: vi.fn(), + openExternal: vi.fn(), + }; + }); + + afterEach(() => { + vi.clearAllTimers(); + vi.useRealTimers(); + vi.restoreAllMocks(); + }); + + /** + * Helper to render useXterm and wait for initialization + */ + async function renderUseXterm(terminalId = 'test-webgl-terminal') { + // Set up XTerm mock with dispose tracking + const mockDispose = vi.fn(); + (XTerm as unknown as Mock).mockImplementation(function() { + return { + open: vi.fn(), + loadAddon: vi.fn(), + attachCustomKeyEventHandler: vi.fn(), + hasSelection: vi.fn(() => false), + getSelection: vi.fn(() => ''), + paste: vi.fn(), + input: vi.fn(), + onData: vi.fn(), + onResize: vi.fn(), + dispose: mockDispose, + write: vi.fn(), + cols: 80, + rows: 24, + options: { + cursorBlink: true, + cursorStyle: 'block', + fontSize: 14, + fontFamily: 'monospace', + fontWeight: 'normal', + lineHeight: 1, + letterSpacing: 0, + theme: { cursorAccent: '#000000' }, + scrollback: 1000 + }, + refresh: vi.fn() + }; + }); + + const { FitAddon } = await import('@xterm/addon-fit'); + (FitAddon as unknown as Mock).mockImplementation(function() { + return { fit: vi.fn(), dispose: vi.fn() }; + }); + + const { WebLinksAddon } = await import('@xterm/addon-web-links'); + (WebLinksAddon as unknown as Mock).mockImplementation(function() { + return {}; + }); + + const { SerializeAddon } = await import('@xterm/addon-serialize'); + (SerializeAddon as unknown as Mock).mockImplementation(function() { + return { serialize: vi.fn(() => ''), dispose: vi.fn() }; + }); + + let disposeHook: (() => void) | null = null; + + const TestWrapper = () => { + const result = useXterm({ terminalId }); + // Expose dispose via ref so tests can call it + disposeHook = result.dispose; + return React.createElement('div', { ref: result.terminalRef }); + }; + + await act(async () => { + render(React.createElement(TestWrapper)); + }); + + return { disposeHook: () => disposeHook?.() }; + } + + it('should lazily import and acquire WebGL context when gpuAcceleration is "auto"', async () => { + mockSettingsStoreState.settings.gpuAcceleration = 'auto'; + + await renderUseXterm('terminal-auto'); + // Flush the dynamic import() promise + microtasks + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + expect(mockWebglRegister).toHaveBeenCalledWith('terminal-auto', expect.anything()); + expect(mockWebglAcquire).toHaveBeenCalledWith('terminal-auto'); + }); + + it('should lazily import and acquire WebGL context when gpuAcceleration is "on"', async () => { + mockSettingsStoreState.settings.gpuAcceleration = 'on'; + + await renderUseXterm('terminal-on'); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + expect(mockWebglRegister).toHaveBeenCalledWith('terminal-on', expect.anything()); + expect(mockWebglAcquire).toHaveBeenCalledWith('terminal-on'); + }); + + it('should NOT import WebGL module at all when gpuAcceleration is "off"', async () => { + mockSettingsStoreState.settings.gpuAcceleration = 'off'; + + await renderUseXterm('terminal-off'); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + // When off, the dynamic import() never fires — no GPU code runs + expect(mockWebglRegister).not.toHaveBeenCalled(); + expect(mockWebglAcquire).not.toHaveBeenCalled(); + }); + + it('should unregister WebGL context on terminal disposal', async () => { + mockSettingsStoreState.settings.gpuAcceleration = 'auto'; + + const { disposeHook } = await renderUseXterm('terminal-dispose'); + // Flush the dynamic import so the manager ref is populated + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + expect(mockWebglRegister).toHaveBeenCalledWith('terminal-dispose', expect.anything()); + + // Dispose the terminal + act(() => { + disposeHook(); + }); + + expect(mockWebglUnregister).toHaveBeenCalledWith('terminal-dispose'); + }); + + it('should NOT unregister on disposal when WebGL was never loaded (off)', async () => { + mockSettingsStoreState.settings.gpuAcceleration = 'off'; + + const { disposeHook } = await renderUseXterm('terminal-off-dispose'); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + // Dispose the terminal + act(() => { + disposeHook(); + }); + + // WebGL was never loaded, so unregister should not be called + expect(mockWebglUnregister).not.toHaveBeenCalled(); + }); + + it('should fallback to "off" when gpuAcceleration is undefined (upgrading users)', async () => { + mockSettingsStoreState.settings.gpuAcceleration = undefined; + + await renderUseXterm('terminal-undefined'); + await act(async () => { await vi.advanceTimersByTimeAsync(0); }); + + // When undefined, the ?? 'off' fallback means no WebGL import at all + expect(mockWebglRegister).not.toHaveBeenCalled(); + expect(mockWebglAcquire).not.toHaveBeenCalled(); + }); +}); diff --git a/apps/frontend/src/renderer/components/terminal/useXterm.ts b/apps/frontend/src/renderer/components/terminal/useXterm.ts index f2382c7dfd..6bd52af4e7 100644 --- a/apps/frontend/src/renderer/components/terminal/useXterm.ts +++ b/apps/frontend/src/renderer/components/terminal/useXterm.ts @@ -10,6 +10,8 @@ import { isWindows as checkIsWindows, isLinux as checkIsLinux } from '../../lib/ import { debounce } from '../../lib/debounce'; import { DEFAULT_TERMINAL_THEME } from '../../lib/terminal-theme'; import { debugLog, debugError } from '../../../shared/utils/debug-logger'; +import { useSettingsStore } from '../../stores/settings-store'; +import type { WebGLContextManagerType } from '../../lib/webgl-context-manager'; interface UseXtermOptions { terminalId: string; @@ -58,6 +60,8 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea const commandBufferRef = useRef(''); const isDisposedRef = useRef(false); const dimensionsReadyCalledRef = useRef(false); + // Lazily-loaded WebGL context manager — only populated when gpuAcceleration !== 'off' + const webglManagerRef = useRef(null); const onResizeRef = useRef(onResize); const [dimensions, setDimensions] = useState<{ cols: number; rows: number }>({ cols: 80, rows: 24 }); @@ -117,6 +121,29 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea xterm.open(terminalRef.current); + // WebGL acceleration: lazily load the WebGL module and acquire a context. + // The dynamic import() ensures NO GPU code (WebGL2 probing, context creation) + // runs unless the user has explicitly enabled GPU acceleration. + // This prevents GPU process instability on systems where WebGL2 is problematic + // (e.g., Apple Silicon Macs with certain macOS / Electron combinations). + const gpuAcceleration = useSettingsStore.getState().settings.gpuAcceleration ?? 'off'; + debugLog(`[useXterm] WebGL check for ${terminalId}: gpuAcceleration=${gpuAcceleration}`); + if (gpuAcceleration !== 'off') { + import('../../lib/webgl-context-manager') + .then(({ webglContextManager }) => { + // Guard: terminal may have been disposed while the import was resolving + if (isDisposedRef.current) return; + webglManagerRef.current = webglContextManager; + webglContextManager.register(terminalId, xterm); + webglContextManager.acquire(terminalId); + debugLog(`[useXterm] WebGL acquired for ${terminalId}`); + }) + .catch((error) => { + // WebGL is a progressive enhancement — terminal works fine without it + debugError(`[useXterm] WebGL initialization failed for ${terminalId}, falling back to canvas renderer:`, error); + }); + } + // Platform detection for copy/paste shortcuts // Use existing os-detection module instead of custom implementation const isWindows = checkIsWindows(); @@ -552,6 +579,16 @@ export function useXterm({ terminalId, onCommandEnter, onResize, onDimensionsRea // Serialize buffer before disposing to preserve ANSI formatting serializeBuffer(); + // Release WebGL context before disposing addons and xterm (only if WebGL was loaded) + if (webglManagerRef.current) { + try { + webglManagerRef.current.unregister(terminalId); + } catch (error) { + debugError(`[useXterm] WebGL cleanup failed for ${terminalId}:`, error); + } + webglManagerRef.current = null; + } + // Dispose addons explicitly before disposing xterm // While xterm.dispose() handles loaded addons, explicit disposal ensures // resources are freed in a predictable order and prevents potential leaks diff --git a/apps/frontend/src/renderer/lib/webgl-context-manager.ts b/apps/frontend/src/renderer/lib/webgl-context-manager.ts index 0ddf1866ab..fc3a491767 100644 --- a/apps/frontend/src/renderer/lib/webgl-context-manager.ts +++ b/apps/frontend/src/renderer/lib/webgl-context-manager.ts @@ -184,6 +184,9 @@ class WebGLContextManager { } } +/** Type alias for the manager — used by consumers that import lazily via dynamic import() */ +export type WebGLContextManagerType = WebGLContextManager; + // Export singleton instance export const webglContextManager = WebGLContextManager.getInstance(); diff --git a/apps/frontend/src/shared/constants/config.ts b/apps/frontend/src/shared/constants/config.ts index a9ae47eb85..bf98326f6c 100644 --- a/apps/frontend/src/shared/constants/config.ts +++ b/apps/frontend/src/shared/constants/config.ts @@ -67,7 +67,11 @@ export const DEFAULT_APP_SETTINGS = { // Anonymous error reporting (Sentry) - enabled by default to help improve the app sentryEnabled: true, // Auto-name Claude terminals based on initial message (enabled by default) - autoNameClaudeTerminals: true + autoNameClaudeTerminals: true, + // GPU acceleration for terminal rendering + // Default to 'off' until WebGL stability is proven across all GPU drivers. + // Users can opt-in via Settings > Display > GPU Acceleration. + gpuAcceleration: 'off' as const }; // ============================================ diff --git a/apps/frontend/src/shared/i18n/locales/en/settings.json b/apps/frontend/src/shared/i18n/locales/en/settings.json index ab3ee21f36..bc7fd8fa8f 100644 --- a/apps/frontend/src/shared/i18n/locales/en/settings.json +++ b/apps/frontend/src/shared/i18n/locales/en/settings.json @@ -192,6 +192,14 @@ "chronological": "Chronological (oldest first)", "reverseChronological": "Reverse-chronological (newest first)" }, + "gpuAcceleration": { + "label": "GPU Acceleration", + "description": "Use WebGL for terminal rendering (experimental, faster with many terminals)", + "auto": "Auto (use WebGL when supported)", + "on": "Always on", + "off": "Off (default)", + "helperText": "Changes apply to new terminals only" + }, "general": { "otherAgentSettings": "Other Agent Settings", "otherAgentSettingsDescription": "Additional agent configuration options", diff --git a/apps/frontend/src/shared/i18n/locales/fr/settings.json b/apps/frontend/src/shared/i18n/locales/fr/settings.json index edcc812b34..8d506e900f 100644 --- a/apps/frontend/src/shared/i18n/locales/fr/settings.json +++ b/apps/frontend/src/shared/i18n/locales/fr/settings.json @@ -192,6 +192,14 @@ "chronological": "Chronologique (plus ancien en premier)", "reverseChronological": "Chronologique inverse (plus récent en premier)" }, + "gpuAcceleration": { + "label": "Accélération GPU", + "description": "Utiliser WebGL pour le rendu des terminaux (expérimental, plus rapide avec plusieurs terminaux)", + "auto": "Auto (utiliser WebGL si supporté)", + "on": "Toujours activé", + "off": "Désactivé (par défaut)", + "helperText": "Les modifications s'appliquent uniquement aux nouveaux terminaux" + }, "general": { "otherAgentSettings": "Autres paramètres de l'agent", "otherAgentSettingsDescription": "Options de configuration supplémentaires de l'agent", diff --git a/apps/frontend/src/shared/types/settings.ts b/apps/frontend/src/shared/types/settings.ts index c6b03e52fd..77d3d6a32f 100644 --- a/apps/frontend/src/shared/types/settings.ts +++ b/apps/frontend/src/shared/types/settings.ts @@ -294,8 +294,13 @@ export interface AppSettings { seenVersionWarnings?: string[]; // Sidebar collapsed state (icons only when true) sidebarCollapsed?: boolean; + // GPU acceleration for terminal rendering (WebGL) + gpuAcceleration?: GpuAcceleration; } +// GPU acceleration mode for terminal WebGL rendering +export type GpuAcceleration = 'auto' | 'on' | 'off'; + // Auto-Claude Source Environment Configuration (for auto-claude repo .env) export interface SourceEnvConfig { // Claude Authentication (required for ideation, roadmap generation, etc.) diff --git a/apps/frontend/src/shared/utils/debug-logger.ts b/apps/frontend/src/shared/utils/debug-logger.ts index 8cd6e1aa50..f7187773c0 100644 --- a/apps/frontend/src/shared/utils/debug-logger.ts +++ b/apps/frontend/src/shared/utils/debug-logger.ts @@ -10,20 +10,36 @@ export const isDebugEnabled = (): boolean => { return false; }; +function safeConsoleWarn(...args: unknown[]): void { + try { + console.warn(...args); + } catch { + // Ignore console stream failures (e.g. EIO) in debug logging paths. + } +} + +function safeConsoleError(...args: unknown[]): void { + try { + console.error(...args); + } catch { + // Ignore console stream failures (e.g. EIO) in debug logging paths. + } +} + export const debugLog = (...args: unknown[]): void => { if (isDebugEnabled()) { - console.warn(...args); + safeConsoleWarn(...args); } }; export const debugWarn = (...args: unknown[]): void => { if (isDebugEnabled()) { - console.warn(...args); + safeConsoleWarn(...args); } }; export const debugError = (...args: unknown[]): void => { if (isDebugEnabled()) { - console.error(...args); + safeConsoleError(...args); } };