From ebfa41ad87892494b0e88e4a384be04dd5dc8aaa Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Mon, 13 Jul 2026 16:02:25 +0200 Subject: [PATCH 1/4] Prevent StackOverflow in PhysicalFilesWatcher on unwatchable file systems On a file system that can't be watched (e.g. a WSL path accessed from Windows), the FileSystemWatcher keeps raising the same Error. PhysicalFilesWatcher.OnError cancels the change tokens, which fires the ChangeToken.OnChange registration in FileConfigurationProvider (and similar consumers); its producer re-creates a token via CreateFileChangeToken, which re-enables the watcher, which raises the same Error again. That cycle recurses until the stack overflows (dotnet/runtime#121475). PhysicalFilesWatcher.OnError now suppresses an Error that recurs identically (same exception type and OS error code) with no change delivered in between, which breaks the loop. InternalBufferOverflowException and DirectoryNotFoundException are always reported (the watcher still works / a real change happened), and any delivered change resets the detection so a later error starts fresh. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aeef6f66-c627-4a58-a4cb-6d14cf4d2649 --- .../src/PhysicalFilesWatcher.cs | 76 +++++++++++ .../tests/PhysicalFilesWatcherTests.cs | 128 ++++++++++++++++++ 2 files changed, 204 insertions(+) diff --git a/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs b/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs index f74d2f3f37760f..52eb12a671e72a 100644 --- a/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs +++ b/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Concurrent; using System.Collections.Generic; +using System.ComponentModel; using System.Diagnostics; using System.IO; using System.Runtime.Versioning; @@ -49,6 +50,13 @@ public class PhysicalFilesWatcher : IDisposable private readonly object _rootCreationWatcherLock = new(); private bool _rootWasUnavailable; + // The last Error reported by the FileSystemWatcher that could indicate a persistent failure, + // remembered until a change is successfully delivered (see OnFileSystemEntryChange). Guarded by + // _errorLock. Used to detect the same error occurring again with no progress in between, like + // file system that can't be watched (for example a network share or a WSL path accessed from Windows). + private Exception? _lastError; + private readonly object _errorLock = new(); + private Timer? _timer; private bool _timerInitialized; private object _timerLock = new(); @@ -373,6 +381,39 @@ private void OnChanged(object sender, FileSystemEventArgs e) [SupportedOSPlatform("maccatalyst")] private void OnError(object sender, ErrorEventArgs e) { + Exception? error = e.GetException(); + + // An InternalBufferOverflowException means the watcher is still functioning but dropped + // events, so consumers must rescan; a DirectoryNotFoundException means the watched directory + // was deleted or moved, which is a real change the root-creation watcher recovers from. Both + // must always be reported, and both count as progress that clears any remembered error. + if (error is InternalBufferOverflowException or DirectoryNotFoundException) + { + ClearLastError(); + } + else if (error is not null) + { + lock (_errorLock) + { + if (_lastError is not null && IsSameError(_lastError, error)) + { + // The same error recurred with no change delivered in between, so the file + // system can't be watched. Don't cancel tokens: doing so would likely re-create tokens + // and re-enable the watcher, looping until the stack overflows. The watcher is + // still re-enabled on the next request, so if the condition later clears it can + // resume watching. + return; + } + + _lastError = error; + } + } + else + { + // The Error carried no exception (only reachable through a custom FileSystemWatcher). + // Report it like any other error, but leave the remembered-error state untouched. + } + // Notify all cache entries on error. CancelAll(_filePathTokenLookup, FilePathRequiresSubdirectories); CancelAll(_wildcardTokenLookup, WildcardRequiresSubdirectories); @@ -396,6 +437,37 @@ void CancelAll(ConcurrentDictionary tokens, Func tokens, Func matched on NativeErrorCode + [InlineData(false)] // IOException -> matched on HResult + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_SameErrorRecurs_SecondOccurrenceIsSuppressed(bool win32) + { + // Regression test for https://github.com/dotnet/runtime/issues/121475: + // On a file system that can't be watched (e.g. a WSL path accessed from Windows), enabling + // the FileSystemWatcher keeps raising the same Error, which cancels tokens, which re-creates + // tokens and re-enables the watcher, recursing until the stack overflows. The first + // occurrence of an error is reported, but an identical recurrence (same type and error code) + // with no change delivered in between is suppressed so the loop can't form. + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + // First error is reported: it cancels the token created before it. + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32, code: 5))); + await WhenChanged(first); + + // The same error (same code) recurs: it must NOT cancel the new token. + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32, code: 5))); + Assert.False(await ChangedWithin(second, WaitTimeForTokenToFire), + "A repeated identical error must not cancel tokens."); + } + + [Theory] + [InlineData(true)] + [InlineData(false)] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_DifferentErrorCode_IsReported(bool win32) + { + // Distinct errors (same type, different error code) are not the same persistent failure, so + // each is reported. + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32, code: 5))); + await WhenChanged(first); + + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32, code: 6))); + await WhenChanged(second); + } + + [Theory] + [InlineData(true)] // InternalBufferOverflowException + [InlineData(false)] // DirectoryNotFoundException + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_RecoverableError_IsAlwaysReported(bool bufferOverflow) + { + // InternalBufferOverflowException (events were dropped, rescan needed) and + // DirectoryNotFoundException (the watched directory was deleted/moved) mean the watcher is + // still functioning or a real change happened, so every occurrence must be reported even when + // it repeats identically. + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + Func createError = bufferOverflow + ? () => new InternalBufferOverflowException() + : () => new DirectoryNotFoundException(); + + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(createError())); + await WhenChanged(first); + + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(createError())); + await WhenChanged(second); + } + + private static Exception MakeError(bool win32, int code) + => win32 ? new Win32Exception(code) : new IOException("watcher error", code); + + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_SameError_AfterDeliveredChange_IsReportedAgain() + { + // A change delivered between two identical errors proves the watcher works, so the second + // error starts over and is reported rather than suppressed as a persistent recurrence. + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32: false, code: 5))); + await WhenChanged(first); + + // A delivered change resets the remembered error. + fileSystemWatcher.CallOnChanged(new FileSystemEventArgs(WatcherChangeTypes.Changed, root.Path, "unrelated.txt")); + + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32: false, code: 5))); + await WhenChanged(second); + } + + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_NullException_IsAlwaysReported() + { + // An Error with no exception carries no identity to de-duplicate, so every occurrence is + // reported rather than suppressed. + using var root = new TempDirectory(GetTestFilePath()); + using var fileSystemWatcher = new MockFileSystemWatcher(root.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(root.Path, fileSystemWatcher, pollForChanges: false); + + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(null!)); + await WhenChanged(first); + + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(null!)); + await WhenChanged(second); + } + + private static async Task ChangedWithin(IChangeToken token, int milliseconds) + { + var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); + using IDisposable registration = token.RegisterChangeCallback(_ => tcs.TrySetResult(true), null); + return await Task.WhenAny(tcs.Task, Task.Delay(milliseconds)) == tcs.Task; + } + private class TestPollingChangeToken : IPollingChangeToken { public int Id { get; set; } From 2c8956f717971fd997fd114f6ab47a90a4233c19 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Mon, 13 Jul 2026 16:34:33 +0200 Subject: [PATCH 2/4] Simplify the suppression test to assert HasChanged directly The token is a CancellationChangeToken, so the test can assert token.HasChanged is false after the recurring error instead of registering a callback and tracking a Task. Removes the ad-hoc ChangedWithin helper. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aeef6f66-c627-4a58-a4cb-6d14cf4d2649 --- .../tests/PhysicalFilesWatcherTests.cs | 11 ++--------- 1 file changed, 2 insertions(+), 9 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs b/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs index 875887d4970639..054eef96494b6a 100644 --- a/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs +++ b/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs @@ -806,8 +806,8 @@ public async Task OnError_SameErrorRecurs_SecondOccurrenceIsSuppressed(bool win3 // The same error (same code) recurs: it must NOT cancel the new token. IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32, code: 5))); - Assert.False(await ChangedWithin(second, WaitTimeForTokenToFire), - "A repeated identical error must not cancel tokens."); + await Task.Delay(WaitTimeForTokenToFire); + Assert.False(second.HasChanged, "A repeated identical error must not cancel tokens."); } [Theory] @@ -902,13 +902,6 @@ public async Task OnError_NullException_IsAlwaysReported() await WhenChanged(second); } - private static async Task ChangedWithin(IChangeToken token, int milliseconds) - { - var tcs = new TaskCompletionSource(TaskCreationOptions.RunContinuationsAsynchronously); - using IDisposable registration = token.RegisterChangeCallback(_ => tcs.TrySetResult(true), null); - return await Task.WhenAny(tcs.Task, Task.Delay(milliseconds)) == tcs.Task; - } - private class TestPollingChangeToken : IPollingChangeToken { public int Id { get; set; } From 2ff5ee18c354b08037077e75044809c20c0c4c31 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Mon, 13 Jul 2026 17:19:22 +0200 Subject: [PATCH 3/4] Address PR review feedback - ClearLastError: remove the lock-free fast-path read of _lastError, which could observe a stale null on a thread concurrent with OnError and skip clearing the remembered error. All access to _lastError now goes through _errorLock; the uncontended lock is cheap and this is not a hot path. - Fix a grammar nit in the _lastError comment. Addresses: - https://github.com/dotnet/runtime/pull/130627#discussion_r3571446053 - https://github.com/dotnet/runtime/pull/130627#discussion_r3571446110 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aeef6f66-c627-4a58-a4cb-6d14cf4d2649 --- .../src/PhysicalFilesWatcher.cs | 11 ++++------- 1 file changed, 4 insertions(+), 7 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs b/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs index 52eb12a671e72a..3c745878c8d20f 100644 --- a/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs +++ b/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs @@ -52,8 +52,8 @@ public class PhysicalFilesWatcher : IDisposable // The last Error reported by the FileSystemWatcher that could indicate a persistent failure, // remembered until a change is successfully delivered (see OnFileSystemEntryChange). Guarded by - // _errorLock. Used to detect the same error occurring again with no progress in between, like - // file system that can't be watched (for example a network share or a WSL path accessed from Windows). + // _errorLock. Used to detect the same error occurring again with no progress in between, like a + // file system that can't be watched (for example, a network share or a WSL path accessed from Windows). private Exception? _lastError; private readonly object _errorLock = new(); @@ -459,12 +459,9 @@ private static bool IsSameError(Exception a, Exception b) private void ClearLastError() { - if (_lastError is not null) + lock (_errorLock) { - lock (_errorLock) - { - _lastError = null; - } + _lastError = null; } } From 03541c802dcdc804851d29d2babf6f3b8f947ba5 Mon Sep 17 00:00:00 2001 From: Petr Onderka Date: Mon, 13 Jul 2026 17:56:42 +0200 Subject: [PATCH 4/4] Only reset error detection for relevant in-root changes Move ClearLastError in OnFileSystemEntryChange to after the StartsWith(_root) and exclusion-filter checks. When _root doesn't exist yet the watcher watches an ancestor and delivers events for siblings outside _root; resetting on those could prevent suppression from engaging during an unwatchable-root error loop. Now only a relevant, non-excluded change inside _root clears the remembered error. Addresses https://github.com/dotnet/runtime/pull/130627#discussion_r3571901844 Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Copilot-Session: aeef6f66-c627-4a58-a4cb-6d14cf4d2649 --- .../src/PhysicalFilesWatcher.cs | 11 ++++--- .../tests/PhysicalFilesWatcherTests.cs | 29 +++++++++++++++++++ 2 files changed, 36 insertions(+), 4 deletions(-) diff --git a/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs b/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs index 3c745878c8d20f..ad723228b44278 100644 --- a/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs +++ b/src/libraries/Microsoft.Extensions.FileProviders.Physical/src/PhysicalFilesWatcher.cs @@ -472,10 +472,6 @@ private void ClearLastError() [SupportedOSPlatform("maccatalyst")] private void OnFileSystemEntryChange(string fullPath) { - // A delivered change proves the watcher is working, so forget any remembered error; a later - // identical error then starts over rather than being treated as a persistent recurrence. - ClearLastError(); - try { // Ignore events outside _root (can happen when the FSW watches an ancestor directory). @@ -494,6 +490,13 @@ private void OnFileSystemEntryChange(string fullPath) return; } + // A relevant change was delivered, which proves the watcher is working, so forget any + // remembered error; a later identical error then starts over rather than being treated + // as a persistent recurrence. This should run only for changes inside _root that aren't + // excluded, so unrelated events don't reset the detection while an unwatchable-root + // error loop is in progress. + ClearLastError(); + string relativePath = fullPath.Substring(_root.Length); ReportChangeForMatchedEntries(relativePath); } diff --git a/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs b/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs index 054eef96494b6a..173bbdc52d98f8 100644 --- a/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs +++ b/src/libraries/Microsoft.Extensions.FileProviders.Physical/tests/PhysicalFilesWatcherTests.cs @@ -883,6 +883,35 @@ public async Task OnError_SameError_AfterDeliveredChange_IsReportedAgain() await WhenChanged(second); } + [Fact] + [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] + public async Task OnError_ChangeOutsideRoot_DoesNotResetDetection() + { + // When the watcher watches an ancestor, it can deliver events for + // siblings outside _root. Such events must not reset the persistent-error detection, otherwise + // unrelated activity could prevent suppression from engaging during an unwatchable-root loop. + using var tempDir = new TempDirectory(GetTestFilePath()); + string rootDir = Path.Combine(tempDir.Path, "rootDir"); + Directory.CreateDirectory(rootDir); + + // The FSW watches the ancestor (tempDir), so it can raise events outside rootDir. + using var fileSystemWatcher = new MockFileSystemWatcher(tempDir.Path); + using var physicalFilesWatcher = new PhysicalFilesWatcher(rootDir, fileSystemWatcher, pollForChanges: false); + + IChangeToken first = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32: false, code: 5))); + await WhenChanged(first); + + // A change to a sibling outside rootDir must NOT reset the remembered error. + fileSystemWatcher.CallOnChanged(new FileSystemEventArgs(WatcherChangeTypes.Changed, tempDir.Path, "outside.txt")); + + // The same error recurs: it must still be suppressed. + IChangeToken second = physicalFilesWatcher.CreateFileChangeToken("appsettings.json"); + fileSystemWatcher.CallOnError(new ErrorEventArgs(MakeError(win32: false, code: 5))); + await Task.Delay(WaitTimeForTokenToFire); + Assert.False(second.HasChanged, "An out-of-root change must not reset persistent-error detection."); + } + [Fact] [SkipOnPlatform(TestPlatforms.Browser | TestPlatforms.iOS | TestPlatforms.tvOS, "System.IO.FileSystem.Watcher is not supported on Browser/iOS/tvOS")] public async Task OnError_NullException_IsAlwaysReported()