Fix flaky FileSystemWatcher_Directory_Delete_MultipleFilters due to Windows NTFS pending-delete race#125915
Fix flaky FileSystemWatcher_Directory_Delete_MultipleFilters due to Windows NTFS pending-delete race#125915
Conversation
Two fixes for the Windows pending-delete race condition: 1. In ExpectEvent, dispose intermediate FileSystemWatcher objects created by RecreateWatcher to release handles promptly, reducing the window for the NTFS pending-delete race. 2. In FileSystemWatcher_Directory_Delete_MultipleFilters, add SpinWait.SpinUntil in cleanup lambdas to wait for directories to be fully deleted before recreating them. On Windows NTFS, RemoveDirectory marks a directory for pending deletion but doesn't remove it until all handles are closed. The previous cleanup's Create() would silently do nothing (because the OS reported the path as still existing), causing the next retry's Delete() to throw DirectoryNotFoundException. Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com> Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/a235899f-fd87-4a52-9252-d3b8635d85d1
Apply the same SpinWait.SpinUntil fix to FileSystemWatcher_File_Delete_MultipleFilters to prevent the same pending-delete race on Windows where File.Create() silently does nothing when the file is still in pending-delete state after deletion. Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com> Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/a235899f-fd87-4a52-9252-d3b8635d85d1
There was a problem hiding this comment.
Pull request overview
Improves reliability of System.IO.FileSystem.Watcher tests on Windows by addressing retry/cleanup races that can leave paths in NTFS “pending delete” state and by reducing FileSystemWatcher handle retention across retries.
Changes:
- Dispose
FileSystemWatcherinstances created duringExpectEventretries to avoid accumulating OS handles. - Add cleanup waits (
SpinWait.SpinUntil) before recreating deleted files/directories in multi-filter delete tests.
Reviewed changes
Copilot reviewed 2 out of 2 changed files in this pull request and generated 3 comments.
| File | Description |
|---|---|
src/libraries/System.IO.FileSystem.Watcher/tests/Utility/FileSystemWatcherTest.cs |
Updates ExpectEvent retry logic to recreate and dispose watchers between attempts. |
src/libraries/System.IO.FileSystem.Watcher/tests/FileSystemWatcher.unit.cs |
Adds delete-completion waits in cleanup lambdas for file/directory multi-filter delete tests. |
| cleanup(); | ||
| } | ||
|
|
||
| if (newWatcher != watcher) | ||
| newWatcher.Dispose(); |
There was a problem hiding this comment.
The recreated watcher is disposed only after the retry loop completes. If ExecuteAndVerifyEvents throws on the final attempt (or if cleanup() throws), this code will exit early and the recreated FileSystemWatcher will not be disposed, reintroducing handle leaks. Consider wrapping the retry loop in a try/finally (or a using for the recreated watcher) so any watcher created by RecreateWatcher is always disposed on exit.
| cleanup: () => { SpinWait.SpinUntil(() => !File.Exists(fileOne.FullName), 2000); fileOne.Create().Dispose(); }, | ||
| expectedPath: fileOne.FullName); | ||
| ExpectEvent(watcher, WatcherChangeTypes.Deleted, fileTwo.Delete, | ||
| cleanup: () => { SpinWait.SpinUntil(() => !File.Exists(fileTwo.FullName), 2000); fileTwo.Create().Dispose(); }, | ||
| expectedPath: fileTwo.FullName); | ||
| ExpectNoEvent(watcher, WatcherChangeTypes.Deleted, fileThree.Delete, | ||
| cleanup: () => { SpinWait.SpinUntil(() => !File.Exists(fileThree.FullName), 2000); fileThree.Create().Dispose(); }, |
There was a problem hiding this comment.
SpinWait.SpinUntil returns false on timeout, but the result is ignored here. If the delete hasn’t fully completed within 2000ms, the subsequent Create() can still no-op and the next retry may hit the same pending-delete race. Please check the return value (e.g., assert it succeeded with a useful message, or otherwise handle the timeout) so cleanup reliably restores the file state before the next attempt.
| cleanup: () => { SpinWait.SpinUntil(() => !File.Exists(fileOne.FullName), 2000); fileOne.Create().Dispose(); }, | |
| expectedPath: fileOne.FullName); | |
| ExpectEvent(watcher, WatcherChangeTypes.Deleted, fileTwo.Delete, | |
| cleanup: () => { SpinWait.SpinUntil(() => !File.Exists(fileTwo.FullName), 2000); fileTwo.Create().Dispose(); }, | |
| expectedPath: fileTwo.FullName); | |
| ExpectNoEvent(watcher, WatcherChangeTypes.Deleted, fileThree.Delete, | |
| cleanup: () => { SpinWait.SpinUntil(() => !File.Exists(fileThree.FullName), 2000); fileThree.Create().Dispose(); }, | |
| cleanup: () => | |
| { | |
| Assert.True( | |
| SpinWait.SpinUntil(() => !File.Exists(fileOne.FullName), 2000), | |
| $"Timed out waiting for {fileOne.FullName} to be deleted."); | |
| fileOne.Create().Dispose(); | |
| }, | |
| expectedPath: fileOne.FullName); | |
| ExpectEvent(watcher, WatcherChangeTypes.Deleted, fileTwo.Delete, | |
| cleanup: () => | |
| { | |
| Assert.True( | |
| SpinWait.SpinUntil(() => !File.Exists(fileTwo.FullName), 2000), | |
| $"Timed out waiting for {fileTwo.FullName} to be deleted."); | |
| fileTwo.Create().Dispose(); | |
| }, | |
| expectedPath: fileTwo.FullName); | |
| ExpectNoEvent(watcher, WatcherChangeTypes.Deleted, fileThree.Delete, | |
| cleanup: () => | |
| { | |
| Assert.True( | |
| SpinWait.SpinUntil(() => !File.Exists(fileThree.FullName), 2000), | |
| $"Timed out waiting for {fileThree.FullName} to be deleted."); | |
| fileThree.Create().Dispose(); | |
| }, |
| cleanup: () => { SpinWait.SpinUntil(() => !Directory.Exists(directoryOne.FullName), 2000); directoryOne.Create(); }, | ||
| expectedPath: directoryOne.FullName); | ||
| ExpectEvent(watcher, WatcherChangeTypes.Deleted, action: () => directoryTwo.Delete(), | ||
| cleanup: () => { SpinWait.SpinUntil(() => !Directory.Exists(directoryTwo.FullName), 2000); directoryTwo.Create(); }, | ||
| expectedPath: directoryTwo.FullName); | ||
| ExpectNoEvent(watcher, WatcherChangeTypes.Deleted, action: () => directoryThree.Delete(), | ||
| cleanup: () => { SpinWait.SpinUntil(() => !Directory.Exists(directoryThree.FullName), 2000); directoryThree.Create(); }, |
There was a problem hiding this comment.
SpinWait.SpinUntil can time out and return false, but the return value is ignored. If the directory is still in a pending-delete state after 2000ms, Create() may still no-op and leave the next retry operating on a path that can disappear asynchronously. Please check the return value (e.g., assert success with a clear message, or handle the timeout) so cleanup deterministically restores the directory before the next attempt.
| cleanup: () => { SpinWait.SpinUntil(() => !Directory.Exists(directoryOne.FullName), 2000); directoryOne.Create(); }, | |
| expectedPath: directoryOne.FullName); | |
| ExpectEvent(watcher, WatcherChangeTypes.Deleted, action: () => directoryTwo.Delete(), | |
| cleanup: () => { SpinWait.SpinUntil(() => !Directory.Exists(directoryTwo.FullName), 2000); directoryTwo.Create(); }, | |
| expectedPath: directoryTwo.FullName); | |
| ExpectNoEvent(watcher, WatcherChangeTypes.Deleted, action: () => directoryThree.Delete(), | |
| cleanup: () => { SpinWait.SpinUntil(() => !Directory.Exists(directoryThree.FullName), 2000); directoryThree.Create(); }, | |
| cleanup: () => | |
| { | |
| bool deleted = SpinWait.SpinUntil(() => !Directory.Exists(directoryOne.FullName), 2000); | |
| Assert.True(deleted, $"Timed out waiting for directory '{directoryOne.FullName}' to be deleted."); | |
| directoryOne.Create(); | |
| }, | |
| expectedPath: directoryOne.FullName); | |
| ExpectEvent(watcher, WatcherChangeTypes.Deleted, action: () => directoryTwo.Delete(), | |
| cleanup: () => | |
| { | |
| bool deleted = SpinWait.SpinUntil(() => !Directory.Exists(directoryTwo.FullName), 2000); | |
| Assert.True(deleted, $"Timed out waiting for directory '{directoryTwo.FullName}' to be deleted."); | |
| directoryTwo.Create(); | |
| }, | |
| expectedPath: directoryTwo.FullName); | |
| ExpectNoEvent(watcher, WatcherChangeTypes.Deleted, action: () => directoryThree.Delete(), | |
| cleanup: () => | |
| { | |
| bool deleted = SpinWait.SpinUntil(() => !Directory.Exists(directoryThree.FullName), 2000); | |
| Assert.True(deleted, $"Timed out waiting for directory '{directoryThree.FullName}' to be deleted."); | |
| directoryThree.Create(); | |
| }, |
|
@copilot address feedback |
…Wait return value - Wrap ExpectEvent retry loop in try/finally so recreated FileSystemWatcher instances are always disposed even if ExecuteAndVerifyEvents or cleanup() throws - Assert the return value of SpinWait.SpinUntil in cleanup lambdas in both FileSystemWatcher_File_Delete_MultipleFilters and FileSystemWatcher_Directory_Delete_MultipleFilters to surface a clear failure message if the delete hasn't completed within the 2s timeout Co-authored-by: danmoseley <6385855+danmoseley@users.noreply.github.com> Agent-Logs-Url: https://github.com/dotnet/runtime/sessions/87e18d9f-0248-4e37-85ee-7daba9ccd599
Done in d28498f. Three changes:
|
Two race conditions in the FSW test retry infrastructure caused intermittent
DirectoryNotFoundExceptionon Windows x86/x64 CI.Root causes:
NTFS pending-delete race:
RemoveDirectorymarks a directory for deletion but doesn't remove it until all handles close. The cleanup lambda calleddirectoryOne.Create()while the path was still in pending-delete state — Windows reported it as existing, soCreateDirectorysilently no-oped. Once the pending delete completed, the directory was gone, and the next retry'sDelete()threwDirectoryNotFoundException.RecreateWatcherhandle leak: Each retry created a newFileSystemWatcherwithout disposing the previous one. Accumulated watchers holding handles extended the pending-delete window.Fixes:
ExpectEvent(test utility): Wrap the retry loop intry/finallyto ensure anyFileSystemWatchercreated byRecreateWatcheris always disposed — even ifExecuteAndVerifyEventsor thecleanup()lambda throws. Intermediate watchers are disposed between retries; the final recreated watcher is disposed in thefinallyblock.FileSystemWatcher_Directory_Delete_MultipleFiltersandFileSystemWatcher_File_Delete_MultipleFilters: AddAssert.True(SpinWait.SpinUntil(() => !Directory/File.Exists(...), 2000), "Timed out waiting for '...' to be deleted.")beforeCreate()in cleanup lambdas, ensuring the path is fully gone before recreating it and surfacing a clear diagnostic if the 2s timeout is exceeded:Original prompt
This section details on the original issue you should resolve
<issue_title>Could not find a part of the path in System.IO.Tests.FileSystemWatcherTests.FileSystemWatcher_Directory_Delete_MultipleFilters</issue_title>
<issue_description>### Error Blob
{ "ErrorMessage": "at System.IO.Tests.FileSystemWatcherTests.FileSystemWatcher_Directory_Delete_MultipleFilters", "BuildRetry": true, "ErrorPattern": "", "ExcludeConsoleLog": false }Reproduction Steps
Libraries Test Run checked coreclr windows x86 DebugKnown issue validation
Build: 🔎 https://dev.azure.com/dnceng-public/publ...
📍 Connect Copilot coding agent with Jira, Azure Boards or Linear to delegate work to Copilot in one click without leaving your project management tool.