mirror of
https://github.com/tiennm99/ccs.git
synced 2026-09-03 12:21:20 +00:00
fix: surface cleanup directory read errors
This commit is contained in:
@@ -36,20 +36,52 @@ function formatBytes(bytes: number): string {
|
|||||||
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
|
return `${(bytes / Math.pow(1024, i)).toFixed(2)} ${units[i]}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Return entries for a real directory, rejecting symlinked directory targets. */
|
interface DirectorySummary {
|
||||||
function readRealDirectory(dirPath: string): string[] {
|
fileCount: number;
|
||||||
|
size: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function getErrorMessage(error: unknown): string {
|
||||||
|
return error instanceof Error ? error.message : String(error);
|
||||||
|
}
|
||||||
|
|
||||||
|
function isMissingPathError(error: unknown): boolean {
|
||||||
|
return (
|
||||||
|
typeof error === 'object' &&
|
||||||
|
error !== null &&
|
||||||
|
'code' in error &&
|
||||||
|
(error as NodeJS.ErrnoException).code === 'ENOENT'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function pathExistsForCleanup(dirPath: string): boolean {
|
||||||
try {
|
try {
|
||||||
const stats = fs.lstatSync(dirPath);
|
fs.lstatSync(dirPath);
|
||||||
if (!stats.isDirectory() || stats.isSymbolicLink()) return [];
|
return true;
|
||||||
return fs.readdirSync(dirPath);
|
} catch (error) {
|
||||||
} catch {
|
if (isMissingPathError(error)) return false;
|
||||||
return [];
|
throw error;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Calculate total size of regular top-level files in a directory */
|
/** Return entries for a real directory, rejecting symlinked directory targets. */
|
||||||
function getDirSize(dirPath: string): number {
|
function readRealDirectory(dirPath: string): string[] {
|
||||||
let totalSize = 0;
|
let stats: fs.Stats;
|
||||||
|
|
||||||
|
try {
|
||||||
|
stats = fs.lstatSync(dirPath);
|
||||||
|
} catch (error) {
|
||||||
|
if (isMissingPathError(error)) return [];
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!stats.isDirectory() || stats.isSymbolicLink()) return [];
|
||||||
|
return fs.readdirSync(dirPath);
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Summarize regular top-level files in a real directory */
|
||||||
|
function summarizeDirectory(dirPath: string): DirectorySummary {
|
||||||
|
const summary = { fileCount: 0, size: 0 };
|
||||||
const entries = readRealDirectory(dirPath);
|
const entries = readRealDirectory(dirPath);
|
||||||
|
|
||||||
for (const entry of entries) {
|
for (const entry of entries) {
|
||||||
@@ -57,33 +89,15 @@ function getDirSize(dirPath: string): number {
|
|||||||
try {
|
try {
|
||||||
const stats = fs.lstatSync(filePath);
|
const stats = fs.lstatSync(filePath);
|
||||||
if (stats.isFile() && !stats.isSymbolicLink()) {
|
if (stats.isFile() && !stats.isSymbolicLink()) {
|
||||||
totalSize += stats.size;
|
summary.fileCount++;
|
||||||
|
summary.size += stats.size;
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// File may have been deleted between readdir and stat - skip
|
// File may have been deleted between readdir and stat - skip
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return totalSize;
|
return summary;
|
||||||
}
|
|
||||||
|
|
||||||
/** Count files in a directory */
|
|
||||||
function countFiles(dirPath: string): number {
|
|
||||||
let count = 0;
|
|
||||||
const entries = readRealDirectory(dirPath);
|
|
||||||
|
|
||||||
for (const entry of entries) {
|
|
||||||
const filePath = path.join(dirPath, entry);
|
|
||||||
try {
|
|
||||||
const stats = fs.lstatSync(filePath);
|
|
||||||
if (stats.isFile() && !stats.isSymbolicLink()) {
|
|
||||||
count++;
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// File may have been deleted - skip
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return count;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Delete all regular files in a real directory (skips symlinks for safety) */
|
/** Delete all regular files in a real directory (skips symlinks for safety) */
|
||||||
@@ -154,10 +168,9 @@ function getErrorLogFiles(logsDir: string): ErrorLogInfo[] {
|
|||||||
|
|
||||||
/** Delete error logs older than specified days */
|
/** Delete error logs older than specified days */
|
||||||
function cleanErrorLogs(
|
function cleanErrorLogs(
|
||||||
logsDir: string,
|
files: ErrorLogInfo[],
|
||||||
maxAgeDays: number
|
maxAgeDays: number
|
||||||
): { deleted: number; freedBytes: number; kept: number } {
|
): { deleted: number; freedBytes: number; kept: number } {
|
||||||
const files = getErrorLogFiles(logsDir);
|
|
||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
let freedBytes = 0;
|
let freedBytes = 0;
|
||||||
let kept = 0;
|
let kept = 0;
|
||||||
@@ -255,14 +268,23 @@ async function handleErrorLogCleanup(
|
|||||||
dryRun: boolean,
|
dryRun: boolean,
|
||||||
force: boolean
|
force: boolean
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// Check if logs directory exists
|
try {
|
||||||
if (!fs.existsSync(logsDir)) {
|
if (!pathExistsForCleanup(logsDir)) {
|
||||||
console.log(info('No CLIProxy logs directory found.'));
|
console.log(info('No CLIProxy logs directory found.'));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
} catch (error) {
|
||||||
|
console.log(warn(`Could not inspect CLIProxy logs: ${getErrorMessage(error)}`));
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get error log files
|
let errorLogs: ErrorLogInfo[];
|
||||||
const errorLogs = getErrorLogFiles(logsDir);
|
try {
|
||||||
|
errorLogs = getErrorLogFiles(logsDir);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(warn(`Could not read CLIProxy logs: ${getErrorMessage(error)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (errorLogs.length === 0) {
|
if (errorLogs.length === 0) {
|
||||||
console.log(info('No error logs found.'));
|
console.log(info('No error logs found.'));
|
||||||
return;
|
return;
|
||||||
@@ -327,7 +349,14 @@ async function handleErrorLogCleanup(
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Perform cleanup
|
// Perform cleanup
|
||||||
const { deleted, freedBytes, kept } = cleanErrorLogs(logsDir, maxAgeDays);
|
let result: { deleted: number; freedBytes: number; kept: number };
|
||||||
|
try {
|
||||||
|
result = cleanErrorLogs(errorLogs, maxAgeDays);
|
||||||
|
} catch (error) {
|
||||||
|
console.log(warn(`Could not clean CLIProxy logs: ${getErrorMessage(error)}`));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const { deleted, freedBytes, kept } = result;
|
||||||
console.log(ok(`Deleted ${deleted} error logs, freed ${formatBytes(freedBytes)}`));
|
console.log(ok(`Deleted ${deleted} error logs, freed ${formatBytes(freedBytes)}`));
|
||||||
if (kept > 0) {
|
if (kept > 0) {
|
||||||
console.log(info(`Kept ${kept} recent error logs (less than ${maxAgeDays} days old)`));
|
console.log(info(`Kept ${kept} recent error logs (less than ${maxAgeDays} days old)`));
|
||||||
@@ -344,15 +373,30 @@ async function handleMainLogCleanup(options: {
|
|||||||
dryRun: boolean;
|
dryRun: boolean;
|
||||||
force: boolean;
|
force: boolean;
|
||||||
}): Promise<void> {
|
}): Promise<void> {
|
||||||
const targets = [
|
const targets: Array<{ label: string; dir: string } & DirectorySummary> = [];
|
||||||
|
const unreadableTargets: Array<{ label: string; dir: string; error: unknown }> = [];
|
||||||
|
for (const target of [
|
||||||
{ label: 'CCS Logs', dir: options.ccsLogsDir },
|
{ label: 'CCS Logs', dir: options.ccsLogsDir },
|
||||||
{ label: 'CCS Log Archives', dir: options.ccsArchiveDir },
|
{ label: 'CCS Log Archives', dir: options.ccsArchiveDir },
|
||||||
{ label: 'CLIProxy Logs', dir: options.cliproxyLogsDir },
|
{ label: 'CLIProxy Logs', dir: options.cliproxyLogsDir },
|
||||||
].map((target) => ({
|
]) {
|
||||||
...target,
|
try {
|
||||||
fileCount: countFiles(target.dir),
|
targets.push({
|
||||||
size: getDirSize(target.dir),
|
...target,
|
||||||
}));
|
...summarizeDirectory(target.dir),
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
unreadableTargets.push({ ...target, error });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (unreadableTargets.length > 0) {
|
||||||
|
for (const target of unreadableTargets) {
|
||||||
|
console.log(warn(`Could not read ${target.label}: ${getErrorMessage(target.error)}`));
|
||||||
|
console.log(` ${target.dir}`);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
const activeTargets = targets.filter((target) => target.fileCount > 0);
|
const activeTargets = targets.filter((target) => target.fileCount > 0);
|
||||||
|
|
||||||
if (activeTargets.length === 0) {
|
if (activeTargets.length === 0) {
|
||||||
@@ -400,9 +444,13 @@ async function handleMainLogCleanup(options: {
|
|||||||
let deleted = 0;
|
let deleted = 0;
|
||||||
let freedBytes = 0;
|
let freedBytes = 0;
|
||||||
for (const target of activeTargets) {
|
for (const target of activeTargets) {
|
||||||
const result = cleanDirectory(target.dir);
|
try {
|
||||||
deleted += result.deleted;
|
const result = cleanDirectory(target.dir);
|
||||||
freedBytes += result.freedBytes;
|
deleted += result.deleted;
|
||||||
|
freedBytes += result.freedBytes;
|
||||||
|
} catch (error) {
|
||||||
|
console.log(warn(`Could not clean ${target.label}: ${getErrorMessage(error)}`));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
console.log(ok(`Deleted ${deleted} files, freed ${formatBytes(freedBytes)}`));
|
console.log(ok(`Deleted ${deleted} files, freed ${formatBytes(freedBytes)}`));
|
||||||
|
|
||||||
|
|||||||
@@ -81,4 +81,94 @@ describe('cleanup command', () => {
|
|||||||
fs.rmSync(victimDir, { recursive: true, force: true });
|
fs.rmSync(victimDir, { recursive: true, force: true });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('warns instead of reporting no logs when a cleanup directory cannot be read', async () => {
|
||||||
|
const archiveDir = getLogArchiveDir();
|
||||||
|
fs.mkdirSync(archiveDir, { recursive: true });
|
||||||
|
|
||||||
|
const originalReaddirSync = fs.readdirSync;
|
||||||
|
const readdirSpy = spyOn(fs, 'readdirSync').mockImplementation((dirPath, options) => {
|
||||||
|
if (String(dirPath) === archiveDir) {
|
||||||
|
throw Object.assign(new Error('permission denied'), { code: 'EACCES' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalReaddirSync(dirPath, options as never);
|
||||||
|
});
|
||||||
|
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleCleanupCommand(['--dry-run']);
|
||||||
|
|
||||||
|
const output = logSpy.mock.calls
|
||||||
|
.flatMap((call) => call.map((value) => String(value)))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
expect(output).toContain('Could not read CCS Log Archives');
|
||||||
|
expect(output).toContain('permission denied');
|
||||||
|
expect(output).not.toContain('No CCS or CLIProxy logs found.');
|
||||||
|
} finally {
|
||||||
|
readdirSpy.mockRestore();
|
||||||
|
logSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns instead of reporting no error logs when CLIProxy logs cannot be read', async () => {
|
||||||
|
const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs');
|
||||||
|
fs.mkdirSync(cliproxyLogsDir, { recursive: true });
|
||||||
|
|
||||||
|
const originalReaddirSync = fs.readdirSync;
|
||||||
|
const readdirSpy = spyOn(fs, 'readdirSync').mockImplementation((dirPath, options) => {
|
||||||
|
if (String(dirPath) === cliproxyLogsDir) {
|
||||||
|
throw Object.assign(new Error('disk I/O failed'), { code: 'EIO' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalReaddirSync(dirPath, options as never);
|
||||||
|
});
|
||||||
|
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleCleanupCommand(['--errors']);
|
||||||
|
|
||||||
|
const output = logSpy.mock.calls
|
||||||
|
.flatMap((call) => call.map((value) => String(value)))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
expect(output).toContain('Could not read CLIProxy logs');
|
||||||
|
expect(output).toContain('disk I/O failed');
|
||||||
|
expect(output).not.toContain('No error logs found.');
|
||||||
|
} finally {
|
||||||
|
readdirSpy.mockRestore();
|
||||||
|
logSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it('warns instead of treating CLIProxy log inspection errors as missing directories', async () => {
|
||||||
|
const cliproxyLogsDir = path.join(getCliproxyDir(), 'logs');
|
||||||
|
fs.mkdirSync(cliproxyLogsDir, { recursive: true });
|
||||||
|
|
||||||
|
const originalLstatSync = fs.lstatSync;
|
||||||
|
const lstatSpy = spyOn(fs, 'lstatSync').mockImplementation((targetPath, options) => {
|
||||||
|
if (String(targetPath) === cliproxyLogsDir) {
|
||||||
|
throw Object.assign(new Error('stat failed'), { code: 'EIO' });
|
||||||
|
}
|
||||||
|
|
||||||
|
return originalLstatSync(targetPath, options as never);
|
||||||
|
});
|
||||||
|
const logSpy = spyOn(console, 'log').mockImplementation(() => {});
|
||||||
|
|
||||||
|
try {
|
||||||
|
await handleCleanupCommand(['--errors']);
|
||||||
|
|
||||||
|
const output = logSpy.mock.calls
|
||||||
|
.flatMap((call) => call.map((value) => String(value)))
|
||||||
|
.join('\n');
|
||||||
|
|
||||||
|
expect(output).toContain('Could not inspect CLIProxy logs');
|
||||||
|
expect(output).toContain('stat failed');
|
||||||
|
expect(output).not.toContain('No CLIProxy logs directory found.');
|
||||||
|
} finally {
|
||||||
|
lstatSpy.mockRestore();
|
||||||
|
logSpy.mockRestore();
|
||||||
|
}
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user