diff --git a/src/Microsoft.DotNet.Watcher.Tools/CommandLineOptions.cs b/src/Microsoft.DotNet.Watcher.Tools/CommandLineOptions.cs index 6960ea93b6..dfd27756bd 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/CommandLineOptions.cs +++ b/src/Microsoft.DotNet.Watcher.Tools/CommandLineOptions.cs @@ -1,11 +1,11 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using System.Collections.Generic; using System.IO; using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Watcher.Tools; +using Microsoft.DotNet.Watcher.Internal; using Microsoft.Extensions.CommandLineUtils; namespace Microsoft.DotNet.Watcher @@ -18,10 +18,7 @@ namespace Microsoft.DotNet.Watcher public IList RemainingArguments { get; private set; } public static CommandLineOptions Parse(string[] args, TextWriter stdout, TextWriter stderr) { - if (args == null) - { - throw new ArgumentNullException(nameof(args)); - } + Ensure.NotNull(args, nameof(args)); var app = new CommandLineApplication(throwOnUnexpectedArg: false) { diff --git a/src/Microsoft.DotNet.Watcher.Tools/DotNetWatcher.cs b/src/Microsoft.DotNet.Watcher.Tools/DotNetWatcher.cs index 1d9f2fd0fb..58756c33cd 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/DotNetWatcher.cs +++ b/src/Microsoft.DotNet.Watcher.Tools/DotNetWatcher.cs @@ -1,12 +1,8 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; -using System.Collections.Generic; -using System.IO; using System.Threading; using System.Threading.Tasks; -using Microsoft.DotNet.Cli.Utils; using Microsoft.DotNet.Watcher.Internal; using Microsoft.Extensions.Logging; @@ -14,163 +10,68 @@ namespace Microsoft.DotNet.Watcher { public class DotNetWatcher { - private readonly Func _fileWatcherFactory; - private readonly Func _processWatcherFactory; - private readonly IProjectProvider _projectProvider; - private readonly ILoggerFactory _loggerFactory; - private readonly ILogger _logger; + private readonly ProcessRunner _processRunner; - public DotNetWatcher( - Func fileWatcherFactory, - Func processWatcherFactory, - IProjectProvider projectProvider, - ILoggerFactory loggerFactory) + public DotNetWatcher(ILogger logger) { - _fileWatcherFactory = fileWatcherFactory; - _processWatcherFactory = processWatcherFactory; - _projectProvider = projectProvider; - _loggerFactory = loggerFactory; + Ensure.NotNull(logger, nameof(logger)); - _logger = _loggerFactory.CreateLogger(nameof(DotNetWatcher)); + _logger = logger; + _processRunner = new ProcessRunner(logger); } - public async Task WatchAsync(string projectFile, IEnumerable dotnetArguments, CancellationToken cancellationToken) + public async Task WatchAsync(ProcessSpec processSpec, IFileSetFactory fileSetFactory, CancellationToken cancellationToken) { - if (string.IsNullOrEmpty(projectFile)) - { - throw new ArgumentNullException(nameof(projectFile)); - } - if (dotnetArguments == null) - { - throw new ArgumentNullException(nameof(dotnetArguments)); - } - if (cancellationToken == null) - { - throw new ArgumentNullException(nameof(cancellationToken)); - } + Ensure.NotNull(processSpec, nameof(processSpec)); - var dotnetArgumentsAsString = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(dotnetArguments); - - var workingDir = Path.GetDirectoryName(projectFile); + var cancelledTaskSource = new TaskCompletionSource(); + cancellationToken.Register(state => ((TaskCompletionSource)state).TrySetResult(null), cancelledTaskSource); while (true) { - await WaitForValidProjectJsonAsync(projectFile, cancellationToken); - cancellationToken.ThrowIfCancellationRequested(); + var fileSet = await fileSetFactory.CreateAsync(cancellationToken); + if (cancellationToken.IsCancellationRequested) + { + return; + } using (var currentRunCancellationSource = new CancellationTokenSource()) using (var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource( cancellationToken, currentRunCancellationSource.Token)) + using (var fileSetWatcher = new FileSetWatcher(fileSet)) { - var fileWatchingTask = WaitForProjectFileToChangeAsync(projectFile, combinedCancellationSource.Token); - var dotnetTask = WaitForDotnetToExitAsync(dotnetArgumentsAsString, workingDir, combinedCancellationSource.Token); + var fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token); + var processTask = _processRunner.RunAsync(processSpec, combinedCancellationSource.Token); - var tasksToWait = new Task[] { dotnetTask, fileWatchingTask }; + var finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task); - int finishedTaskIndex = Task.WaitAny(tasksToWait, cancellationToken); - - // Regardless of the outcome, make sure everything is cancelled + // Regardless of the which task finished first, make sure everything is cancelled // and wait for dotnet to exit. We don't want orphan processes currentRunCancellationSource.Cancel(); - Task.WaitAll(tasksToWait); - cancellationToken.ThrowIfCancellationRequested(); + await Task.WhenAll(processTask, fileSetTask); - string changedFile; - if (finishedTaskIndex == 0) - { - // This is the dotnet task - var dotnetExitCode = dotnetTask.Result; - - if (dotnetExitCode == 0) - { - _logger.LogInformation($"dotnet exit code: {dotnetExitCode}"); - } - else - { - _logger.LogError($"dotnet exit code: {dotnetExitCode}"); - } - - _logger.LogInformation("Waiting for a file to change before restarting dotnet..."); - // Now wait for a file to change before restarting dotnet - changedFile = await WaitForProjectFileToChangeAsync(projectFile, cancellationToken); - } - else - { - // This is a file watcher task - changedFile = fileWatchingTask.Result; - } - - if (!string.IsNullOrEmpty(changedFile)) - { - _logger.LogInformation($"File changed: {changedFile}"); - } - } - } - } - - private async Task WaitForProjectFileToChangeAsync(string projectFile, CancellationToken cancellationToken) - { - using (var projectWatcher = CreateProjectWatcher(projectFile, watchProjectJsonOnly: false)) - { - return await projectWatcher.WaitForChangeAsync(cancellationToken); - } - } - - private Task WaitForDotnetToExitAsync(string dotnetArguments, string workingDir, CancellationToken cancellationToken) - { - _logger.LogDebug($"Running dotnet with the following arguments: {dotnetArguments}"); - - var dotnetWatcher = _processWatcherFactory(); - int dotnetProcessId = dotnetWatcher.Start("dotnet", dotnetArguments, workingDir); - _logger.LogInformation($"dotnet process id: {dotnetProcessId}"); - - return dotnetWatcher.WaitForExitAsync(cancellationToken); - } - - private async Task WaitForValidProjectJsonAsync(string projectFile, CancellationToken cancellationToken) - { - while (true) - { - IProject project; - string errors; - if (_projectProvider.TryReadProject(projectFile, out project, out errors)) - { - return; - } - - _logger.LogError($"Error(s) reading project file '{projectFile}': "); - _logger.LogError(errors); - _logger.LogInformation("Fix the error to continue."); - - using (var projectWatcher = CreateProjectWatcher(projectFile, watchProjectJsonOnly: true)) - { - await projectWatcher.WaitForChangeAsync(cancellationToken); - - if (cancellationToken.IsCancellationRequested) + if (finishedTask == cancelledTaskSource.Task || cancellationToken.IsCancellationRequested) { return; } - _logger.LogInformation($"File changed: {projectFile}"); + if (finishedTask == processTask) + { + _logger.LogInformation("Waiting for a file to change before restarting dotnet..."); + + // Now wait for a file to change before restarting process + await fileSetWatcher.GetChangedFileAsync(cancellationToken); + } + + if (!string.IsNullOrEmpty(fileSetTask.Result)) + { + _logger.LogInformation($"File changed: {fileSetTask.Result}"); + } } } } - - private ProjectWatcher CreateProjectWatcher(string projectFile, bool watchProjectJsonOnly) - { - return new ProjectWatcher(projectFile, watchProjectJsonOnly, _fileWatcherFactory, _projectProvider); - } - - public static DotNetWatcher CreateDefault(ILoggerFactory loggerFactory) - { - return new DotNetWatcher( - fileWatcherFactory: () => new FileWatcher(), - processWatcherFactory: () => new ProcessWatcher(), - projectProvider: new ProjectProvider(), - loggerFactory: loggerFactory); - } } } diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/IProjectProvider.cs b/src/Microsoft.DotNet.Watcher.Tools/IFileSet.cs similarity index 52% rename from src/Microsoft.DotNet.Watcher.Tools/Internal/IProjectProvider.cs rename to src/Microsoft.DotNet.Watcher.Tools/IFileSet.cs index 30b2a94ee9..7554d3f542 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/Internal/IProjectProvider.cs +++ b/src/Microsoft.DotNet.Watcher.Tools/IFileSet.cs @@ -1,10 +1,12 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -namespace Microsoft.DotNet.Watcher.Internal +using System.Collections.Generic; + +namespace Microsoft.DotNet.Watcher { - public interface IProjectProvider + public interface IFileSet : IEnumerable { - bool TryReadProject(string projectFile, out IProject project, out string errors); + bool Contains(string filePath); } } diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/IProcessWatcher.cs b/src/Microsoft.DotNet.Watcher.Tools/IFileSetFactory.cs similarity index 51% rename from src/Microsoft.DotNet.Watcher.Tools/Internal/IProcessWatcher.cs rename to src/Microsoft.DotNet.Watcher.Tools/IFileSetFactory.cs index e80bd7c0a9..6a70c06a4c 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/Internal/IProcessWatcher.cs +++ b/src/Microsoft.DotNet.Watcher.Tools/IFileSetFactory.cs @@ -1,16 +1,13 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using System.Threading; using System.Threading.Tasks; -namespace Microsoft.DotNet.Watcher.Internal +namespace Microsoft.DotNet.Watcher { - public interface IProcessWatcher + public interface IFileSetFactory { - int Start(string executable, string arguments, string workingDir); - - Task WaitForExitAsync(CancellationToken cancellationToken); + Task CreateAsync(CancellationToken cancellationToken); } -} +} \ No newline at end of file diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/Ensure.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/Ensure.cs new file mode 100644 index 0000000000..12bfe5f05d --- /dev/null +++ b/src/Microsoft.DotNet.Watcher.Tools/Internal/Ensure.cs @@ -0,0 +1,30 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.DotNet.Watcher.Tools; + +namespace Microsoft.DotNet.Watcher.Internal +{ + internal static class Ensure + { + public static T NotNull(T obj, string paramName) + where T : class + { + if (obj == null) + { + throw new ArgumentNullException(paramName); + } + return obj; + } + + public static string NotNullOrEmpty(string obj, string paramName) + { + if (string.IsNullOrEmpty(obj)) + { + throw new ArgumentException(Resources.Error_StringNullOrEmpty, paramName); + } + return obj; + } + } +} diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/FileSet.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/FileSet.cs new file mode 100644 index 0000000000..5cc524e79e --- /dev/null +++ b/src/Microsoft.DotNet.Watcher.Tools/Internal/FileSet.cs @@ -0,0 +1,24 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class FileSet : IFileSet + { + private readonly HashSet _files; + + public FileSet(IEnumerable files) + { + _files = new HashSet(files, StringComparer.OrdinalIgnoreCase); + } + + public bool Contains(string filePath) => _files.Contains(filePath); + + public IEnumerator GetEnumerator() => _files.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator(); + } +} diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/FileSetWatcher.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/FileSetWatcher.cs new file mode 100644 index 0000000000..a1f56d9650 --- /dev/null +++ b/src/Microsoft.DotNet.Watcher.Tools/Internal/FileSetWatcher.cs @@ -0,0 +1,52 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class FileSetWatcher : IDisposable + { + private readonly IFileWatcher _fileWatcher; + private readonly IFileSet _fileSet; + + public FileSetWatcher(IFileSet fileSet) + { + _fileSet = fileSet; + _fileWatcher = new FileWatcher(); + } + + public async Task GetChangedFileAsync(CancellationToken cancellationToken) + { + foreach (var file in _fileSet) + { + _fileWatcher.WatchDirectory(Path.GetDirectoryName(file)); + } + + var tcs = new TaskCompletionSource(); + cancellationToken.Register(() => tcs.TrySetResult(null)); + + Action callback = path => + { + if (_fileSet.Contains(path)) + { + tcs.TrySetResult(path); + } + }; + + _fileWatcher.OnFileChange += callback; + var changedFile = await tcs.Task; + _fileWatcher.OnFileChange -= callback; + + return changedFile; + } + + public void Dispose() + { + _fileWatcher.Dispose(); + } + } +} diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/Implementation/FileWatcher.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/FileWatcher.cs similarity index 100% rename from src/Microsoft.DotNet.Watcher.Tools/Internal/Implementation/FileWatcher.cs rename to src/Microsoft.DotNet.Watcher.Tools/Internal/FileWatcher.cs diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/FileWatcher/DotnetFileWatcher.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/FileWatcher/DotnetFileWatcher.cs index e76fa088f6..4cda4f0cf8 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/Internal/FileWatcher/DotnetFileWatcher.cs +++ b/src/Microsoft.DotNet.Watcher.Tools/Internal/FileWatcher/DotnetFileWatcher.cs @@ -22,10 +22,8 @@ namespace Microsoft.DotNet.Watcher.Internal internal DotnetFileWatcher(string watchedDirectory, Func fileSystemWatcherFactory) { - if (string.IsNullOrEmpty(watchedDirectory)) - { - throw new ArgumentNullException(nameof(watchedDirectory)); - } + Ensure.NotNull(fileSystemWatcherFactory, nameof(fileSystemWatcherFactory)); + Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory)); _watchedDirectory = watchedDirectory; _watcherFactory = fileSystemWatcherFactory; @@ -38,10 +36,7 @@ namespace Microsoft.DotNet.Watcher.Internal private static FileSystemWatcher DefaultWatcherFactory(string watchedDirectory) { - if (string.IsNullOrEmpty(watchedDirectory)) - { - throw new ArgumentNullException(nameof(watchedDirectory)); - } + Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory)); return new FileSystemWatcher(watchedDirectory); } @@ -51,10 +46,7 @@ namespace Microsoft.DotNet.Watcher.Internal // Recreate the watcher CreateFileSystemWatcher(); - if (OnError != null) - { - OnError(this, null); - } + OnError?.Invoke(this, null); } private void WatcherRenameHandler(object sender, RenamedEventArgs e) @@ -81,11 +73,8 @@ namespace Microsoft.DotNet.Watcher.Internal private void NotifyChange(string fullPath) { - if (OnFileChange != null) - { - // Only report file changes - OnFileChange(this, fullPath); - } + // Only report file changes + OnFileChange?.Invoke(this, fullPath); } private void CreateFileSystemWatcher() diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/FileWatcher/PollingFileWatcher.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/FileWatcher/PollingFileWatcher.cs index 1920eecb90..9ca5084347 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/Internal/FileWatcher/PollingFileWatcher.cs +++ b/src/Microsoft.DotNet.Watcher.Tools/Internal/FileWatcher/PollingFileWatcher.cs @@ -27,10 +27,7 @@ namespace Microsoft.DotNet.Watcher.Internal public PollingFileWatcher(string watchedDirectory) { - if (string.IsNullOrEmpty(watchedDirectory)) - { - throw new ArgumentNullException(nameof(watchedDirectory)); - } + Ensure.NotNullOrEmpty(watchedDirectory, nameof(watchedDirectory)); _watchedDirectory = new DirectoryInfo(watchedDirectory); diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/IProject.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/IProject.cs deleted file mode 100644 index ca1dfb670a..0000000000 --- a/src/Microsoft.DotNet.Watcher.Tools/Internal/IProject.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System.Collections.Generic; - -namespace Microsoft.DotNet.Watcher.Internal -{ - public interface IProject - { - string ProjectFile { get; } - - IEnumerable Files { get; } - - IEnumerable ProjectDependencies { get; } - } -} diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/Implementation/ProcessWatcher.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/Implementation/ProcessWatcher.cs deleted file mode 100644 index 466ef9be17..0000000000 --- a/src/Microsoft.DotNet.Watcher.Tools/Internal/Implementation/ProcessWatcher.cs +++ /dev/null @@ -1,59 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Diagnostics; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.Extensions.Internal; - -namespace Microsoft.DotNet.Watcher.Internal -{ - public class ProcessWatcher : IProcessWatcher - { - private Process _runningProcess; - - public int Start(string executable, string arguments, string workingDir) - { - // This is not thread safe but it will not run in a multithreaded environment so don't worry - if (_runningProcess != null) - { - throw new InvalidOperationException("The previous process is still running"); - } - - _runningProcess = new Process(); - _runningProcess.StartInfo = new ProcessStartInfo() - { - FileName = executable, - Arguments = arguments, - UseShellExecute = false, - WorkingDirectory = workingDir - }; - - _runningProcess.Start(); - - return _runningProcess.Id; - } - - public Task WaitForExitAsync(CancellationToken cancellationToken) - { - cancellationToken.Register(() => - { - if (_runningProcess != null) - { - _runningProcess.KillTree(); - } - }); - - return Task.Run(() => - { - _runningProcess.WaitForExit(); - - var exitCode = _runningProcess.ExitCode; - _runningProcess = null; - - return exitCode; - }); - } - } -} \ No newline at end of file diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/IncludeContextExtensions.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/IncludeContextExtensions.cs index d01703999d..bd7cf0b8ad 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/Internal/IncludeContextExtensions.cs +++ b/src/Microsoft.DotNet.Watcher.Tools/Internal/IncludeContextExtensions.cs @@ -1,7 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using System.Collections.Generic; using System.Linq; using Microsoft.DotNet.ProjectModel.Files; @@ -12,10 +11,7 @@ namespace Microsoft.DotNet.Watcher.Internal { public static IEnumerable ResolveFiles(this IncludeContext context) { - if (context == null) - { - throw new ArgumentNullException(nameof(context)); - } + Ensure.NotNull(context, nameof(context)); return IncludeFilesResolver .GetIncludeFiles(context, "/", diagnostics: null) diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/ProcessRunner.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/ProcessRunner.cs new file mode 100644 index 0000000000..f8d9260039 --- /dev/null +++ b/src/Microsoft.DotNet.Watcher.Tools/Internal/ProcessRunner.cs @@ -0,0 +1,126 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Diagnostics; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Internal; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class ProcessRunner + { + private readonly ILogger _logger; + + public ProcessRunner(ILogger logger) + { + Ensure.NotNull(logger, nameof(logger)); + + _logger = logger; + } + + // May not be necessary in the future. See https://github.com/dotnet/corefx/issues/12039 + public async Task RunAsync(ProcessSpec processSpec, CancellationToken cancellationToken) + { + Ensure.NotNull(processSpec, nameof(processSpec)); + + int exitCode; + + using (var process = CreateProcess(processSpec)) + using (var processState = new ProcessState(process)) + { + cancellationToken.Register(() => processState.TryKill()); + + process.Start(); + _logger.LogInformation("{execName} process id: {pid}", processSpec.ShortDisplayName(), process.Id); + + await processState.Task; + + exitCode = process.ExitCode; + } + + LogResult(processSpec, exitCode); + + return exitCode; + } + + private Process CreateProcess(ProcessSpec processSpec) + { + var arguments = ArgumentEscaper.EscapeAndConcatenateArgArrayForProcessStart(processSpec.Arguments); + + _logger.LogInformation("Running {execName} with the following arguments: {args}", processSpec.ShortDisplayName(), arguments); + + var startInfo = new ProcessStartInfo + { + FileName = processSpec.Executable, + Arguments = arguments, + UseShellExecute = false, + WorkingDirectory = processSpec.WorkingDirectory + }; + var process = new Process + { + StartInfo = startInfo, + EnableRaisingEvents = true + }; + return process; + } + + private void LogResult(ProcessSpec processSpec, int exitCode) + { + var processName = processSpec.ShortDisplayName(); + if (exitCode == 0) + { + _logger.LogInformation("{execName} exit code: {code}", processName, exitCode); + } + else + { + _logger.LogError("{execName} exit code: {code}", processName, exitCode); + } + } + + private class ProcessState : IDisposable + { + private readonly Process _process; + private readonly TaskCompletionSource _tcs = new TaskCompletionSource(); + private volatile bool _disposed; + + public ProcessState(Process process) + { + _process = process; + _process.Exited += OnExited; + } + + public Task Task => _tcs.Task; + + public void TryKill() + { + try + { + if (!_process.HasExited) + { + _process.KillTree(); + } + } + catch + { } + } + + private void OnExited(object sender, EventArgs args) + => _tcs.TrySetResult(null); + + public void Dispose() + { + if (!_disposed) + { + _disposed = true; + TryKill(); + _process.Exited -= OnExited; + _process.Dispose(); + } + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/Implementation/Project.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/Project.cs similarity index 96% rename from src/Microsoft.DotNet.Watcher.Tools/Internal/Implementation/Project.cs rename to src/Microsoft.DotNet.Watcher.Tools/Internal/Project.cs index 10d8624df3..8485cc331b 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/Internal/Implementation/Project.cs +++ b/src/Microsoft.DotNet.Watcher.Tools/Internal/Project.cs @@ -1,16 +1,14 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. -using System; using System.Collections.Generic; using System.IO; using System.Linq; -using Microsoft.DotNet.ProjectModel.Files; using Microsoft.DotNet.ProjectModel.Graph; namespace Microsoft.DotNet.Watcher.Internal { - internal class Project : IProject + public class Project { public Project(ProjectModel.Project runtimeProject) { diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectJsonFileSet.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectJsonFileSet.cs new file mode 100644 index 0000000000..c9d67daf5c --- /dev/null +++ b/src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectJsonFileSet.cs @@ -0,0 +1,95 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections; +using System.Collections.Generic; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class ProjectJsonFileSet : IFileSet + { + private readonly string _projectFile; + private ISet _currentFiles; + + public ProjectJsonFileSet(string projectFile) + { + _projectFile = projectFile; + } + + public bool Contains(string filePath) + { + // if it was in the original list of files we were watching + if (_currentFiles?.Contains(filePath) == true) + { + return true; + } + + // It's possible the new file was not in the old set but will be in the new set. + // Additions should be considered part of this. + RefreshFileList(); + + return _currentFiles.Contains(filePath); + } + + public IEnumerator GetEnumerator() + { + EnsureInitialized(); + return _currentFiles.GetEnumerator(); + } + + IEnumerator IEnumerable.GetEnumerator() + { + EnsureInitialized(); + return _currentFiles.GetEnumerator(); + } + + private void EnsureInitialized() + { + if (_currentFiles == null) + { + RefreshFileList(); + } + } + + private void RefreshFileList() + { + _currentFiles = new HashSet(FindFiles(), StringComparer.OrdinalIgnoreCase); + } + + private IEnumerable FindFiles() + { + var projects = new HashSet(); // temporary store to prevent re-parsing a project multiple times + return GetProjectFilesClosure(_projectFile, projects); + } + + private IEnumerable GetProjectFilesClosure(string projectFile, ISet projects) + { + if (projects.Contains(projectFile)) + { + yield break; + } + + projects.Add(projectFile); + + Project project; + string errors; + + if (ProjectReader.TryReadProject(projectFile, out project, out errors)) + { + foreach (var file in project.Files) + { + yield return file; + } + + foreach (var dependency in project.ProjectDependencies) + { + foreach (var file in GetProjectFilesClosure(dependency, projects)) + { + yield return file; + } + } + } + } + } +} diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectJsonFileSetFactory.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectJsonFileSetFactory.cs new file mode 100644 index 0000000000..419776d4dc --- /dev/null +++ b/src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectJsonFileSetFactory.cs @@ -0,0 +1,51 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class ProjectJsonFileSetFactory : IFileSetFactory + { + private readonly ILogger _logger; + private readonly string _projectFile; + public ProjectJsonFileSetFactory(ILogger logger, string projectFile) + { + Ensure.NotNull(logger, nameof(logger)); + Ensure.NotNullOrEmpty(projectFile, nameof(projectFile)); + + _logger = logger; + _projectFile = projectFile; + } + + public async Task CreateAsync(CancellationToken cancellationToken) + { + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + Project project; + string errors; + if (ProjectReader.TryReadProject(_projectFile, out project, out errors)) + { + return new ProjectJsonFileSet(_projectFile); + } + + _logger.LogError($"Error(s) reading project file '{_projectFile}': "); + _logger.LogError(errors); + _logger.LogInformation("Fix the error to continue."); + + var fileSet = new FileSet(new[] { _projectFile }); + + using (var watcher = new FileSetWatcher(fileSet)) + { + await watcher.GetChangedFileAsync(cancellationToken); + + _logger.LogInformation($"File changed: {_projectFile}"); + } + } + } + } +} diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/Implementation/ProjectProvider.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectReaderUtils.cs similarity index 81% rename from src/Microsoft.DotNet.Watcher.Tools/Internal/Implementation/ProjectProvider.cs rename to src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectReaderUtils.cs index 6d1cf3d99e..ffd2e012f4 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/Internal/Implementation/ProjectProvider.cs +++ b/src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectReaderUtils.cs @@ -2,16 +2,14 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; -using System.Collections.Generic; using System.Linq; using System.Text; -using Microsoft.DotNet.ProjectModel; namespace Microsoft.DotNet.Watcher.Internal { - public class ProjectProvider : IProjectProvider + public class ProjectReader { - public bool TryReadProject(string projectFile, out IProject project, out string errors) + public static bool TryReadProject(string projectFile, out Project project, out string errors) { errors = null; project = null; @@ -35,12 +33,11 @@ namespace Microsoft.DotNet.Watcher.Internal return true; } - // Same as TryGetProject but it doesn't throw - private bool TryGetProject(string projectFile, out ProjectModel.Project project, out string errorMessage) + private static bool TryGetProject(string projectFile, out ProjectModel.Project project, out string errorMessage) { try { - if (!ProjectReader.TryGetProject(projectFile, out project)) + if (!ProjectModel.ProjectReader.TryGetProject(projectFile, out project)) { if (project?.Diagnostics != null && project.Diagnostics.Any()) { @@ -66,7 +63,7 @@ namespace Microsoft.DotNet.Watcher.Internal return false; } - private string CollectMessages(Exception exception) + private static string CollectMessages(Exception exception) { var builder = new StringBuilder(); builder.AppendLine(exception.Message); diff --git a/src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectWatcher.cs b/src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectWatcher.cs deleted file mode 100644 index 6b5fce3ab5..0000000000 --- a/src/Microsoft.DotNet.Watcher.Tools/Internal/ProjectWatcher.cs +++ /dev/null @@ -1,119 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.IO; -using System.Threading; -using System.Threading.Tasks; - -namespace Microsoft.DotNet.Watcher.Internal -{ - public class ProjectWatcher : IDisposable - { - private readonly IProjectProvider _projectProvider; - private readonly IFileWatcher _fileWatcher; - - private readonly string _rootProject; - private readonly bool _watchProjectJsonOnly; - - private ISet _watchedFiles; - - public ProjectWatcher( - string projectToWatch, - bool watchProjectJsonOnly, - Func fileWatcherFactory, - IProjectProvider projectProvider) - { - _projectProvider = projectProvider; - _fileWatcher = fileWatcherFactory(); - - _rootProject = projectToWatch; - _watchProjectJsonOnly = watchProjectJsonOnly; - } - - public async Task WaitForChangeAsync(CancellationToken cancellationToken) - { - _watchedFiles = GetProjectFilesClosure(_rootProject); - - foreach (var file in _watchedFiles) - { - _fileWatcher.WatchDirectory(Path.GetDirectoryName(file)); - } - - var tcs = new TaskCompletionSource(); - cancellationToken.Register(() => tcs.TrySetResult(null)); - - Action callback = path => - { - // If perf becomes a problem, this could be a good starting point - // because it reparses the project on every change - // Maybe it could time-buffer the changes in case there are a lot - // of files changed at the same time - if (IsFileInTheWatchedSet(path)) - { - tcs.TrySetResult(path); - } - }; - - _fileWatcher.OnFileChange += callback; - var changedFile = await tcs.Task; - _fileWatcher.OnFileChange -= callback; - - return changedFile; - } - - public void Dispose() - { - _fileWatcher?.Dispose(); - } - - private bool IsFileInTheWatchedSet(string file) - { - // If the file was already watched - // or if the new project file closure determined - // by file globbing patterns contains the new file - // Note, we cannot simply rebuild the closure every time because it wouldn't - // detect renamed files that have the new name outside of the closure - return - _watchedFiles.Contains(file) || - GetProjectFilesClosure(_rootProject).Contains(file); - } - - private ISet GetProjectFilesClosure(string projectFile) - { - var closure = new HashSet(); - - if (_watchProjectJsonOnly) - { - closure.Add(projectFile); - } - else - { - GetProjectFilesClosure(projectFile, closure); - } - return closure; - } - - private void GetProjectFilesClosure(string projectFile, ISet closure) - { - closure.Add(projectFile); - - IProject project; - string errors; - - if (_projectProvider.TryReadProject(projectFile, out project, out errors)) - { - foreach (var file in project.Files) - { - closure.Add(file); - } - - foreach (var dependency in project.ProjectDependencies) - { - GetProjectFilesClosure(dependency, closure); - } - } - } - } -} diff --git a/src/Microsoft.DotNet.Watcher.Tools/Microsoft.DotNet.Watcher.Tools.xproj b/src/Microsoft.DotNet.Watcher.Tools/Microsoft.DotNet.Watcher.Tools.xproj index f6e8a5813a..6ce5b43fae 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/Microsoft.DotNet.Watcher.Tools.xproj +++ b/src/Microsoft.DotNet.Watcher.Tools/Microsoft.DotNet.Watcher.Tools.xproj @@ -4,7 +4,7 @@ 14.0 $(MSBuildExtensionsPath32)\Microsoft\VisualStudio\v$(VisualStudioVersion) - + 8a8ceabc-ac47-43ff-a5df-69224f7e1f46 .\obj @@ -16,5 +16,5 @@ 2.0 - + \ No newline at end of file diff --git a/src/Microsoft.DotNet.Watcher.Tools/ProcessSpec.cs b/src/Microsoft.DotNet.Watcher.Tools/ProcessSpec.cs new file mode 100644 index 0000000000..2d18b9a0be --- /dev/null +++ b/src/Microsoft.DotNet.Watcher.Tools/ProcessSpec.cs @@ -0,0 +1,18 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.IO; + +namespace Microsoft.DotNet.Watcher +{ + public class ProcessSpec + { + public string Executable { get; set; } + public string WorkingDirectory { get; set; } + public IEnumerable Arguments { get; set; } + + public string ShortDisplayName() + => Path.GetFileNameWithoutExtension(Executable); + } +} diff --git a/src/Microsoft.DotNet.Watcher.Tools/Program.cs b/src/Microsoft.DotNet.Watcher.Tools/Program.cs index 71cdc7f5d6..14a115c978 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/Program.cs +++ b/src/Microsoft.DotNet.Watcher.Tools/Program.cs @@ -5,29 +5,23 @@ using System; using System.IO; using System.Threading; using System.Threading.Tasks; -using Microsoft.Extensions.Logging; using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.Watcher.Internal; +using Microsoft.Extensions.Logging; namespace Microsoft.DotNet.Watcher { public class Program { - private readonly ILoggerFactory _loggerFactory = new LoggerFactory(); + private const string LoggerName = "DotNetWatcher"; private readonly CancellationToken _cancellationToken; private readonly TextWriter _stdout; private readonly TextWriter _stderr; public Program(TextWriter consoleOutput, TextWriter consoleError, CancellationToken cancellationToken) { - if (consoleOutput == null) - { - throw new ArgumentNullException(nameof(consoleOutput)); - } - - if (cancellationToken == null) - { - throw new ArgumentNullException(nameof(cancellationToken)); - } + Ensure.NotNull(consoleOutput, nameof(consoleOutput)); + Ensure.NotNull(consoleError, nameof(consoleError)); _cancellationToken = cancellationToken; _stdout = consoleOutput; @@ -36,28 +30,43 @@ namespace Microsoft.DotNet.Watcher public static int Main(string[] args) { + DebugHelper.HandleDebugSwitch(ref args); + using (CancellationTokenSource ctrlCTokenSource = new CancellationTokenSource()) { Console.CancelKeyPress += (sender, ev) => { + if (!ctrlCTokenSource.IsCancellationRequested) + { + Console.WriteLine($"[{LoggerName}] Shutdown requested. Press CTRL+C again to force exit."); + ev.Cancel = true; + } + else + { + ev.Cancel = false; + } ctrlCTokenSource.Cancel(); - ev.Cancel = false; }; - int exitCode; try { - exitCode = new Program(Console.Out, Console.Error, ctrlCTokenSource.Token) + return new Program(Console.Out, Console.Error, ctrlCTokenSource.Token) .MainInternalAsync(args) .GetAwaiter() .GetResult(); } - catch (TaskCanceledException) + catch (Exception ex) { - // swallow when only exception is the CTRL+C exit cancellation task - exitCode = 0; + if (ex is TaskCanceledException || ex is OperationCanceledException) + { + // swallow when only exception is the CTRL+C forced an exit + return 0; + } + + Console.Error.WriteLine(ex.ToString()); + Console.Error.WriteLine($"[{LoggerName}] An unexpected error occurred".Bold().Red()); + return 1; } - return exitCode; } } @@ -75,17 +84,25 @@ namespace Microsoft.DotNet.Watcher return 2; } + var loggerFactory = new LoggerFactory(); var commandProvider = new CommandOutputProvider { LogLevel = ResolveLogLevel(options) }; - _loggerFactory.AddProvider(commandProvider); + loggerFactory.AddProvider(commandProvider); + var logger = loggerFactory.CreateLogger(LoggerName); - var projectToWatch = Path.Combine(Directory.GetCurrentDirectory(), ProjectModel.Project.FileName); + var projectFile = Path.Combine(Directory.GetCurrentDirectory(), ProjectModel.Project.FileName); + var projectFileSetFactory = new ProjectJsonFileSetFactory(logger, projectFile); + var processInfo = new ProcessSpec + { + Executable = new Muxer().MuxerPath, + WorkingDirectory = Path.GetDirectoryName(projectFile), + Arguments = options.RemainingArguments + }; - await DotNetWatcher - .CreateDefault(_loggerFactory) - .WatchAsync(projectToWatch, options.RemainingArguments, _cancellationToken); + await new DotNetWatcher(logger) + .WatchAsync(processInfo, projectFileSetFactory, _cancellationToken); return 0; } @@ -97,7 +114,7 @@ namespace Microsoft.DotNet.Watcher return LogLevel.Warning; } - bool globalVerbose; + bool globalVerbose; bool.TryParse(Environment.GetEnvironmentVariable(CommandContext.Variables.Verbose), out globalVerbose); if (options.IsVerbose // dotnet watch --verbose diff --git a/src/Microsoft.DotNet.Watcher.Tools/Properties/Resources.Designer.cs b/src/Microsoft.DotNet.Watcher.Tools/Properties/Resources.Designer.cs index 92987141e4..5cdf225bb8 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/Properties/Resources.Designer.cs +++ b/src/Microsoft.DotNet.Watcher.Tools/Properties/Resources.Designer.cs @@ -26,6 +26,22 @@ namespace Microsoft.DotNet.Watcher.Tools return GetString("Error_QuietAndVerboseSpecified"); } + /// + /// Value cannot be null or an empty string. + /// + internal static string Error_StringNullOrEmpty + { + get { return GetString("Error_StringNullOrEmpty"); } + } + + /// + /// Value cannot be null or an empty string. + /// + internal static string FormatError_StringNullOrEmpty() + { + return GetString("Error_StringNullOrEmpty"); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.DotNet.Watcher.Tools/Resources.resx b/src/Microsoft.DotNet.Watcher.Tools/Resources.resx index 47ccd0a26c..34336a97ba 100644 --- a/src/Microsoft.DotNet.Watcher.Tools/Resources.resx +++ b/src/Microsoft.DotNet.Watcher.Tools/Resources.resx @@ -120,4 +120,7 @@ Cannot specify both '--quiet' and '--verbose' options. + + Value cannot be null or an empty string. + \ No newline at end of file diff --git a/test/Microsoft.DotNet.Watcher.Tools.FunctionalTests/Scenario/ProjectToolScenario.cs b/test/Microsoft.DotNet.Watcher.Tools.FunctionalTests/Scenario/ProjectToolScenario.cs index 41ac05c762..c0c2ce0016 100644 --- a/test/Microsoft.DotNet.Watcher.Tools.FunctionalTests/Scenario/ProjectToolScenario.cs +++ b/test/Microsoft.DotNet.Watcher.Tools.FunctionalTests/Scenario/ProjectToolScenario.cs @@ -5,14 +5,11 @@ using System; using System.Collections.Generic; using System.Diagnostics; using System.IO; -using System.Threading; -using System.Xml.Linq; -using Newtonsoft.Json.Linq; -using Microsoft.DotNet.Cli.Utils; -using Microsoft.Extensions.DependencyModel; -using Microsoft.DotNet.ProjectModel; -using System.Reflection; using System.Linq; +using System.Reflection; +using System.Threading; +using Microsoft.DotNet.Cli.Utils; +using Microsoft.DotNet.ProjectModel; namespace Microsoft.DotNet.Watcher.Tools.FunctionalTests {