Skip to content

fix(build): replace fs.rmSync with recursive directory removal for CI reliability#526

Closed
Copilot wants to merge 3 commits into
mainfrom
copilot/update-action-job-steps
Closed

fix(build): replace fs.rmSync with recursive directory removal for CI reliability#526
Copilot wants to merge 3 commits into
mainfrom
copilot/update-action-job-steps

Conversation

Copy link
Copy Markdown
Contributor

Copilot AI commented Feb 5, 2026

CI builds fail with ENOTEMPTY errors when fs.rmSync() attempts to clean the json-schema directory. The native retry options (maxRetries, retryDelay) are insufficient for filesystem timing issues in CI environments.

Changes

Replaced bulk fs.rmSync() with manual depth-first traversal:

function removeDirRecursive(dirPath: string, retries = MAX_RETRIES): void {
  // Walk directory tree
  const entries = fs.readdirSync(dirPath, { withFileTypes: true });
  
  for (const entry of entries) {
    if (entry.isDirectory()) {
      removeDirRecursive(fullPath, retries);  // Recurse with error context
    } else {
      // Per-file retry with exponential backoff
      fs.unlinkSync(fullPath);
    }
  }
  
  fs.rmdirSync(dirPath);  // Remove now-empty directory
}

Key differences:

  • Retry logic at file level instead of bulk operation
  • Explicit error context for failed subdirectories
  • Handles filesystem sync delays that affect nested directory removal
Original prompt

引用: https://github.com/objectstack-ai/spec/actions/runs/21710218564/job/62611604796#step:8:1


💡 You can make Copilot smarter by setting up custom instructions, customizing its development environment and configuring Model Context Protocol (MCP) servers. Learn more Copilot coding agent tips in the docs.

@vercel
Copy link
Copy Markdown

vercel Bot commented Feb 5, 2026

The latest updates on your projects. Learn more about Vercel for GitHub.

Project Deployment Actions Updated (UTC)
spec Ready Ready Preview, Comment Feb 5, 2026 11:58am

Request Review

Copilot AI and others added 2 commits February 5, 2026 11:51
The fs.rmSync() call was failing in CI with ENOTEMPTY errors when trying to clean the json-schema directory. This implements a more robust removeDirRecursive() function that:
- Manually walks the directory tree
- Removes files and subdirectories with retry logic
- Handles ENOTEMPTY errors more reliably in CI environments
- Uses exponential backoff for retries

Tested locally with multiple consecutive builds to verify directory cleaning works correctly.

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
- Add comprehensive JSDoc with @param and @throws
- Add error context for failing subdirectories
- Keep fs.rmdirSync for directory removal (most reliable approach)

Co-authored-by: hotlong <50353452+hotlong@users.noreply.github.com>
Copilot AI changed the title [WIP] Update action job steps for improved execution fix(build): replace fs.rmSync with recursive directory removal for CI reliability Feb 5, 2026
Copilot AI requested a review from hotlong February 5, 2026 11:57
@hotlong hotlong marked this pull request as ready for review February 5, 2026 12:00
Copilot AI review requested due to automatic review settings February 5, 2026 12:00
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

This PR addresses CI build failures caused by ENOTEMPTY errors when using fs.rmSync() to clean the json-schema directory. The solution replaces the bulk directory removal with a custom depth-first traversal implementation that provides per-file retry logic with exponential backoff.

Changes:

  • Replaced fs.rmSync() with a new removeDirRecursive() function that manually traverses directory trees
  • Implemented file-level retry logic with exponential backoff to handle filesystem timing issues
  • Added error context for failed subdirectory removals

if (entry.isDirectory()) {
// Recursively remove subdirectory
try {
removeDirRecursive(fullPath, retries);
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The recursive call passes the same retries parameter to subdirectories, which means the outer retry loop (line 86) can retry the entire directory removal multiple times, and each subdirectory removal will also retry multiple times. This creates a multiplicative retry effect (e.g., with MAX_RETRIES=3, a nested directory could see up to 333=27 attempts for deeply nested structures). This can significantly increase build times in CI.

Consider passing retries: 1 to recursive calls or removing the outer retry loop, since the inner operations already have retry logic.

Suggested change
removeDirRecursive(fullPath, retries);
removeDirRecursive(fullPath, 1);

Copilot uses AI. Check for mistakes.
Comment on lines +99 to +119
if (entry.isDirectory()) {
// Recursively remove subdirectory
try {
removeDirRecursive(fullPath, retries);
} catch (error) {
throw new Error(`Failed to remove subdirectory ${fullPath}: ${error}`);
}
} else {
// Remove file with retry
for (let fileRetry = 0; fileRetry < retries; fileRetry++) {
try {
fs.unlinkSync(fullPath);
break;
} catch (error) {
if (fileRetry === retries - 1) {
throw error;
}
sleepSync(RETRY_DELAY_BASE_MS * (fileRetry + 1));
}
}
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The function doesn't handle symbolic links, which could cause issues if the json-schema directory contains symlinks. Attempting to recurse into a symlink to a directory or remove a symlink as a file could fail. The original fs.rmSync handles symlinks correctly.

Add a check for symlinks using entry.isSymbolicLink() and handle them appropriately (e.g., using fs.unlinkSync() regardless of the target type).

Copilot uses AI. Check for mistakes.
try {
removeDirRecursive(fullPath, retries);
} catch (error) {
throw new Error(`Failed to remove subdirectory ${fullPath}: ${error}`);
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Wrapping the subdirectory removal error in a new Error object loses the original error's stack trace and type information. This makes debugging CI failures more difficult, which contradicts the PR's goal of improving reliability.

Instead of wrapping, re-throw the original error or use error cause chaining (available in Node.js 16.9.0+): throw new Error(Failed to remove subdirectory ${fullPath}, { cause: error }).

Suggested change
throw new Error(`Failed to remove subdirectory ${fullPath}: ${error}`);
throw new Error(`Failed to remove subdirectory ${fullPath}`, { cause: error });

Copilot uses AI. Check for mistakes.
Comment on lines +86 to +135
for (let attempt = 0; attempt < retries; attempt++) {
try {
if (!fs.existsSync(dirPath)) {
return; // Already removed
}

// Read all entries in the directory
const entries = fs.readdirSync(dirPath, { withFileTypes: true });

// Process each entry
for (const entry of entries) {
const fullPath = path.join(dirPath, entry.name);

if (entry.isDirectory()) {
// Recursively remove subdirectory
try {
removeDirRecursive(fullPath, retries);
} catch (error) {
throw new Error(`Failed to remove subdirectory ${fullPath}: ${error}`);
}
} else {
// Remove file with retry
for (let fileRetry = 0; fileRetry < retries; fileRetry++) {
try {
fs.unlinkSync(fullPath);
break;
} catch (error) {
if (fileRetry === retries - 1) {
throw error;
}
sleepSync(RETRY_DELAY_BASE_MS * (fileRetry + 1));
}
}
}
}

// Now remove the empty directory
// Using rmdirSync for removing empty directories (works reliably across Node versions)
fs.rmdirSync(dirPath);
return; // Success

} catch (error) {
if (attempt === retries - 1) {
throw new Error(`Failed to remove directory ${dirPath} after ${retries} attempts: ${error}`);
}
// Wait before retrying with exponential backoff
const delay = RETRY_DELAY_BASE_MS * (attempt + 1);
sleepSync(delay);
}
}
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The outer try-catch block's retry logic (lines 86-135) may not work as intended because once the directory traversal begins (line 93), any error thrown from within the loop (lines 96-120) will be caught by the outer catch block. However, if the error occurred partway through processing entries, retrying from the beginning could encounter already-deleted files or directories, potentially causing new errors.

Consider whether the outer retry loop adds value given that individual file operations already have retry logic. If ENOTEMPTY errors occur at the rmdirSync call (line 124), the outer retry would help, but the current error handling doesn't distinguish between different failure points.

Copilot uses AI. Check for mistakes.
Comment on lines +100 to +104
// Recursively remove subdirectory
try {
removeDirRecursive(fullPath, retries);
} catch (error) {
throw new Error(`Failed to remove subdirectory ${fullPath}: ${error}`);
Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The nested try-catch for subdirectory removal (lines 101-105) will catch errors and re-throw them, bypassing the file-level retry logic. This means if a subdirectory removal fails on the first attempt, it won't be retried by the outer loop at line 86 because the error is thrown immediately, preventing the loop from continuing to process remaining entries.

This inconsistency means subdirectories get different error handling than the main directory, potentially making the function less reliable for the nested structures it's meant to handle.

Suggested change
// Recursively remove subdirectory
try {
removeDirRecursive(fullPath, retries);
} catch (error) {
throw new Error(`Failed to remove subdirectory ${fullPath}: ${error}`);
// Recursively remove subdirectory with retry
for (let dirRetry = 0; dirRetry < retries; dirRetry++) {
try {
removeDirRecursive(fullPath, retries);
break;
} catch (error) {
if (dirRetry === retries - 1) {
throw error;
}
sleepSync(RETRY_DELAY_BASE_MS * (dirRetry + 1));
}

Copilot uses AI. Check for mistakes.
Comment on lines +122 to +126
// Now remove the empty directory
// Using rmdirSync for removing empty directories (works reliably across Node versions)
fs.rmdirSync(dirPath);
return; // Success

Copy link

Copilot AI Feb 5, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

fs.rmdirSync() has been deprecated since Node.js 14.14.0 in favor of fs.rmSync() with the recursive option (which is being replaced in this PR). While it still works in Node 18+ (per the package.json engines requirement), using deprecated APIs is a best practice violation.

Consider using fs.rmSync(dirPath) instead of fs.rmdirSync(dirPath) for removing the empty directory, as it's the modern, non-deprecated alternative.

Suggested change
// Now remove the empty directory
// Using rmdirSync for removing empty directories (works reliably across Node versions)
fs.rmdirSync(dirPath);
return; // Success
// Now remove the empty directory using modern API (non-deprecated)
fs.rmSync(dirPath, { recursive: false, force: false });
return; // Success

Copilot uses AI. Check for mistakes.
@hotlong hotlong closed this Feb 5, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants