From dabb9ad8f68cdafb53b398157d52a3f37e5c8c86 Mon Sep 17 00:00:00 2001 From: Godwin Iheuwa Date: Fri, 23 Jan 2026 18:28:45 +0000 Subject: [PATCH] fix(core): gracefully handle disk full errors in chat recording (#17305) Co-authored-by: RUiNtheExtinct Co-authored-by: Tommaso Sciortino --- .../src/services/chatRecordingService.test.ts | 155 ++++++++++++++++++ .../core/src/services/chatRecordingService.ts | 30 +++- 2 files changed, 184 insertions(+), 1 deletion(-) diff --git a/packages/core/src/services/chatRecordingService.test.ts b/packages/core/src/services/chatRecordingService.test.ts index 6fb49fbd5f..ff4fe51879 100644 --- a/packages/core/src/services/chatRecordingService.test.ts +++ b/packages/core/src/services/chatRecordingService.test.ts @@ -454,4 +454,159 @@ describe('ChatRecordingService', () => { expect(writeFileSyncSpy).not.toHaveBeenCalled(); }); }); + + describe('ENOSPC (disk full) graceful degradation - issue #16266', () => { + it('should disable recording and not throw when ENOSPC occurs during initialize', () => { + const enospcError = new Error('ENOSPC: no space left on device'); + (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; + + mkdirSyncSpy.mockImplementation(() => { + throw enospcError; + }); + + // Should not throw + expect(() => chatRecordingService.initialize()).not.toThrow(); + + // Recording should be disabled (conversationFile set to null) + expect(chatRecordingService.getConversationFilePath()).toBeNull(); + }); + + it('should disable recording and not throw when ENOSPC occurs during writeConversation', () => { + chatRecordingService.initialize(); + + const enospcError = new Error('ENOSPC: no space left on device'); + (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; + + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ + sessionId: 'test-session-id', + projectHash: 'test-project-hash', + messages: [], + }), + ); + + writeFileSyncSpy.mockImplementation(() => { + throw enospcError; + }); + + // Should not throw when recording a message + expect(() => + chatRecordingService.recordMessage({ + type: 'user', + content: 'Hello', + model: 'gemini-pro', + }), + ).not.toThrow(); + + // Recording should be disabled (conversationFile set to null) + expect(chatRecordingService.getConversationFilePath()).toBeNull(); + }); + + it('should skip recording operations when recording is disabled', () => { + chatRecordingService.initialize(); + + const enospcError = new Error('ENOSPC: no space left on device'); + (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; + + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ + sessionId: 'test-session-id', + projectHash: 'test-project-hash', + messages: [], + }), + ); + + // First call throws ENOSPC + writeFileSyncSpy.mockImplementationOnce(() => { + throw enospcError; + }); + + chatRecordingService.recordMessage({ + type: 'user', + content: 'First message', + model: 'gemini-pro', + }); + + // Reset mock to track subsequent calls + writeFileSyncSpy.mockClear(); + + // Subsequent calls should be no-ops (not call writeFileSync) + chatRecordingService.recordMessage({ + type: 'user', + content: 'Second message', + model: 'gemini-pro', + }); + + chatRecordingService.recordThought({ + subject: 'Test', + description: 'Test thought', + }); + + chatRecordingService.saveSummary('Test summary'); + + // writeFileSync should not have been called for any of these + expect(writeFileSyncSpy).not.toHaveBeenCalled(); + }); + + it('should return null from getConversation when recording is disabled', () => { + chatRecordingService.initialize(); + + const enospcError = new Error('ENOSPC: no space left on device'); + (enospcError as NodeJS.ErrnoException).code = 'ENOSPC'; + + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ + sessionId: 'test-session-id', + projectHash: 'test-project-hash', + messages: [], + }), + ); + + writeFileSyncSpy.mockImplementation(() => { + throw enospcError; + }); + + // Trigger ENOSPC + chatRecordingService.recordMessage({ + type: 'user', + content: 'Hello', + model: 'gemini-pro', + }); + + // getConversation should return null when disabled + expect(chatRecordingService.getConversation()).toBeNull(); + expect(chatRecordingService.getConversationFilePath()).toBeNull(); + }); + + it('should still throw for non-ENOSPC errors', () => { + chatRecordingService.initialize(); + + const otherError = new Error('Permission denied'); + (otherError as NodeJS.ErrnoException).code = 'EACCES'; + + vi.spyOn(fs, 'readFileSync').mockReturnValue( + JSON.stringify({ + sessionId: 'test-session-id', + projectHash: 'test-project-hash', + messages: [], + }), + ); + + writeFileSyncSpy.mockImplementation(() => { + throw otherError; + }); + + // Should throw for non-ENOSPC errors + expect(() => + chatRecordingService.recordMessage({ + type: 'user', + content: 'Hello', + model: 'gemini-pro', + }), + ).toThrow('Permission denied'); + + // Recording should NOT be disabled for non-ENOSPC errors (file path still exists) + expect(chatRecordingService.getConversationFilePath()).not.toBeNull(); + }); + }); }); diff --git a/packages/core/src/services/chatRecordingService.ts b/packages/core/src/services/chatRecordingService.ts index b308cce789..2a920df8b7 100644 --- a/packages/core/src/services/chatRecordingService.ts +++ b/packages/core/src/services/chatRecordingService.ts @@ -20,6 +20,14 @@ import type { ToolResultDisplay } from '../tools/tools.js'; export const SESSION_FILE_PREFIX = 'session-'; +/** + * Warning message shown when recording is disabled due to disk full. + */ +const ENOSPC_WARNING_MESSAGE = + 'Chat recording disabled: No space left on device. ' + + 'The conversation will continue but will not be saved to disk. ' + + 'Free up disk space and restart to enable recording.'; + /** * Token usage summary for a message or conversation. */ @@ -173,6 +181,16 @@ export class ChatRecordingService { this.queuedThoughts = []; this.queuedTokens = null; } catch (error) { + // Handle disk full (ENOSPC) gracefully - disable recording but allow CLI to continue + if ( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOSPC' + ) { + this.conversationFile = null; + debugLogger.warn(ENOSPC_WARNING_MESSAGE); + return; // Don't throw - allow the CLI to continue + } debugLogger.error('Error initializing chat recording service:', error); throw error; } @@ -425,6 +443,16 @@ export class ChatRecordingService { fs.writeFileSync(this.conversationFile, newContent); } } catch (error) { + // Handle disk full (ENOSPC) gracefully - disable recording but allow conversation to continue + if ( + error instanceof Error && + 'code' in error && + (error as NodeJS.ErrnoException).code === 'ENOSPC' + ) { + this.conversationFile = null; + debugLogger.warn(ENOSPC_WARNING_MESSAGE); + return; // Don't throw - allow the conversation to continue + } debugLogger.error('Error writing conversation file.', error); throw error; } @@ -474,7 +502,7 @@ export class ChatRecordingService { /** * Gets the path to the current conversation file. - * Returns null if the service hasn't been initialized yet. + * Returns null if the service hasn't been initialized yet or recording is disabled. */ getConversationFilePath(): string | null { return this.conversationFile;