Skip to content

Commit 763a18e

Browse files
authored
Avoid running restores for dotnet-watch run (#23421)
* Tweaks to make dotnet-watch run faster * Previously dotnet-watch calculated the watch file list on every run by invoking MSBuild. This changes the tool to only calculate it if an MSBuild file (.targets, .props, .csproj etc) file changed * For dotnet watch run and dotnet watch test command, use --no-restore if changed file is not an MSBuild file. * Add opt-out switch * Update src/Tools/dotnet-watch/README.md * Fixup typo * Update src/Tools/dotnet-watch/README.md
1 parent 769fc6d commit 763a18e

File tree

13 files changed

+680
-16
lines changed

13 files changed

+680
-16
lines changed

src/Tools/dotnet-watch/README.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ Some configuration options can be passed to `dotnet watch` through environment v
2929
| Variable | Effect |
3030
| ---------------------------------------------- | -------------------------------------------------------- |
3131
| DOTNET_USE_POLLING_FILE_WATCHER | If set to "1" or "true", `dotnet watch` will use a polling file watcher instead of CoreFx's `FileSystemWatcher`. Used when watching files on network shares or Docker mounted volumes. |
32+
| DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM | By default, `dotnet watch` optimizes the build by avoiding certain operations such as running restore or re-evaluating the set of watched files on every file change. If set to "1" or "true", these optimizations are disabled. |
3233

3334
### MSBuild
3435

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,24 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using Microsoft.Extensions.Tools.Internal;
5+
6+
namespace Microsoft.DotNet.Watcher.Tools
7+
{
8+
public class DotNetWatchContext
9+
{
10+
public IReporter Reporter { get; set; } = NullReporter.Singleton;
11+
12+
public ProcessSpec ProcessSpec { get; set; }
13+
14+
public IFileSet FileSet { get; set; }
15+
16+
public int Iteration { get; set; }
17+
18+
public string ChangedFile { get; set; }
19+
20+
public bool RequiresMSBuildRevaluation { get; set; }
21+
22+
public bool SuppressMSBuildIncrementalism { get; set; }
23+
}
24+
}

src/Tools/dotnet-watch/src/DotNetWatcher.cs

Lines changed: 46 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,9 +3,12 @@
33

44
using System;
55
using System.Globalization;
6+
using System.IO;
7+
using System.Linq;
68
using System.Threading;
79
using System.Threading.Tasks;
810
using Microsoft.DotNet.Watcher.Internal;
11+
using Microsoft.DotNet.Watcher.Tools;
912
using Microsoft.Extensions.CommandLineUtils;
1013
using Microsoft.Extensions.Tools.Internal;
1114

@@ -15,33 +18,63 @@ public class DotNetWatcher
1518
{
1619
private readonly IReporter _reporter;
1720
private readonly ProcessRunner _processRunner;
21+
private readonly IWatchFilter[] _filters;
1822

19-
public DotNetWatcher(IReporter reporter)
23+
public DotNetWatcher(IReporter reporter, IFileSetFactory fileSetFactory)
2024
{
2125
Ensure.NotNull(reporter, nameof(reporter));
2226

2327
_reporter = reporter;
2428
_processRunner = new ProcessRunner(reporter);
29+
30+
_filters = new IWatchFilter[]
31+
{
32+
new MSBuildEvaluationFilter(fileSetFactory),
33+
new NoRestoreFilter(),
34+
};
2535
}
2636

27-
public async Task WatchAsync(ProcessSpec processSpec, IFileSetFactory fileSetFactory,
28-
CancellationToken cancellationToken)
37+
public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancellationToken)
2938
{
3039
Ensure.NotNull(processSpec, nameof(processSpec));
3140

32-
var cancelledTaskSource = new TaskCompletionSource<object>();
33-
cancellationToken.Register(state => ((TaskCompletionSource<object>) state).TrySetResult(null),
41+
var cancelledTaskSource = new TaskCompletionSource();
42+
cancellationToken.Register(state => ((TaskCompletionSource)state).TrySetResult(),
3443
cancelledTaskSource);
3544

36-
var iteration = 1;
45+
var initialArguments = processSpec.Arguments.ToArray();
46+
var suppressMSBuildIncrementalism = Environment.GetEnvironmentVariable("DOTNET_WATCH_SUPPRESS_MSBUILD_INCREMENTALISM");
47+
var context = new DotNetWatchContext
48+
{
49+
Iteration = -1,
50+
ProcessSpec = processSpec,
51+
Reporter = _reporter,
52+
SuppressMSBuildIncrementalism = suppressMSBuildIncrementalism == "1" || suppressMSBuildIncrementalism == "true",
53+
};
54+
55+
if (context.SuppressMSBuildIncrementalism)
56+
{
57+
_reporter.Verbose("MSBuild incremental optimizations suppressed.");
58+
}
3759

3860
while (true)
3961
{
40-
processSpec.EnvironmentVariables["DOTNET_WATCH_ITERATION"] = iteration.ToString(CultureInfo.InvariantCulture);
41-
iteration++;
62+
context.Iteration++;
63+
64+
// Reset arguments
65+
processSpec.Arguments = initialArguments;
66+
67+
for (var i = 0; i < _filters.Length; i++)
68+
{
69+
await _filters[i].ProcessAsync(context, cancellationToken);
70+
}
71+
72+
// Reset for next run
73+
context.RequiresMSBuildRevaluation = false;
4274

43-
var fileSet = await fileSetFactory.CreateAsync(cancellationToken);
75+
processSpec.EnvironmentVariables["DOTNET_WATCH_ITERATION"] = (context.Iteration + 1).ToString(CultureInfo.InvariantCulture);
4476

77+
var fileSet = context.FileSet;
4578
if (fileSet == null)
4679
{
4780
_reporter.Error("Failed to find a list of files to watch");
@@ -91,10 +124,13 @@ public async Task WatchAsync(ProcessSpec processSpec, IFileSetFactory fileSetFac
91124
return;
92125
}
93126

127+
context.ChangedFile = fileSetTask.Result;
94128
if (finishedTask == processTask)
95129
{
130+
// Process exited. Redo evaludation
131+
context.RequiresMSBuildRevaluation = true;
96132
// Now wait for a file to change before restarting process
97-
await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _reporter.Warn("Waiting for a file to change before restarting dotnet..."));
133+
context.ChangedFile = await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _reporter.Warn("Waiting for a file to change before restarting dotnet..."));
98134
}
99135

100136
if (!string.IsNullOrEmpty(fileSetTask.Result))
Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System.Threading;
5+
using System.Threading.Tasks;
6+
7+
namespace Microsoft.DotNet.Watcher.Tools
8+
{
9+
public interface IWatchFilter
10+
{
11+
ValueTask ProcessAsync(DotNetWatchContext context, CancellationToken cancellationToken);
12+
}
13+
}

src/Tools/dotnet-watch/src/Internal/FileSet.cs

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
// Copyright (c) .NET Foundation. All rights reserved.
1+
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
33

44
using System;
@@ -20,6 +20,8 @@ public FileSet(IEnumerable<string> files)
2020

2121
public int Count => _files.Count;
2222

23+
public static IFileSet Empty = new FileSet(Array.Empty<string>());
24+
2325
public IEnumerator<string> GetEnumerator() => _files.GetEnumerator();
2426
IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator();
2527
}
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.IO;
7+
using System.Linq;
8+
using System.Threading;
9+
using System.Threading.Tasks;
10+
11+
namespace Microsoft.DotNet.Watcher.Tools
12+
{
13+
public class MSBuildEvaluationFilter : IWatchFilter
14+
{
15+
// File types that require an MSBuild re-evaluation
16+
private static readonly string[] _msBuildFileExtensions = new[]
17+
{
18+
".csproj", ".props", ".targets", ".fsproj", ".vbproj", ".vcxproj",
19+
};
20+
private static readonly int[] _msBuildFileExtensionHashes = _msBuildFileExtensions
21+
.Select(e => e.GetHashCode(StringComparison.OrdinalIgnoreCase))
22+
.ToArray();
23+
24+
private readonly IFileSetFactory _factory;
25+
26+
private List<(string fileName, DateTime lastWriteTimeUtc)> _msbuildFileTimestamps;
27+
28+
public MSBuildEvaluationFilter(IFileSetFactory factory)
29+
{
30+
_factory = factory;
31+
}
32+
33+
public async ValueTask ProcessAsync(DotNetWatchContext context, CancellationToken cancellationToken)
34+
{
35+
if (context.SuppressMSBuildIncrementalism)
36+
{
37+
context.RequiresMSBuildRevaluation = true;
38+
context.FileSet = await _factory.CreateAsync(cancellationToken);
39+
return;
40+
}
41+
42+
if (context.Iteration == 0 || RequiresMSBuildRevaluation(context))
43+
{
44+
context.RequiresMSBuildRevaluation = true;
45+
}
46+
47+
if (context.RequiresMSBuildRevaluation)
48+
{
49+
context.Reporter.Verbose("Evaluating dotnet-watch file set.");
50+
51+
context.FileSet = await _factory.CreateAsync(cancellationToken);
52+
_msbuildFileTimestamps = GetMSBuildFileTimeStamps(context);
53+
}
54+
}
55+
56+
private bool RequiresMSBuildRevaluation(DotNetWatchContext context)
57+
{
58+
var changedFile = context.ChangedFile;
59+
if (!string.IsNullOrEmpty(changedFile) && IsMsBuildFileExtension(changedFile))
60+
{
61+
return true;
62+
}
63+
64+
// The filewatcher may miss changes to files. For msbuild files, we can verify that they haven't been modified
65+
// since the previous iteration.
66+
// We do not have a way to identify renames or new additions that the file watcher did not pick up,
67+
// without performing an evaluation. We will start off by keeping it simple and comparing the timestamps
68+
// of known MSBuild files from previous run. This should cover the vast majority of cases.
69+
70+
foreach (var (file, lastWriteTimeUtc) in _msbuildFileTimestamps)
71+
{
72+
if (GetLastWriteTimeUtcSafely(file) != lastWriteTimeUtc)
73+
{
74+
context.Reporter.Verbose($"Re-evaluation needed due to changes in {file}.");
75+
76+
return true;
77+
}
78+
}
79+
80+
return false;
81+
}
82+
83+
private List<(string fileName, DateTime lastModifiedUtc)> GetMSBuildFileTimeStamps(DotNetWatchContext context)
84+
{
85+
var msbuildFiles = new List<(string fileName, DateTime lastModifiedUtc)>();
86+
foreach (var file in context.FileSet)
87+
{
88+
if (!string.IsNullOrEmpty(file) && IsMsBuildFileExtension(file))
89+
{
90+
msbuildFiles.Add((file, GetLastWriteTimeUtcSafely(file)));
91+
}
92+
}
93+
94+
return msbuildFiles;
95+
}
96+
97+
protected virtual DateTime GetLastWriteTimeUtcSafely(string file)
98+
{
99+
try
100+
{
101+
return File.GetLastWriteTimeUtc(file);
102+
}
103+
catch
104+
{
105+
return DateTime.UtcNow;
106+
}
107+
}
108+
109+
static bool IsMsBuildFileExtension(string fileName)
110+
{
111+
var extension = Path.GetExtension(fileName.AsSpan());
112+
var hashCode = string.GetHashCode(extension, StringComparison.OrdinalIgnoreCase);
113+
for (var i = 0; i < _msBuildFileExtensionHashes.Length; i++)
114+
{
115+
if (_msBuildFileExtensionHashes[i] == hashCode && extension.Equals(_msBuildFileExtensions[i], StringComparison.OrdinalIgnoreCase))
116+
{
117+
return true;
118+
}
119+
}
120+
121+
return false;
122+
}
123+
}
124+
}
Lines changed: 75 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,75 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
3+
4+
using System;
5+
using System.Collections.Generic;
6+
using System.Linq;
7+
using System.Threading;
8+
using System.Threading.Tasks;
9+
using Microsoft.Extensions.Tools.Internal;
10+
11+
namespace Microsoft.DotNet.Watcher.Tools
12+
{
13+
public sealed class NoRestoreFilter : IWatchFilter
14+
{
15+
private bool _canUseNoRestore;
16+
private string[] _noRestoreArguments;
17+
18+
public ValueTask ProcessAsync(DotNetWatchContext context, CancellationToken cancellationToken)
19+
{
20+
if (context.SuppressMSBuildIncrementalism)
21+
{
22+
return default;
23+
}
24+
25+
if (context.Iteration == 0)
26+
{
27+
var arguments = context.ProcessSpec.Arguments;
28+
_canUseNoRestore = CanUseNoRestore(arguments, context.Reporter);
29+
if (_canUseNoRestore)
30+
{
31+
// Create run --no-restore <other args>
32+
_noRestoreArguments = arguments.Take(1).Append("--no-restore").Concat(arguments.Skip(1)).ToArray();
33+
context.Reporter.Verbose($"No restore arguments: {string.Join(" ", _noRestoreArguments)}");
34+
}
35+
}
36+
else if (_canUseNoRestore)
37+
{
38+
if (context.RequiresMSBuildRevaluation)
39+
{
40+
context.Reporter.Verbose("Cannot use --no-restore since msbuild project files have changed.");
41+
}
42+
else
43+
{
44+
context.Reporter.Verbose("Modifying command to use --no-restore");
45+
context.ProcessSpec.Arguments = _noRestoreArguments;
46+
}
47+
}
48+
49+
return default;
50+
}
51+
52+
private static bool CanUseNoRestore(IEnumerable<string> arguments, IReporter reporter)
53+
{
54+
// For some well-known dotnet commands, we can pass in the --no-restore switch to avoid unnecessary restores between iterations.
55+
// For now we'll support the "run" and "test" commands.
56+
if (arguments.Any(a => string.Equals(a, "--no-restore", StringComparison.Ordinal)))
57+
{
58+
// Did the user already configure a --no-restore?
59+
return false;
60+
}
61+
62+
var dotnetCommand = arguments.FirstOrDefault();
63+
if (string.Equals(dotnetCommand, "run", StringComparison.Ordinal) || string.Equals(dotnetCommand, "test", StringComparison.Ordinal))
64+
{
65+
reporter.Verbose("Watch command can be configured to use --no-restore.");
66+
return true;
67+
}
68+
else
69+
{
70+
reporter.Verbose($"Watch command will not use --no-restore. Unsupport dotnet-command '{dotnetCommand}'.");
71+
return false;
72+
}
73+
}
74+
}
75+
}

src/Tools/dotnet-watch/src/Program.cs

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -162,8 +162,8 @@ private async Task<int> MainInternalAsync(
162162
_reporter.Output("Polling file watcher is enabled");
163163
}
164164

165-
await new DotNetWatcher(reporter)
166-
.WatchAsync(processInfo, fileSetFactory, cancellationToken);
165+
await new DotNetWatcher(reporter, fileSetFactory)
166+
.WatchAsync(processInfo, cancellationToken);
167167

168168
return 0;
169169
}

0 commit comments

Comments
 (0)