Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Reduce IVsFreeThreadedFileChangeEvents2.DirectoryChangedEx2 notifications from VS shell #75815

Open
wants to merge 5 commits into
base: main
Choose a base branch
from

Conversation

ToddGrun
Copy link
Contributor

@ToddGrun ToddGrun commented Nov 8, 2024

While looking into another FileWatcher issue, I noticed that we were getting more notifications from shell on directory changes than I would have expected. It turns out this is because we don't currently combine WatchDirectory operations as we do WatchFile/UnwatchFile/UnwatchDirectories.

This is generally the desired behavior as vs shell doesn't support multiple directories being supported by a single notification. However, they do support multiple filters on the same directory, and this is exactly the case that we usualy experience when processing a batch of WatcherOperations. This PR simply allows combining of directory watch notifications for the same directory (and sink), keeping track of the combined set of filters that all requests had.

Note that this PR also changed the mergability of WatchFiles by only allowing merging if the sinks are the same. I don't have context to be completely confident the prior behavior is a bug, but it sure seems wrong.

…ions from VS shell

While looking into another FileWatcher issue, I noticed that we were getting more notifications from shell on directory changes than I would have expected. It turns out this is because we don't currently combine WatchDirectory operations as we do WatchFile/UnwatchFile/UnwatchDirectories.

This is generally the desired behavior as vs shell doesn't support multiple directories being supported by a single notification. However, they do support multiple filters on the same directory, and this is exactly the case that we usualy experience when processing a batch of WatcherOperations. This PR simply allows combining of directory watch notifications for the same directory (and sink), keeping track of the combined set of filters that all requests had.

Note that this PR also changed the mergability of WatchFiles by only allowing merging if the sinks are the same. I don't have context to be completely confident the prior behavior is a bug, but it sure seems wrong.
@ToddGrun ToddGrun requested a review from a team as a code owner November 8, 2024 00:17
@dotnet-issue-labeler dotnet-issue-labeler bot added Area-IDE untriaged Issues and PRs which have not yet been triaged by a lead labels Nov 8, 2024
return (_kind == other._kind);
if (_kind == Kind.WatchFiles)
{
return other._sink == _sink;
Copy link
Contributor Author

Choose a reason for hiding this comment

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

@jasonmalinowski -- This is the code that seemed like a bug before. I definitely see WatchFiles requests with different sinks getting merged.

Copy link
Member

Choose a reason for hiding this comment

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

Looks like this is fixing #74716 then.

@CyrusNajmabadi
Copy link
Member

In this PR, or in a followup, can you examine if TimeSpan.Zero is really a good idea for the ABWQ delay. As this is IO and FS stuff, i really think it would make more sense to have a more reasonable delay (like 500ms or a second). From user perspective, they'll never notice this. But it means we can collect a tremendous amount more, and process and analyze the results in much larger batches for things like deduping and whatnot. TimeSpan.Zero is really for cases of "we are both going to getting an insane firehose of messages AND we must issue them immediately or else badness will occur". For disk notifications, the latter just doesn't apply here. No one will be harmed AFAICT if we wait 500ms.

@ToddGrun
Copy link
Contributor Author

ToddGrun commented Nov 8, 2024

In this PR, or in a followup, can you examine if TimeSpan.Zero is really a good idea for the ABWQ delay. As this is IO and FS stuff, i really think it would make more sense to have a more reasonable delay (like 500ms or a second). From user perspective, they'll never notice this. But it means we can collect a tremendous amount more, and process and analyze the results in much larger batches for things like deduping and whatnot. TimeSpan.Zero is really for cases of "we are both going to getting an insane firehose of messages AND we must issue them immediately or else badness will occur". For disk notifications, the latter just doesn't apply here. No one will be harmed AFAICT if we wait 500ms.

From local testing of open/close roslyn sln, I saw the following:

0 ms delay
2046 calls to ProcessBatchAsync
19942 total operations passed in
8502 merged batches created

100 ms
47 calls to ProcessBatchAsync
19942 total operations passed in
6972 merged batches created

500 ms
18 calls to ProcessBatchAsync
19942 total operations passed in
6518 merged batches created

@CyrusNajmabadi
Copy link
Member

Love it. This is also nice as we really do want to "back off" when notifications come in. They often come in in huge flurries, and we don't want to then be contending with the disk while we're being notified about chnages from other files. 500ms is a nice compromise.

Copy link
Member

@jasonmalinowski jasonmalinowski left a comment

Choose a reason for hiding this comment

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

I believe this breaks how the cookies have to get back to the original context for the directory watch, so later unsubscribes won't unsubscribe. I could be wrong, but we should really have a test either way that two directory watches combine, and you can successfully remove them later.

// 📝 Empirical testing during high activity (e.g. solution close) showed strong batching performance even
// though the batching delay is 0.
// 📝 Empirical testing during high activity (e.g. solution open/close) showed strong batching performance
// with batching delay of 500 ms.
Copy link
Member

Choose a reason for hiding this comment

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

I have no particular opinion what this number should be, but do we have some way to actually differentiate between zero and 500 ms for which is better here? If both numbers were "showing strong batching performance" how did we come up with this change?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Just to make sure, you saw the earlier comment I made regarding the different performance characteristics I saw when trying out various values?

Copy link
Member

Choose a reason for hiding this comment

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

I did not! Maybe we should add that data as a comment to the code.

return (_kind == other._kind);
if (_kind == Kind.WatchFiles)
{
return other._sink == _sink;
Copy link
Member

Choose a reason for hiding this comment

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

It might be easier/prudent just to compare the _sink regardless of the underlying type; that way we don't ever forget to update this check and combine a different kind of operation by accident.

for (var i = 0; i < op._filters.Length; i++)
filtersBuilder.Add(op._filters[i]);

cookiesBuilder.AddRange(op._cookies);
Copy link
Member

Choose a reason for hiding this comment

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

So I think is going to break things -- the list of cookies in the WatchDirectory case isn't just the list that can get copied around, but it's actually the list that gets filled back in. Put another way, the List<uint> object is held by the Context object, and is expected that exact instance gets passed around to the ApplyAsync method so it can be filled in later. That way that same list object gets passed to the UnwatchDirectories later.

Copy link
Member

Choose a reason for hiding this comment

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

We really should add some tests here. Specifically, have a test that merging two directories and then unsubscribing them successfully results in no watches once we're done. I expect that to be broken.

@svick's test in #74716 is a pretty good start for the general pattern. Rather than firing events and seeing what's fired, it might be easier to just assert the number of watches on the underlying mocked file change service. (Although making sure events actually fire right wouldn't hurt either.)

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Backed out the watch directory merging, wasn't going to be easy to solve and found a different way to get some watcher reductions.

case Kind.WatchDirectory:
filtersBuilder.RemoveDuplicates();
return WatchDirectory(firstOp._paths[0], filtersBuilder.ToImmutable(), firstOp._sink, cookiesBuilder.ToList());
case Kind.UnwatchDirectories:
Copy link
Member

Choose a reason for hiding this comment

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

There's probably another problem here too: if two watches against a directory are for different filters, and we combine them, what happens on unsubscribe? Do we still keep the other one around?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

Backed out the watch directory merging, wasn't going to be easy to solve and found a different way to get some watcher reductions.

…issues with that approach.

However, we can still improve batching by changing the WatchDirectory callers to create fewer WatchedDirectory objects that instead specify multiple filters each.
@@ -367,8 +361,13 @@ public Context(FileChangeWatcher fileChangeWatcher, ImmutableArray<WatchedDirect

foreach (var watchedDirectory in watchedDirectories)
{
_fileChangeWatcher._taskQueue.AddWork(watchedDirectories.Select(
watchedDirectory => WatcherOperation.WatchDirectory(watchedDirectory.Path, watchedDirectory.ExtensionFilter, this, _directoryWatchCookies)));
var item = WatcherOperation.WatchDirectory(
Copy link
Contributor Author

Choose a reason for hiding this comment

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

var item = WatcherOperation.WatchDirectory(

Noticed there was a bug here in that every watched directory caused an operation to be created for the whole immutable array, not just for itself.

@ToddGrun
Copy link
Contributor Author

ToddGrun commented Nov 9, 2024

Backed out the watch directory merging, wasn't going to be easy to solve and found a different way to get some watcher reductions.


In reply to: 2424933376

@@ -61,7 +62,7 @@ public async Task CreatingDirectoryWatchRequestsDirectoryWatch()
var tempDirectory = tempRoot.CreateDirectory();

// Try creating a context and ensure we created the registration
var context = lspFileChangeWatcher.CreateContext([new ProjectSystem.WatchedDirectory(tempDirectory.Path, extensionFilter: null)]);
var context = lspFileChangeWatcher.CreateContext([new ProjectSystem.WatchedDirectory(tempDirectory.Path, extensionFilters: ImmutableArray<string>.Empty)]);
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
var context = lspFileChangeWatcher.CreateContext([new ProjectSystem.WatchedDirectory(tempDirectory.Path, extensionFilters: ImmutableArray<string>.Empty)]);
var context = lspFileChangeWatcher.CreateContext([new ProjectSystem.WatchedDirectory(tempDirectory.Path, extensionFilters: [])]);

@@ -252,21 +252,15 @@ public static WatcherOperation CombineRange(ImmutableSegmentedList<WatcherOperat
{
case Kind.WatchFiles:
for (var i = 0; i < op._paths.Count; i++)
{
fileNamesBuilder.Add(op._paths[i]);
Copy link
Member

Choose a reason for hiding this comment

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

is this not just .AddRange (and the same for the below lines)?

Copy link
Contributor Author

Choose a reason for hiding this comment

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

op._paths is a OneOrMany, I don't think there is an override for ArrayBuilder.AddRange that takes that in.

@@ -297,7 +291,7 @@ public bool CanCombineWith(in WatcherOperation other)
if (_kind == Kind.WatchDirectory)
return false;

return (_kind == other._kind);
return _kind == other._kind && _sink == other._sink;
Copy link
Member

Choose a reason for hiding this comment

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

consider doc'ing. up to you.


Namespace Microsoft.VisualStudio.LanguageServices.UnitTests.ProjectSystemShim
<UseExportProvider>
Public Class FileChangeWatcherTests
Copy link
Member

Choose a reason for hiding this comment

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

Suggested change
Public Class FileChangeWatcherTests
Public NotInheritable Class FileChangeWatcherTests

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
Area-IDE untriaged Issues and PRs which have not yet been triaged by a lead VSCode
Projects
None yet
Development

Successfully merging this pull request may close these issues.

3 participants