diff --git a/main/src/database/database.ts b/main/src/database/database.ts index 1596d34a..c1ee5f62 100644 --- a/main/src/database/database.ts +++ b/main/src/database/database.ts @@ -63,6 +63,51 @@ interface ExecutionDiffRow { timestamp: string; } +const DEBUG_DB_PANEL_STATE = process.env.PANE_DEBUG_DB_PANEL_STATE === "1"; +const LARGE_PANEL_STATE_FIELDS = new Set([ + "scrollbackBuffer", + "alternateScreenBuffer", + "serializedBuffer", + "commandHistory", + "lastActiveCommand", + "outputBuffer", +]); + +function summarizePanelStateField(value: unknown): string { + if (typeof value === "string") { + return `[string length=${value.length}]`; + } + + if (Array.isArray(value)) { + return `[array length=${value.length}]`; + } + + if (value && typeof value === "object") { + return `[object keys=${Object.keys(value).length}]`; + } + + return `[${typeof value}]`; +} + +function sanitizePanelStateForLog(value: unknown): unknown { + if (Array.isArray(value)) { + return value.map(item => sanitizePanelStateForLog(item)); + } + + if (!value || typeof value !== "object") { + return value; + } + + return Object.fromEntries( + Object.entries(value as Record).map(([key, nestedValue]) => [ + key, + LARGE_PANEL_STATE_FIELDS.has(key) + ? summarizePanelStateField(nestedValue) + : sanitizePanelStateForLog(nestedValue), + ]), + ); +} + export class DatabaseService { private db: Database.Database; @@ -3969,6 +4014,15 @@ export class DatabaseService { } } + if (DEBUG_DB_PANEL_STATE) { + console.log("[DB-DEBUG] updatePanel state merge:", { + panelId, + updates: sanitizePanelStateForLog(updates.state), + existing: sanitizePanelStateForLog(existingState), + merged: sanitizePanelStateForLog(mergedState), + }); + } + setClauses.push("state = ?"); values.push(JSON.stringify(mergedState)); } diff --git a/main/src/events.ts b/main/src/events.ts index 39369d43..4581e3b9 100644 --- a/main/src/events.ts +++ b/main/src/events.ts @@ -15,6 +15,13 @@ import type { Project } from './database/models'; import type { GitStatus } from './types/session'; import { resourceMonitorService } from './services/resourceMonitorService'; +function isArchivedSessionOutputValidation(validation: { error?: string; sessionId?: string }): boolean { + return Boolean( + validation.sessionId && + validation.error === `Session ${validation.sessionId} is archived` + ); +} + export function setupEventListeners(services: AppServices, getMainWindow: () => BrowserWindow | null): void { const { sessionManager, @@ -137,6 +144,11 @@ export function setupEventListeners(services: AppServices, getMainWindow: () => // Validate the output has valid session context const validation = validateEventContext(output); if (!validation.valid) { + if (isArchivedSessionOutputValidation(validation)) { + console.log(`[Validation] Dropping late session-output for archived session ${validation.sessionId}`); + return; + } + logValidationFailure('session-output event', validation); return; // Don't broadcast invalid events } diff --git a/main/src/services/configManager.ts b/main/src/services/configManager.ts index 8715ff68..705f9522 100644 --- a/main/src/services/configManager.ts +++ b/main/src/services/configManager.ts @@ -153,8 +153,8 @@ export class ConfigManager extends EventEmitter { } private async saveConfig(): Promise { - const configJson = JSON.stringify(this.config, null, 2); const writeConfig = async () => { + const configJson = JSON.stringify(this.config, null, 2); await fs.mkdir(this.configDir, { recursive: true }); const tmpPath = `${this.configPath}.${process.pid}.${Date.now()}.${Math.random().toString(36).slice(2)}.tmp`; diff --git a/main/src/utils/logger.ts b/main/src/utils/logger.ts index 38ff73bd..38ca6059 100644 --- a/main/src/utils/logger.ts +++ b/main/src/utils/logger.ts @@ -13,7 +13,8 @@ const ORIGINAL_CONSOLE = { info: console.info }; -const MAX_LOG_EVENT_CHARS = 16 * 1024; +const MAX_CONSOLE_LOG_EVENT_CHARS = 16 * 1024; +const MAX_FILE_LOG_EVENT_BYTES = 64 * 1024; function sanitizeLogControls(message: string): string { let sanitized = ''; @@ -32,6 +33,26 @@ function sanitizeLogControls(message: string): string { return sanitized; } +function truncateToByteLength(value: string, maxBytes: number): string { + if (Buffer.byteLength(value) <= maxBytes) { + return value; + } + + let low = 0; + let high = value.length; + + while (low < high) { + const mid = Math.ceil((low + high) / 2); + if (Buffer.byteLength(value.slice(0, mid)) <= maxBytes) { + low = mid; + } else { + high = mid - 1; + } + } + + return value.slice(0, low); +} + export class Logger { private logDir: string; private currentLogFile: string; @@ -147,7 +168,7 @@ export class Logger { } private writeToFile(logMessage: string) { - const messageWithNewline = logMessage + '\n'; + const messageWithNewline = `${this.truncateForFile(logMessage)}\n`; // Add to queue this.writeQueue.push({ @@ -231,26 +252,43 @@ export class Logger { } } - private normalizeLogEvent(message: string): string { + private normalizeLogEventForConsole(message: string): string { const sanitized = sanitizeLogControls(message); - if (sanitized.length <= MAX_LOG_EVENT_CHARS) { + if (sanitized.length <= MAX_CONSOLE_LOG_EVENT_CHARS) { return sanitized; } - const omittedChars = sanitized.length - MAX_LOG_EVENT_CHARS; - return `${sanitized.slice(0, MAX_LOG_EVENT_CHARS)} ... [truncated ${omittedChars} chars, original=${sanitized.length}]`; + const omittedChars = sanitized.length - MAX_CONSOLE_LOG_EVENT_CHARS; + return `${sanitized.slice(0, MAX_CONSOLE_LOG_EVENT_CHARS)} ... [truncated ${omittedChars} chars, original=${sanitized.length}]`; + } + + private truncateForFile(message: string): string { + const sanitized = sanitizeLogControls(message); + const originalBytes = Buffer.byteLength(sanitized); + + if (originalBytes <= MAX_FILE_LOG_EVENT_BYTES) { + return sanitized; + } + + const suffix = ` ... [truncated file log event from ${originalBytes} bytes to ${MAX_FILE_LOG_EVENT_BYTES} bytes]`; + const suffixBytes = Buffer.byteLength(suffix); + const targetBytes = Math.max(0, MAX_FILE_LOG_EVENT_BYTES - suffixBytes); + const truncated = truncateToByteLength(sanitized, targetBytes); + + return `${truncated}${suffix}`; } private log(level: string, message: string, error?: Error) { const timestamp = formatForDatabase(); const errorInfo = error ? ` Error: ${error.message}\nStack: ${error.stack}` : ''; - const fullMessage = this.normalizeLogEvent(`[${timestamp}] ${level}: ${message}${errorInfo}`); + const fullMessage = `[${timestamp}] ${level}: ${message}${errorInfo}`; + const consoleMessage = this.normalizeLogEventForConsole(fullMessage); // Try to log to console, but handle EPIPE errors gracefully try { // Always log to console using the original console method to avoid recursion - this.originalConsole.log(fullMessage); + this.originalConsole.log(consoleMessage); } catch (consoleError: unknown) { // If console logging fails (e.g., EPIPE), just write to file if ((consoleError as NodeJS.ErrnoException)?.code !== 'EPIPE' && !this.isInErrorHandler) { @@ -259,9 +297,9 @@ export class Logger { try { // For non-EPIPE errors, try to at least write the error to file // Use a direct write to avoid potential recursion through writeToFile - const errorMessage = `[${timestamp}] ERROR: Failed to write to console: ${(consoleError as Error)?.message || 'Unknown error'}\n`; + const errorMessage = `[${timestamp}] ERROR: Failed to write to console: ${(consoleError as Error)?.message || 'Unknown error'}`; if (this.logStream && !this.logStream.destroyed) { - this.logStream.write(errorMessage); + this.logStream.write(`${this.truncateForFile(errorMessage)}\n`); } } catch { // Silently fail - we've done our best