Skip to content

fix: handle race conditions in collection-watcher during collection deletion#7484

Open
chirag-bruno wants to merge 2 commits intousebruno:mainfrom
chirag-bruno:fix/collection-watcher-unlink-race-condition
Open

fix: handle race conditions in collection-watcher during collection deletion#7484
chirag-bruno wants to merge 2 commits intousebruno:mainfrom
chirag-bruno:fix/collection-watcher-unlink-race-condition

Conversation

@chirag-bruno
Copy link
Collaborator

@chirag-bruno chirag-bruno commented Mar 13, 2026

Description

When deleting a collection, the config file (bruno.json/opencollection.yml) may be deleted before other files, causing getCollectionFormat to throw and crash the app.

Changes:

  • Add format caching in getCollectionFormat to preserve format after config deletion
  • Wrap all getCollectionFormat calls in try-catch blocks throughout collection-watcher
  • Add defensive error handling to isFolderRootFile, addDirectory, add, change, unlink, and unlinkDir handlers
  • Add clearCollectionFormatCache to clean up when collection root is deleted
  • Add Playwright tests to verify race condition handling

Contribution Checklist:

  • I've used AI significantly to create this pull request
  • The pull request only addresses one issue or adds one feature.
  • The pull request does not introduce any breaking changes
  • I have added screenshots or gifs to help explain the change if applicable.
  • I have read the contribution guidelines.
  • Create an issue and link to the pull request.

Note: Keeping the PR small and focused helps make it easier to review and merge. If you have multiple changes you want to make, please consider submitting them as separate pull requests.

Publishing to New Package Managers

Please see here for more information.

Summary by CodeRabbit

  • Bug Fixes

    • Improved stability when collection configuration files or folders are missing or deleted, preventing crashes and ensuring consistent UI/state updates.
    • Faster and more reliable recognition of collection format changes so removed or updated collections are handled promptly without errors.
  • Tests

    • Added end-to-end tests covering collection config/folder deletion to verify the app remains responsive during concurrent filesystem changes.

…eletion

When deleting a collection, the config file (bruno.json/opencollection.yml)
may be deleted before other files, causing getCollectionFormat to throw
and crash the app.

Changes:
- Add format caching in getCollectionFormat to preserve format after config deletion
- Wrap all getCollectionFormat calls in try-catch blocks throughout collection-watcher
- Add defensive error handling to isFolderRootFile, addDirectory, add, change,
  unlink, and unlinkDir handlers
- Add clearCollectionFormatCache to clean up when collection root is deleted
- Add Playwright tests to verify race condition handling
@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 13, 2026

Walkthrough

Adds defensive error handling around collection watcher file events, introduces collection-format cache invalidation, and adds Playwright tests to ensure the app remains responsive when collection files or folders are deleted. (46 words)

Changes

Cohort / File(s) Summary
Collection Watcher
packages/bruno-electron/src/app/collection-watcher.js
Wraps calls to getCollectionFormat and main event handlers (add, addDirectory, change, unlink, unlinkDir) in try/catch blocks, adds standardized error logging, ensures early returns for synchronous branches, and moves root-file handling into guarded scopes.
Filesystem Cache Management
packages/bruno-electron/src/utils/filesystem.js
Adds clearCollectionFormatCache(collectionPath) and caches normalized results inside getCollectionFormat; exports the new cache-clear helper for external invalidation.
End-to-end Tests
tests/collection/delete-race-condition/delete-race-condition.spec.ts
New Playwright suite that simulates deletion of opencollection.yml and deletion of the full collection folder to assert the UI/watcher remain responsive and interactive.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Suggested reviewers

  • helloanoop
  • lohit-bruno
  • naman-bruno
  • bijin-bruno

Poem

Files disappear, the watcher wakes,
Cache is cleared for safety's sake,
Try/catch sings a steady tune,
Tests dance under a careful moon,
No crash tonight — the app stays awake. ✨

🚥 Pre-merge checks | ✅ 3
✅ Passed checks (3 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title accurately and specifically describes the main change: handling race conditions in collection-watcher during collection deletion, which directly aligns with all the file changes and the PR objectives.
Docstring Coverage ✅ Passed No functions found in the changed files to evaluate docstring coverage. Skipping docstring coverage check.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment
📝 Coding Plan
  • Generate coding plan for human review comments

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 2

🧹 Nitpick comments (2)
tests/collection/delete-race-condition/delete-race-condition.spec.ts (2)

24-41: Consider replacing waitForTimeout with a more deterministic wait.

The page.waitForTimeout(1000) at line 30 relies on timing assumptions. Per coding guidelines, this should be avoided unless absolutely necessary. If there's an observable state change (e.g., collection disappears, error indicator appears), waiting for that would be more reliable.

If no UI indicator exists for this scenario, consider adding a brief comment explaining why the timeout is necessary.

Suggested improvement
     await fs.promises.unlink(openCollectionYmlPath);

-    // Wait for watcher to process the deletion
-    await page.waitForTimeout(1000);
+    // Wait for watcher to process the deletion
+    // Note: No observable UI change occurs when config file is deleted,
+    // so a timeout is necessary to allow watcher processing
+    await page.waitForTimeout(1000);
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/collection/delete-race-condition/delete-race-condition.spec.ts` around
lines 24 - 41, Replace the brittle page.waitForTimeout(1000) with a
deterministic wait for an observable UI/state change: after unlinking
openCollectionYmlPath, wait for a specific indicator that the watcher processed
the deletion (for example use Playwright's expect or page.waitForSelector to
assert a collection row is gone, a "no collections" message appears, or a
watcher error/toast becomes visible) instead of timing, referencing the test
'Should handle collection config file deletion without crashing' and the current
use of page.waitForTimeout; if no UI change exists, add a brief comment next to
the unlink and page.waitForTimeout explaining why a timeout is unavoidable.

6-60: Add test.step for better report readability.

Per coding guidelines, test.step should be used to improve generated report clarity. Consider wrapping logical sections of each test.

Example refactor for first test
   test('Should handle collection config file deletion without crashing', async ({ page }) => {
-    const openCollectionYmlPath = path.join(fullCollectionPath, 'opencollection.yml');
-
-    await fs.promises.unlink(openCollectionYmlPath);
-
-    // Wait for watcher to process the deletion
-    await page.waitForTimeout(1000);
-
-    // The app should still be responsive even after the config file is deleted
-    await expect(page.getByTestId('collections-header-add-menu')).toBeVisible();
-    await page.getByTestId('collections-header-add-menu').click();
-
-    // Verify dropdown appears (app is responsive)
-    await expect(page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' })).toBeVisible();
-
-    // Close dropdown
-    await page.keyboard.press('Escape');
+    await test.step('Delete collection config file', async () => {
+      const openCollectionYmlPath = path.join(fullCollectionPath, 'opencollection.yml');
+      await fs.promises.unlink(openCollectionYmlPath);
+      await page.waitForTimeout(1000);
+    });
+
+    await test.step('Verify app remains responsive', async () => {
+      await expect(page.getByTestId('collections-header-add-menu')).toBeVisible();
+      await page.getByTestId('collections-header-add-menu').click();
+      await expect(page.locator('.tippy-box .dropdown-item').filter({ hasText: 'Create collection' })).toBeVisible();
+      await page.keyboard.press('Escape');
+    });
   });
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@tests/collection/delete-race-condition/delete-race-condition.spec.ts` around
lines 6 - 60, Wrap logical actions in each test with Playwright's test.step to
improve report readability: inside the test titled "Should handle collection
config file deletion without crashing" wrap the filesystem deletion (unlink
openCollectionYmlPath) and the subsequent wait/assert/click checks into
descriptive test.step blocks (e.g., "Delete config file" and "Verify UI remains
responsive"), and likewise in the test titled "Should handle collection folder
deletion without crashing" wrap the rm call and the wait/assert/click sequence
into descriptive test.step blocks (e.g., "Delete collection folder" and "Verify
UI remains responsive"). Ensure you call test.step around the async operations
so the steps await correctly and keep existing assertions and waits intact.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@packages/bruno-electron/src/app/collection-watcher.js`:
- Around line 569-598: The unlink handler in function unlink should invalidate
the cached collection format when the collection config file itself is deleted
(e.g., basename 'opencollection.yml' or 'bruno.json' in the collection root) so
subsequent calls to getCollectionFormat don't return stale data; update unlink
(near getCollectionFormat and the early-return check for opencollection.yml) to
call a cache-clear function (e.g., clearCollectionFormatCache(collectionPath) or
invalidateCollectionFormat(collectionPath)) when you detect the config filename
in the collection root (before returning) and ensure that same invalidation
occurs for 'bruno.json' deletions as well.

In `@packages/bruno-electron/src/utils/filesystem.js`:
- Around line 238-258: The getCollectionFormat function normalizes
collectionPath for the cache but still constructs ocYmlPath and brunoJsonPath
from the raw collectionPath, causing inconsistency; update the function to use
normalizedPath when joining filenames (i.e., build ocYmlPath and brunoJsonPath
from normalizedPath) so fs.existsSync checks and cache keys use the same
canonical path (references: getCollectionFormat, collectionFormatCache,
ocYmlPath, brunoJsonPath).

---

Nitpick comments:
In `@tests/collection/delete-race-condition/delete-race-condition.spec.ts`:
- Around line 24-41: Replace the brittle page.waitForTimeout(1000) with a
deterministic wait for an observable UI/state change: after unlinking
openCollectionYmlPath, wait for a specific indicator that the watcher processed
the deletion (for example use Playwright's expect or page.waitForSelector to
assert a collection row is gone, a "no collections" message appears, or a
watcher error/toast becomes visible) instead of timing, referencing the test
'Should handle collection config file deletion without crashing' and the current
use of page.waitForTimeout; if no UI change exists, add a brief comment next to
the unlink and page.waitForTimeout explaining why a timeout is unavoidable.
- Around line 6-60: Wrap logical actions in each test with Playwright's
test.step to improve report readability: inside the test titled "Should handle
collection config file deletion without crashing" wrap the filesystem deletion
(unlink openCollectionYmlPath) and the subsequent wait/assert/click checks into
descriptive test.step blocks (e.g., "Delete config file" and "Verify UI remains
responsive"), and likewise in the test titled "Should handle collection folder
deletion without crashing" wrap the rm call and the wait/assert/click sequence
into descriptive test.step blocks (e.g., "Delete collection folder" and "Verify
UI remains responsive"). Ensure you call test.step around the async operations
so the steps await correctly and keep existing assertions and waits intact.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 021de5cf-0162-4d92-9997-90d1031246b5

📥 Commits

Reviewing files that changed from the base of the PR and between 1ab296f and c39915a.

📒 Files selected for processing (3)
  • packages/bruno-electron/src/app/collection-watcher.js
  • packages/bruno-electron/src/utils/filesystem.js
  • tests/collection/delete-race-condition/delete-race-condition.spec.ts

Comment on lines 569 to 598
const unlink = (win, pathname, collectionUid, collectionPath) => {
try {
if (!fs.existsSync(collectionPath)) {
return;
}
console.log(`watcher unlink: ${pathname}`);
console.log(`watcher unlink: ${pathname}`);

try {
if (isEnvironmentsFolder(pathname, collectionPath)) {
return unlinkEnvironmentFile(win, pathname, collectionUid);
}

let format;
try {
format = getCollectionFormat(collectionPath);
} catch (error) {
console.error(`Error getting collection format for: ${collectionPath}`, error);
return;
}
const format = getCollectionFormat(collectionPath);
if (hasRequestExtension(pathname, format)) {
const basename = path.basename(pathname);
const dirname = path.dirname(pathname);

if (basename === 'opencollection.yml' && path.normalize(dirname) === path.normalize(collectionPath)) {
return;
}

const file = {
meta: {
collectionUid,
pathname,
name: basename
}
};
win.webContents.send('main:collection-tree-updated', 'unlink', file);
}
} catch (err) {
console.error(`Error processing unlink event for: ${pathname}`, err);
} catch (error) {
console.error(`Error in unlink handler for ${pathname}:`, error);
}
};
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

Missing cache invalidation when config file is unlinked.

When opencollection.yml or bruno.json is deleted via unlink (not unlinkDir), getCollectionFormat at line 577 returns the cached value, but the cache is never cleared. Subsequent calls from other handlers (e.g., loadBrunoConfig in openapi-sync.js, IPC handlers in collection.js) will use stale cached format and attempt to read a non-existent file.

Consider clearing the cache when the config file itself is deleted.

Proposed fix
 const unlink = (win, pathname, collectionUid, collectionPath) => {
   console.log(`watcher unlink: ${pathname}`);

   try {
     if (isEnvironmentsFolder(pathname, collectionPath)) {
       return unlinkEnvironmentFile(win, pathname, collectionUid);
     }

+    const basename = path.basename(pathname);
+    const dirname = path.dirname(pathname);
+
+    // Clear cache if config file is deleted
+    if (path.normalize(dirname) === path.normalize(collectionPath)) {
+      if (basename === 'opencollection.yml' || basename === 'bruno.json') {
+        clearCollectionFormatCache(collectionPath);
+        return;
+      }
+    }
+
     const format = getCollectionFormat(collectionPath);
     if (hasRequestExtension(pathname, format)) {
-      const basename = path.basename(pathname);
-      const dirname = path.dirname(pathname);
-
-      if (basename === 'opencollection.yml' && path.normalize(dirname) === path.normalize(collectionPath)) {
-        return;
-      }
-
       const file = {
         meta: {
           collectionUid,
           pathname,
           name: basename
         }
       };
       win.webContents.send('main:collection-tree-updated', 'unlink', file);
     }
   } catch (error) {
     console.error(`Error in unlink handler for ${pathname}:`, error);
   }
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bruno-electron/src/app/collection-watcher.js` around lines 569 -
598, The unlink handler in function unlink should invalidate the cached
collection format when the collection config file itself is deleted (e.g.,
basename 'opencollection.yml' or 'bruno.json' in the collection root) so
subsequent calls to getCollectionFormat don't return stale data; update unlink
(near getCollectionFormat and the early-return check for opencollection.yml) to
call a cache-clear function (e.g., clearCollectionFormatCache(collectionPath) or
invalidateCollectionFormat(collectionPath)) when you detect the config filename
in the collection root (before returning) and ensure that same invalidation
occurs for 'bruno.json' deletions as well.

Comment on lines 238 to 258
const getCollectionFormat = (collectionPath) => {
const normalizedPath = path.normalize(collectionPath);

if (collectionFormatCache.has(normalizedPath)) {
return collectionFormatCache.get(normalizedPath);
}

const ocYmlPath = path.join(collectionPath, 'opencollection.yml');
if (fs.existsSync(ocYmlPath)) {
collectionFormatCache.set(normalizedPath, 'yml');
return 'yml';
}

const brunoJsonPath = path.join(collectionPath, 'bruno.json');
if (fs.existsSync(brunoJsonPath)) {
collectionFormatCache.set(normalizedPath, 'bru');
return 'bru';
}

throw new Error(`No collection configuration found at: ${collectionPath}`);
};
Copy link
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟡 Minor

Path normalization inconsistency in getCollectionFormat.

The function normalizes collectionPath for cache operations (lines 239, 241-242, 247, 253) but uses the raw collectionPath for fs.existsSync checks (lines 245, 251-252). If input paths vary in format (e.g., trailing slashes, mixed separators), this could cause cache misses or incorrect lookups.

Proposed fix
 const getCollectionFormat = (collectionPath) => {
   const normalizedPath = path.normalize(collectionPath);

   if (collectionFormatCache.has(normalizedPath)) {
     return collectionFormatCache.get(normalizedPath);
   }

-  const ocYmlPath = path.join(collectionPath, 'opencollection.yml');
+  const ocYmlPath = path.join(normalizedPath, 'opencollection.yml');
   if (fs.existsSync(ocYmlPath)) {
     collectionFormatCache.set(normalizedPath, 'yml');
     return 'yml';
   }

-  const brunoJsonPath = path.join(collectionPath, 'bruno.json');
+  const brunoJsonPath = path.join(normalizedPath, 'bruno.json');
   if (fs.existsSync(brunoJsonPath)) {
     collectionFormatCache.set(normalizedPath, 'bru');
     return 'bru';
   }

-  throw new Error(`No collection configuration found at: ${collectionPath}`);
+  throw new Error(`No collection configuration found at: ${normalizedPath}`);
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bruno-electron/src/utils/filesystem.js` around lines 238 - 258, The
getCollectionFormat function normalizes collectionPath for the cache but still
constructs ocYmlPath and brunoJsonPath from the raw collectionPath, causing
inconsistency; update the function to use normalizedPath when joining filenames
(i.e., build ocYmlPath and brunoJsonPath from normalizedPath) so fs.existsSync
checks and cache keys use the same canonical path (references:
getCollectionFormat, collectionFormatCache, ocYmlPath, brunoJsonPath).

Copy link
Contributor

@coderabbitai coderabbitai bot left a comment

Choose a reason for hiding this comment

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

♻️ Duplicate comments (1)
packages/bruno-electron/src/app/collection-watcher.js (1)

572-585: ⚠️ Potential issue | 🟠 Major

Invalidate format cache when root config files are deleted in unlink.

At Line 583, opencollection.yml unlinks return early without clearing cache, and bruno.json unlinks are not handled. This leaves stale getCollectionFormat() state until (or unless) root directory deletion happens.

Proposed fix
 const unlink = (win, pathname, collectionUid, collectionPath) => {
   console.log(`watcher unlink: ${pathname}`);

   try {
     if (isEnvironmentsFolder(pathname, collectionPath)) {
       return unlinkEnvironmentFile(win, pathname, collectionUid);
     }
-    console.log(`watcher unlink: ${pathname}`);
+    const basename = path.basename(pathname);
+    const dirname = path.dirname(pathname);
+    const isCollectionRootPath = path.normalize(dirname) === path.normalize(collectionPath);
+
+    if (isCollectionRootPath && (basename === 'opencollection.yml' || basename === 'bruno.json')) {
+      clearCollectionFormatCache(collectionPath);
+      return;
+    }

     const format = getCollectionFormat(collectionPath);
     if (hasRequestExtension(pathname, format)) {
-      const basename = path.basename(pathname);
-      const dirname = path.dirname(pathname);
-
-      if (basename === 'opencollection.yml' && path.normalize(dirname) === path.normalize(collectionPath)) {
-        return;
-      }
-
       const file = {
         meta: {
           collectionUid,
           pathname,
           name: basename
         }
       };
       win.webContents.send('main:collection-tree-updated', 'unlink', file);
     }
   } catch (error) {
     console.error(`Error in unlink handler for ${pathname}:`, error);
   }
 };
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bruno-electron/src/app/collection-watcher.js` around lines 572 -
585, The unlink handler returns early for root config files (e.g.,
opencollection.yml) and doesn't invalidate the collection format cache, and it
also doesn't handle bruno.json deletions; update the unlink branch in
collection-watcher.js (the block using isEnvironmentsFolder,
unlinkEnvironmentFile, getCollectionFormat, hasRequestExtension) so that when a
root config file is unlinked (basename === 'opencollection.yml' at the
collection root or basename === 'bruno.json' at the collection root) you
explicitly invalidate the cached format for that collection (e.g., call a new or
existing cache invalidation helper like
clearCollectionFormatCache(collectionPath) or remove the entry used by
getCollectionFormat) before returning; ensure both opencollection.yml and
bruno.json cases are covered.
🧹 Nitpick comments (1)
packages/bruno-electron/src/app/collection-watcher.js (1)

570-577: Remove duplicate unlink logging.

Line 570 and Line 576 log the same message; keeping one avoids noisy watcher logs.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@packages/bruno-electron/src/app/collection-watcher.js` around lines 570 -
577, The watcher currently logs the same "watcher unlink: ${pathname}" message
twice; remove the duplicate console.log so only one log remains before the
conditional. In the collection-watcher.js block around the console.log, keep the
initial console.log(`watcher unlink: ${pathname}`) (or the later one if
preferred) and delete the second identical call immediately after the
isEnvironmentsFolder/unlinkEnvironmentFile check to avoid noisy duplicate logs
while preserving the isEnvironmentsFolder(pathname, collectionPath) and
unlinkEnvironmentFile(win, pathname, collectionUid) behavior.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Duplicate comments:
In `@packages/bruno-electron/src/app/collection-watcher.js`:
- Around line 572-585: The unlink handler returns early for root config files
(e.g., opencollection.yml) and doesn't invalidate the collection format cache,
and it also doesn't handle bruno.json deletions; update the unlink branch in
collection-watcher.js (the block using isEnvironmentsFolder,
unlinkEnvironmentFile, getCollectionFormat, hasRequestExtension) so that when a
root config file is unlinked (basename === 'opencollection.yml' at the
collection root or basename === 'bruno.json' at the collection root) you
explicitly invalidate the cached format for that collection (e.g., call a new or
existing cache invalidation helper like
clearCollectionFormatCache(collectionPath) or remove the entry used by
getCollectionFormat) before returning; ensure both opencollection.yml and
bruno.json cases are covered.

---

Nitpick comments:
In `@packages/bruno-electron/src/app/collection-watcher.js`:
- Around line 570-577: The watcher currently logs the same "watcher unlink:
${pathname}" message twice; remove the duplicate console.log so only one log
remains before the conditional. In the collection-watcher.js block around the
console.log, keep the initial console.log(`watcher unlink: ${pathname}`) (or the
later one if preferred) and delete the second identical call immediately after
the isEnvironmentsFolder/unlinkEnvironmentFile check to avoid noisy duplicate
logs while preserving the isEnvironmentsFolder(pathname, collectionPath) and
unlinkEnvironmentFile(win, pathname, collectionUid) behavior.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 1b936f1c-31bb-4d06-a61f-2ad05b434f20

📥 Commits

Reviewing files that changed from the base of the PR and between c39915a and 05cb8bb.

📒 Files selected for processing (1)
  • packages/bruno-electron/src/app/collection-watcher.js

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants