fix(core): gracefully handle disk full errors in chat recording (#17305)

Co-authored-by: RUiNtheExtinct <deepkarma001@gmail.com>
Co-authored-by: Tommaso Sciortino <sciortino@gmail.com>
This commit is contained in:
Godwin Iheuwa
2026-01-23 18:28:45 +00:00
committed by GitHub
parent b5cac836c5
commit dabb9ad8f6
2 changed files with 184 additions and 1 deletions

View File

@@ -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();
});
});
});

View File

@@ -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;