feat: implement background process logging and cleanup (#21189)

This commit is contained in:
Gal Zahavi
2026-03-10 17:13:20 -07:00
committed by GitHub
parent 7c4570339e
commit 524679d23c
15 changed files with 724 additions and 141 deletions

View File

@@ -0,0 +1,116 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { vi, describe, it, expect, beforeEach } from 'vitest';
import {
promises as fs,
type PathLike,
type Dirent,
type Stats,
} from 'node:fs';
import * as path from 'node:path';
import { cleanupBackgroundLogs } from './logCleanup.js';
vi.mock('@google/gemini-cli-core', () => ({
ShellExecutionService: {
getLogDir: vi.fn().mockReturnValue('/tmp/gemini/tmp/background-processes'),
},
debugLogger: {
debug: vi.fn(),
warn: vi.fn(),
},
}));
vi.mock('node:fs', () => ({
promises: {
access: vi.fn(),
readdir: vi.fn(),
stat: vi.fn(),
unlink: vi.fn(),
},
}));
describe('logCleanup', () => {
const logDir = '/tmp/gemini/tmp/background-processes';
beforeEach(() => {
vi.clearAllMocks();
});
it('should skip cleanup if the directory does not exist', async () => {
vi.mocked(fs.access).mockRejectedValue(new Error('ENOENT'));
await cleanupBackgroundLogs();
expect(fs.access).toHaveBeenCalledWith(logDir);
expect(fs.readdir).not.toHaveBeenCalled();
});
it('should skip cleanup if the directory is empty', async () => {
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(fs.readdir).mockResolvedValue([]);
await cleanupBackgroundLogs();
expect(fs.readdir).toHaveBeenCalledWith(logDir, { withFileTypes: true });
expect(fs.unlink).not.toHaveBeenCalled();
});
it('should delete log files older than 7 days', async () => {
const now = Date.now();
const oldTime = now - 8 * 24 * 60 * 60 * 1000; // 8 days ago
const newTime = now - 1 * 24 * 60 * 60 * 1000; // 1 day ago
const entries = [
{ name: 'old.log', isFile: () => true },
{ name: 'new.log', isFile: () => true },
{ name: 'not-a-log.txt', isFile: () => true },
{ name: 'some-dir', isFile: () => false },
] as Dirent[];
vi.mocked(fs.access).mockResolvedValue(undefined);
vi.mocked(
fs.readdir as (
path: PathLike,
options: { withFileTypes: true },
) => Promise<Dirent[]>,
).mockResolvedValue(entries);
vi.mocked(fs.stat).mockImplementation((filePath: PathLike) => {
const pathStr = filePath.toString();
if (pathStr.endsWith('old.log')) {
return Promise.resolve({ mtime: new Date(oldTime) } as Stats);
}
if (pathStr.endsWith('new.log')) {
return Promise.resolve({ mtime: new Date(newTime) } as Stats);
}
return Promise.resolve({ mtime: new Date(now) } as Stats);
});
vi.mocked(fs.unlink).mockResolvedValue(undefined);
await cleanupBackgroundLogs();
expect(fs.unlink).toHaveBeenCalledTimes(1);
expect(fs.unlink).toHaveBeenCalledWith(path.join(logDir, 'old.log'));
expect(fs.unlink).not.toHaveBeenCalledWith(path.join(logDir, 'new.log'));
});
it('should handle errors during file deletion gracefully', async () => {
const now = Date.now();
const oldTime = now - 8 * 24 * 60 * 60 * 1000;
const entries = [{ name: 'old.log', isFile: () => true }];
vi.mocked(fs.access).mockResolvedValue(undefined);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.readdir).mockResolvedValue(entries as any);
// eslint-disable-next-line @typescript-eslint/no-explicit-any
vi.mocked(fs.stat).mockResolvedValue({ mtime: new Date(oldTime) } as any);
vi.mocked(fs.unlink).mockRejectedValue(new Error('Permission denied'));
await expect(cleanupBackgroundLogs()).resolves.not.toThrow();
expect(fs.unlink).toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,66 @@
/**
* @license
* Copyright 2026 Google LLC
* SPDX-License-Identifier: Apache-2.0
*/
import { promises as fs } from 'node:fs';
import * as path from 'node:path';
import { ShellExecutionService, debugLogger } from '@google/gemini-cli-core';
const RETENTION_PERIOD_MS = 7 * 24 * 60 * 60 * 1000; // 7 days
/**
* Cleans up background process log files older than 7 days.
* Scans ~/.gemini/tmp/background-processes/ for .log files.
*
* @param debugMode Whether to log detailed debug information.
*/
export async function cleanupBackgroundLogs(
debugMode: boolean = false,
): Promise<void> {
try {
const logDir = ShellExecutionService.getLogDir();
// Check if the directory exists
try {
await fs.access(logDir);
} catch {
// Directory doesn't exist, nothing to clean up
return;
}
const entries = await fs.readdir(logDir, { withFileTypes: true });
const now = Date.now();
let deletedCount = 0;
for (const entry of entries) {
if (entry.isFile() && entry.name.endsWith('.log')) {
const filePath = path.join(logDir, entry.name);
try {
const stats = await fs.stat(filePath);
if (now - stats.mtime.getTime() > RETENTION_PERIOD_MS) {
await fs.unlink(filePath);
deletedCount++;
}
} catch (error) {
if (debugMode) {
debugLogger.debug(
`Failed to process log file ${entry.name}:`,
error,
);
}
}
}
}
if (deletedCount > 0 && debugMode) {
debugLogger.debug(`Cleaned up ${deletedCount} expired background logs.`);
}
} catch (error) {
// Best-effort cleanup, don't let it crash the CLI
if (debugMode) {
debugLogger.warn('Background log cleanup failed:', error);
}
}
}