Skip to content

Fix incomplete plugin directory deletion on uninstall#5

Merged
Jack251970 merged 4 commits intodevfrom
copilot/fix-plugin-directory-deletion
Feb 4, 2026
Merged

Fix incomplete plugin directory deletion on uninstall#5
Jack251970 merged 4 commits intodevfrom
copilot/fix-plugin-directory-deletion

Conversation

Copy link

Copilot AI commented Feb 4, 2026

Plugin uninstallation left empty directories with orphaned subdirectories when files were locked by processes or had permission issues. Directory.Delete(directory, true) fails atomically—if any file is locked, nothing gets deleted.

Changes

New deletion strategy (FilesFolders.TryDeleteDirectoryRobust)

  • Deletes files individually with retry logic (3 attempts, 100ms delay)
  • Removes read-only attributes before deletion
  • Cleans empty directories bottom-up after files removed
  • Returns bool indicating complete vs. partial deletion

Updated plugin cleanup (PluginConfig.cs)

  • Replaced atomic deletion with robust incremental approach
  • Logs warnings when directories partially deleted
  • Recreates NeedDelete.txt marker on incomplete deletion - ensures automatic retry on next startup for eventual consistency

Test coverage (FilesFoldersTest.cs)

  • Empty/nested directories, read-only files, non-existent paths

Example

Before:

Directory.Delete(directory, true);  // Fails completely if alarm.wav is locked

After:

var fullyDeleted = FilesFolders.TryDeleteDirectoryRobust(directory, maxRetries: 3, retryDelayMs: 100);
if (!fullyDeleted) {
    // Recreate marker file to retry on next startup
    File.WriteAllText(Path.Combine(directory, DataLocation.PluginDeleteFile), string.Empty);
}
// Deletes everything possible, retries locked files, automatically retries on next startup

Handles both reported errors:

  • IOException: The process cannot access the file 'alarm.wav'
  • UnauthorizedAccessException: Access to the path '...\Images' is denied

If files remain locked after retry attempts, the deletion marker is preserved and cleanup is automatically retried on subsequent application restarts until successful.

Original prompt

This section details on the original issue you should resolve

<issue_title>BUG: Plugin directory is not deleted fully</issue_title>
<issue_description>### Checks

  • I have checked that this issue has not already been reported.

  • I am using the latest version of Flow Launcher.

  • I am using the prerelease version of Flow Launcher.

Problem Description

When Restart after modifying plugin via Plugin Store option is enabled, if I uninstall one plugin and Flow restart automatically.

Sometimes, if I check the AppDATA/Plugins directory, the plugin directory will not be deleted but all files in it is deleted. In the plugin folder, there might be some empty folders (such as Languages), but all the files have been deleted.

In log files, it gives me two kinds of exception: System.IO.IOException: The process cannot access the file 'alarm.wav' because it is being used by another process. or Access to the path '\\?\C:\Users\11602\AppData\Roaming\FlowLauncher\Plugins\Audio Device Selector-1.0.3\Images' is denied.

To Reproduce

.

Screenshots

.

Flow Launcher Version

.

Windows Build Number

.

Error Log

Details
14:34:00.9400+08:00 - ERROR - PluginConfig.Parse - Can't delete <C:\Users\11602\AppData\Roaming\FlowLauncher\Plugins\Timer-1.0.0>
EXCEPTION OCCURS: System.IO.IOException: The process cannot access the file 'alarm.wav' because it is being used by another process.
   at void System.IO.FileSystem.RemoveDirectoryRecursive(string fullPath, ref WIN32_FIND_DATA findData, bool topLevel)
   at void System.IO.FileSystem.RemoveDirectory(string fullPath, bool recursive)
   at List<PluginMetadata> Flow.Launcher.Core.Plugin.PluginConfig.Parse(string[] pluginDirectories) in C:/projects/flow-launcher/Flow.Launcher.Core/Plugin/PluginConfig.cs:line 37
16:26:29.9124+08:00 - ERROR - PluginConfig.Parse - Can't delete <C:\Users\11602\AppData\Roaming\FlowLauncher\Plugins\Audio Device Selector-1.0.3>
EXCEPTION OCCURS: System.IO.IOException: Access to the path '\\?\C:\Users\11602\AppData\Roaming\FlowLauncher\Plugins\Audio Device Selector-1.0.3\Images' is denied.
   at void System.IO.FileSystem.RemoveDirectoryRecursive(string fullPath, ref WIN32_FIND_DATA findData, bool topLevel)
   at void System.IO.FileSystem.RemoveDirectory(string fullPath, bool recursive)
   at List<PluginMetadata> Flow.Launcher.Core.Plugin.PluginConfig.Parse(string[] pluginDirectories) in C:/projects/flow-launcher/Flow.Launcher.Core/Plugin/PluginConfig.cs:line 33

</issue_description>

Comments on the Issue (you are @copilot in this section)


💡 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.

Co-authored-by: Jack251970 <53996452+Jack251970@users.noreply.github.com>
Copilot AI changed the title [WIP] Fix issue with incomplete plugin directory deletion Fix incomplete plugin directory deletion on uninstall Feb 4, 2026
Copilot AI requested a review from Jack251970 February 4, 2026 04:25
@Jack251970
Copy link
Owner

@copilot I think we can do it better. If this directory is not fully deleted, I think we can add back delete file DataLocation.PluginDeleteFile. So in the next time, it will be checked again

Co-authored-by: Jack251970 <53996452+Jack251970@users.noreply.github.com>
Copy link
Author

Copilot AI commented Feb 4, 2026

@copilot I think we can do it better. If this directory is not fully deleted, I think we can add back delete file DataLocation.PluginDeleteFile. So in the next time, it will be checked again

Good suggestion! Implemented in commit f3f9d62. Now when the directory is not fully deleted, the NeedDelete.txt marker file is recreated, ensuring the deletion will be automatically retried on the next startup until all files can be removed.

Refactored the process for recreating the marker file when a plugin directory is not fully deleted. Removed unnecessary try-catch and nested checks, now directly checking for file existence and creating it if missing. This streamlines the code and removes redundant error handling.
@Jack251970 Jack251970 marked this pull request as ready for review February 4, 2026 04:52
Copilot AI review requested due to automatic review settings February 4, 2026 04:52
@Jack251970 Jack251970 merged commit e74285c into dev Feb 4, 2026
3 checks passed
Copy link

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 pull request addresses incomplete plugin directory deletion during uninstallation when files are locked by processes or have permission issues. The previous atomic deletion approach (Directory.Delete(directory, true)) would fail completely if any file was locked, leaving the plugin directory partially intact with empty subdirectories.

Changes:

  • Introduced a new TryDeleteDirectoryRobust method with individual file deletion, retry logic, and incremental cleanup
  • Updated plugin cleanup logic to use the robust deletion method and recreate the deletion marker on incomplete deletions
  • Added comprehensive test coverage for the new deletion method

Reviewed changes

Copilot reviewed 3 out of 3 changed files in this pull request and generated 2 comments.

File Description
Flow.Launcher.Plugin/SharedCommands/FilesFolders.cs Implements TryDeleteDirectoryRobust with retry logic for locked files, read-only attribute handling, and bottom-up directory cleanup
Flow.Launcher.Core/Plugin/PluginConfig.cs Integrates the robust deletion method and recreates the deletion marker when cleanup is incomplete to enable automatic retry on next startup
Flow.Launcher.Test/FilesFoldersTest.cs Adds unit tests for various deletion scenarios including empty directories, nested structures, and read-only files

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

foreach (var file in files)
{
bool fileDeleted = false;
for (int attempt = 0; attempt <= maxRetries; attempt++)
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

Off-by-one error in retry logic. The loop condition 'attempt <= maxRetries' with 'attempt' starting at 0 means that when maxRetries is 3, the code will make 4 attempts (0, 1, 2, 3) instead of the intended 3. Change the loop condition to 'attempt < maxRetries' to ensure the number of attempts matches the parameter value, or adjust the documentation to clarify that maxRetries represents the number of retries after the initial attempt.

Copilot uses AI. Check for mistakes.
Comment on lines +55 to +137
[Test]
public void TryDeleteDirectoryRobust_WhenDirectoryDoesNotExist_ReturnsTrue()
{
// Arrange
string nonExistentPath = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());

// Act
bool result = FilesFolders.TryDeleteDirectoryRobust(nonExistentPath);

// Assert
ClassicAssert.IsTrue(result);
}

[Test]
public void TryDeleteDirectoryRobust_WhenDirectoryIsEmpty_DeletesSuccessfully()
{
// Arrange
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempDir);

// Act
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);

// Assert
ClassicAssert.IsTrue(result);
ClassicAssert.IsFalse(Directory.Exists(tempDir));
}

[Test]
public void TryDeleteDirectoryRobust_WhenDirectoryHasFiles_DeletesSuccessfully()
{
// Arrange
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempDir);
File.WriteAllText(Path.Combine(tempDir, "test.txt"), "test content");

// Act
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);

// Assert
ClassicAssert.IsTrue(result);
ClassicAssert.IsFalse(Directory.Exists(tempDir));
}

[Test]
public void TryDeleteDirectoryRobust_WhenDirectoryHasNestedStructure_DeletesSuccessfully()
{
// Arrange
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempDir);
string subDir1 = Path.Combine(tempDir, "SubDir1");
string subDir2 = Path.Combine(tempDir, "SubDir2");
Directory.CreateDirectory(subDir1);
Directory.CreateDirectory(subDir2);
File.WriteAllText(Path.Combine(subDir1, "file1.txt"), "content1");
File.WriteAllText(Path.Combine(subDir2, "file2.txt"), "content2");
File.WriteAllText(Path.Combine(tempDir, "root.txt"), "root content");

// Act
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);

// Assert
ClassicAssert.IsTrue(result);
ClassicAssert.IsFalse(Directory.Exists(tempDir));
}

[Test]
public void TryDeleteDirectoryRobust_WhenFileIsReadOnly_RemovesAttributeAndDeletes()
{
// Arrange
string tempDir = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName());
Directory.CreateDirectory(tempDir);
string filePath = Path.Combine(tempDir, "readonly.txt");
File.WriteAllText(filePath, "readonly content");
File.SetAttributes(filePath, FileAttributes.ReadOnly);

// Act
bool result = FilesFolders.TryDeleteDirectoryRobust(tempDir);

// Assert
ClassicAssert.IsTrue(result);
ClassicAssert.IsFalse(Directory.Exists(tempDir));
}
Copy link

Copilot AI Feb 4, 2026

Choose a reason for hiding this comment

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

The test coverage doesn't include a test case for partial deletion scenarios where TryDeleteDirectoryRobust returns false. Consider adding a test that verifies the method returns false when file deletion fails (e.g., by mocking or using a test helper that simulates a locked file). This would ensure the core functionality that differentiates this method from Directory.Delete is properly tested.

Copilot uses AI. Check for mistakes.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

BUG: Plugin directory is not deleted fully

2 participants