diff --git a/docs/reference/commandline/tye-run.md b/docs/reference/commandline/tye-run.md index a98342bb..69ea2339 100644 --- a/docs/reference/commandline/tye-run.md +++ b/docs/reference/commandline/tye-run.md @@ -62,6 +62,10 @@ If a directory path is specified, `tye run` will default to using these files, i The default value is `info` +- `--watch` + + Watches for file changes in all projects that are built by tye. Uses [`dotnet watch`](https://docs.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-3.1) to monitor for file changes. + ## Examples - Run an application in the current directory: diff --git a/src/Microsoft.Tye.Core/ArgumentEscaper.cs b/src/Microsoft.Tye.Core/ArgumentEscaper.cs new file mode 100644 index 00000000..897468cb --- /dev/null +++ b/src/Microsoft.Tye.Core/ArgumentEscaper.cs @@ -0,0 +1,107 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; +using System.Linq; +using System.Text; + +namespace Microsoft.Tye +{ + public static class ArgumentEscaper + { + /// + /// Undo the processing which took place to create string[] args in Main, so that the next process will + /// receive the same string[] args. + /// + /// + /// See https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/ + /// + /// The arguments to concatenate. + /// The escaped arguments, concatenated. + public static string EscapeAndConcatenate(IEnumerable args) + => string.Join(" ", args.Select(EscapeSingleArg)); + + private static string EscapeSingleArg(string arg) + { + var sb = new StringBuilder(); + + var needsQuotes = ShouldSurroundWithQuotes(arg); + var isQuoted = needsQuotes || IsSurroundedWithQuotes(arg); + + if (needsQuotes) + { + sb.Append('"'); + } + + for (int i = 0; i < arg.Length; ++i) + { + var backslashes = 0; + + // Consume all backslashes + while (i < arg.Length && arg[i] == '\\') + { + backslashes++; + i++; + } + + if (i == arg.Length && isQuoted) + { + // Escape any backslashes at the end of the arg when the argument is also quoted. + // This ensures the outside quote is interpreted as an argument delimiter + sb.Append('\\', 2 * backslashes); + } + else if (i == arg.Length) + { + // At then end of the arg, which isn't quoted, + // just add the backslashes, no need to escape + sb.Append('\\', backslashes); + } + else if (arg[i] == '"') + { + // Escape any preceding backslashes and the quote + sb.Append('\\', (2 * backslashes) + 1); + sb.Append('"'); + } + else + { + // Output any consumed backslashes and the character + sb.Append('\\', backslashes); + sb.Append(arg[i]); + } + } + + if (needsQuotes) + { + sb.Append('"'); + } + + return sb.ToString(); + } + + private static bool ShouldSurroundWithQuotes(string argument) + { + // Don't quote already quoted strings + if (IsSurroundedWithQuotes(argument)) + { + return false; + } + + // Only quote if whitespace exists in the string + return ContainsWhitespace(argument); + } + + private static bool IsSurroundedWithQuotes(string argument) + { + if (argument.Length <= 1) + { + return false; + } + + return argument[0] == '"' && argument[argument.Length - 1] == '"'; + } + + private static bool ContainsWhitespace(string argument) + => argument.IndexOfAny(new[] { ' ', '\t', '\n' }) >= 0; + } +} diff --git a/src/Microsoft.Tye.Core/HostOptions.cs b/src/Microsoft.Tye.Core/HostOptions.cs index 965059ad..73135cfd 100644 --- a/src/Microsoft.Tye.Core/HostOptions.cs +++ b/src/Microsoft.Tye.Core/HostOptions.cs @@ -25,5 +25,7 @@ namespace Microsoft.Tye public int? Port { get; set; } public Verbosity LogVerbosity { get; set; } = Verbosity.Debug; + + public bool Watch { get; set; } } } diff --git a/src/Microsoft.Tye.Core/ProcessExtensions.cs b/src/Microsoft.Tye.Core/ProcessExtensions.cs new file mode 100644 index 00000000..a3885af2 --- /dev/null +++ b/src/Microsoft.Tye.Core/ProcessExtensions.cs @@ -0,0 +1,122 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.ComponentModel; +using System.Diagnostics; +using System.IO; +using System.Runtime.InteropServices; + +namespace Microsoft.Tye +{ + internal static class ProcessExtensions + { + private static readonly bool _isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows); + private static readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30); + + public static void KillTree(this System.Diagnostics.Process process) => process.KillTree(_defaultTimeout); + + public static void KillTree(this System.Diagnostics.Process process, TimeSpan timeout) + { + var pid = process.Id; + if (_isWindows) + { + RunProcessAndWaitForExit( + "taskkill", + $"/T /F /PID {pid}", + timeout, + out var _); + } + else + { + var children = new HashSet(); + GetAllChildIdsUnix(pid, children, timeout); + foreach (var childId in children) + { + KillProcessUnix(childId, timeout); + } + KillProcessUnix(pid, timeout); + } + } + + private static void GetAllChildIdsUnix(int parentId, ISet children, TimeSpan timeout) + { + try + { + RunProcessAndWaitForExit( + "pgrep", + $"-P {parentId}", + timeout, + out var stdout); + + if (!string.IsNullOrEmpty(stdout)) + { + using (var reader = new StringReader(stdout)) + { + while (true) + { + var text = reader.ReadLine(); + if (text == null) + { + return; + } + + if (int.TryParse(text, out var id)) + { + children.Add(id); + // Recursively get the children + GetAllChildIdsUnix(id, children, timeout); + } + } + } + } + } + catch (Win32Exception ex) when (ex.Message.Contains("No such file or directory")) + { + // This probably means that pgrep isn't installed. Nothing to be done? + } + } + + private static void KillProcessUnix(int processId, TimeSpan timeout) + { + try + { + RunProcessAndWaitForExit( + "kill", + $"-TERM {processId}", + timeout, + out var stdout); + } + catch (Win32Exception ex) when (ex.Message.Contains("No such file or directory")) + { + // This probably means that the process is already dead + } + } + + private static void RunProcessAndWaitForExit(string fileName, string arguments, TimeSpan timeout, out string? stdout) + { + var startInfo = new ProcessStartInfo + { + FileName = fileName, + Arguments = arguments, + RedirectStandardOutput = true, + RedirectStandardError = true, + UseShellExecute = false, + }; + + var process = System.Diagnostics.Process.Start(startInfo); + + stdout = null; + if (process.WaitForExit((int)timeout.TotalMilliseconds)) + { + stdout = process.StandardOutput.ReadToEnd(); + } + else + { + process.Kill(); + } + } + } +} diff --git a/src/Microsoft.Tye.Core/ProcessSpec.cs b/src/Microsoft.Tye.Core/ProcessSpec.cs new file mode 100644 index 00000000..7952726c --- /dev/null +++ b/src/Microsoft.Tye.Core/ProcessSpec.cs @@ -0,0 +1,24 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; + +namespace Microsoft.Tye +{ + public class ProcessSpec + { + public string? Executable { get; set; } + public string? WorkingDirectory { get; set; } + public IDictionary EnvironmentVariables { get; set; } = new Dictionary(); + public string? Arguments { get; set; } + public Action? OutputData { get; set; } + public Action? ErrorData { get; set; } + public Action? OnStart { get; set; } + public string? ShortDisplayName() + => Path.GetFileNameWithoutExtension(Executable); + } +} diff --git a/src/Microsoft.Tye.Core/ProcessUtil.cs b/src/Microsoft.Tye.Core/ProcessUtil.cs index 60d9a133..0c3f6000 100644 --- a/src/Microsoft.Tye.Core/ProcessUtil.cs +++ b/src/Microsoft.Tye.Core/ProcessUtil.cs @@ -42,7 +42,6 @@ namespace Microsoft.Tye EnableRaisingEvents = true }; - if (workingDirectory != null) { process.StartInfo.WorkingDirectory = workingDirectory; @@ -140,6 +139,11 @@ namespace Microsoft.Tye return await processLifetimeTask.Task; } + public static Task RunAsync(ProcessSpec processSpec, CancellationToken cancellationToken = default, bool throwOnError = true) + { + return RunAsync(processSpec.Executable!, processSpec.Arguments!, processSpec.WorkingDirectory, throwOnError: throwOnError, processSpec.EnvironmentVariables, processSpec.OutputData, processSpec.ErrorData, processSpec.OnStart, cancellationToken); + } + public static void KillProcess(int pid) { try diff --git a/src/Microsoft.Tye.Hosting/Microsoft.Tye.Hosting.csproj b/src/Microsoft.Tye.Hosting/Microsoft.Tye.Hosting.csproj index d9a13d02..7b22cd00 100644 --- a/src/Microsoft.Tye.Hosting/Microsoft.Tye.Hosting.csproj +++ b/src/Microsoft.Tye.Hosting/Microsoft.Tye.Hosting.csproj @@ -13,6 +13,10 @@ + + + + diff --git a/src/Microsoft.Tye.Hosting/ProcessRunner.cs b/src/Microsoft.Tye.Hosting/ProcessRunner.cs index da731591..d156677f 100644 --- a/src/Microsoft.Tye.Hosting/ProcessRunner.cs +++ b/src/Microsoft.Tye.Hosting/ProcessRunner.cs @@ -11,6 +11,8 @@ using System.Runtime.InteropServices; using System.Text; using System.Threading; using System.Threading.Tasks; +using Microsoft.DotNet.Watcher; +using Microsoft.DotNet.Watcher.Internal; using Microsoft.Extensions.Logging; using Microsoft.Tye.Hosting.Model; @@ -22,7 +24,6 @@ namespace Microsoft.Tye.Hosting private readonly ILogger _logger; private readonly ProcessRunnerOptions _options; - private readonly ReplicaRegistry _replicaRegistry; public ProcessRunner(ILogger logger, ReplicaRegistry replicaRegistry, ProcessRunnerOptions options) @@ -268,15 +269,18 @@ namespace Microsoft.Tye.Hosting try { service.Logs.OnNext($"[{replica}]:{path} {args}"); - - var result = await ProcessUtil.RunAsync( - path, - args, - environmentVariables: environment, - workingDirectory: workingDirectory, - outputDataReceived: data => service.Logs.OnNext($"[{replica}]: {data}"), - errorDataReceived: data => service.Logs.OnNext($"[{replica}]: {data}"), - onStart: pid => + var processInfo = new ProcessSpec + { + Executable = path, + WorkingDirectory = workingDirectory, + Arguments = args, + EnvironmentVariables = environment, + OutputData = data => + { + service.Logs.OnNext($"[{replica}]: {data}"); + }, + ErrorData = data => service.Logs.OnNext($"[{replica}]: {data}"), + OnStart = pid => { if (hasPorts) { @@ -294,16 +298,34 @@ namespace Microsoft.Tye.Hosting WriteReplicaToStore(pid.ToString()); service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Started, status)); - }, - throwOnError: false, - cancellationToken: status.StoppingTokenSource.Token); + } + }; - status.ExitCode = result.ExitCode; + if (_options.Watch && service.Description.RunInfo is ProjectRunInfo runInfo) + { + var projectFile = runInfo.ProjectFile.FullName; - if (status.Pid != null) + var fileSetFactory = new MsBuildFileSetFactory(_logger, + projectFile, + waitOnError: true, + trace: false); + environment["DOTNET_WATCH"] = "1"; + + await new DotNetWatcher(_logger) + .WatchAsync(processInfo, fileSetFactory, replica, status.StoppingTokenSource.Token); + } + else { - service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Stopped, status)); + var result = await ProcessUtil.RunAsync(processInfo, status.StoppingTokenSource.Token, throwOnError: false); + + status.ExitCode = result.ExitCode; + + if (status.Pid != null) + { + service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Stopped, status)); + } } + } catch (Exception ex) { diff --git a/src/Microsoft.Tye.Hosting/ProcessRunnerOptions.cs b/src/Microsoft.Tye.Hosting/ProcessRunnerOptions.cs index c311753f..69981427 100644 --- a/src/Microsoft.Tye.Hosting/ProcessRunnerOptions.cs +++ b/src/Microsoft.Tye.Hosting/ProcessRunnerOptions.cs @@ -13,6 +13,7 @@ namespace Microsoft.Tye.Hosting public bool BuildProjects { get; set; } public string[]? ServicesToDebug { get; set; } public bool DebugAllServices { get; set; } + public bool Watch { get; set; } public static ProcessRunnerOptions FromHostOptions(HostOptions options) { @@ -21,7 +22,8 @@ namespace Microsoft.Tye.Hosting BuildProjects = !options.NoBuild, DebugMode = options.Debug.Any(), ServicesToDebug = options.Debug.ToArray(), - DebugAllServices = options.Debug?.Contains("*", StringComparer.OrdinalIgnoreCase) ?? false + DebugAllServices = options.Debug?.Contains("*", StringComparer.OrdinalIgnoreCase) ?? false, + Watch = options.Watch }; } } diff --git a/src/Microsoft.Tye.Hosting/Watch/DotNetWatcher.cs b/src/Microsoft.Tye.Hosting/Watch/DotNetWatcher.cs new file mode 100644 index 00000000..77fb42e1 --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/DotNetWatcher.cs @@ -0,0 +1,102 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Globalization; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.DotNet.Watcher.Internal; +using Microsoft.Extensions.Logging; +using Microsoft.Tye; + +namespace Microsoft.DotNet.Watcher +{ + public class DotNetWatcher + { + private readonly ILogger _logger; + + public DotNetWatcher(ILogger logger) + { + _logger = logger; + } + + public async Task WatchAsync(ProcessSpec processSpec, IFileSetFactory fileSetFactory, string replica, + CancellationToken cancellationToken) + { + var cancelledTaskSource = new TaskCompletionSource(); + cancellationToken.Register(state => ((TaskCompletionSource)state!).TrySetResult(null!), + cancelledTaskSource); + + var iteration = 1; + + while (true) + { + processSpec.EnvironmentVariables["DOTNET_WATCH_ITERATION"] = iteration.ToString(CultureInfo.InvariantCulture); + iteration++; + + var fileSet = await fileSetFactory.CreateAsync(cancellationToken); + + if (fileSet == null) + { + _logger.LogError("watch: Failed to find a list of files to watch"); + return; + } + + if (cancellationToken.IsCancellationRequested) + { + return; + } + + using (var currentRunCancellationSource = new CancellationTokenSource()) + using (var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource( + cancellationToken, + currentRunCancellationSource.Token)) + using (var fileSetWatcher = new FileSetWatcher(fileSet, _logger)) + { + var fileSetTask = fileSetWatcher.GetChangedFileAsync(combinedCancellationSource.Token); + var processTask = ProcessUtil.RunAsync(processSpec, combinedCancellationSource.Token, throwOnError: false); + + var args = processSpec.Arguments!; + _logger.LogDebug($"Running {processSpec.ShortDisplayName()} with the following arguments: {args}"); + + _logger.LogInformation("watch: {Replica} Started", replica); + + var finishedTask = await Task.WhenAny(processTask, fileSetTask, cancelledTaskSource.Task); + + // 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(); + + await Task.WhenAll(processTask, fileSetTask); + + if (processTask.Result.ExitCode != 0 && finishedTask == processTask && !cancellationToken.IsCancellationRequested) + { + // Only show this error message if the process exited non-zero due to a normal process exit. + // Don't show this if dotnet-watch killed the inner process due to file change or CTRL+C by the user + _logger.LogError($"watch: Exited with error code {processTask.Result}"); + } + else + { + _logger.LogInformation("watch: {Replica} Exited", replica); + } + + if (finishedTask == cancelledTaskSource.Task || cancellationToken.IsCancellationRequested) + { + return; + } + + if (finishedTask == processTask) + { + // Now wait for a file to change before restarting process + await fileSetWatcher.GetChangedFileAsync(cancellationToken, () => _logger.LogWarning("Waiting for a file to change before restarting dotnet...")); + } + + if (!string.IsNullOrEmpty(fileSetTask.Result)) + { + _logger.LogInformation($"watch: File changed: {fileSetTask.Result}"); + } + } + } + } + } +} diff --git a/src/Microsoft.Tye.Hosting/Watch/IFileSet.cs b/src/Microsoft.Tye.Hosting/Watch/IFileSet.cs new file mode 100644 index 00000000..cdaae40d --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/IFileSet.cs @@ -0,0 +1,13 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Collections.Generic; + +namespace Microsoft.DotNet.Watcher +{ + public interface IFileSet : IEnumerable + { + bool Contains(string filePath); + } +} diff --git a/src/Microsoft.Tye.Hosting/Watch/IFileSetFactory.cs b/src/Microsoft.Tye.Hosting/Watch/IFileSetFactory.cs new file mode 100644 index 00000000..604cc0c8 --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/IFileSetFactory.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.DotNet.Watcher +{ + public interface IFileSetFactory + { + Task CreateAsync(CancellationToken cancellationToken); + } +} diff --git a/src/Microsoft.Tye.Hosting/Watch/Internal/FileSet.cs b/src/Microsoft.Tye.Hosting/Watch/Internal/FileSet.cs new file mode 100644 index 00000000..b47ac4c5 --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/Internal/FileSet.cs @@ -0,0 +1,27 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more 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 int Count => _files.Count; + + public IEnumerator GetEnumerator() => _files.GetEnumerator(); + IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator(); + } +} diff --git a/src/Microsoft.Tye.Hosting/Watch/Internal/FileSetWatcher.cs b/src/Microsoft.Tye.Hosting/Watch/Internal/FileSetWatcher.cs new file mode 100644 index 00000000..b8f33a53 --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/Internal/FileSetWatcher.cs @@ -0,0 +1,61 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class FileSetWatcher : IDisposable + { + private readonly FileWatcher _fileWatcher; + private readonly IFileSet _fileSet; + + public FileSetWatcher(IFileSet fileSet, ILogger logger) + { + _fileSet = fileSet; + _fileWatcher = new FileWatcher(logger); + } + + public async Task GetChangedFileAsync(CancellationToken cancellationToken, Action startedWatching) + { + 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; + startedWatching(); + var changedFile = await tcs.Task; + _fileWatcher.OnFileChange -= callback; + + return changedFile; + } + + + public Task GetChangedFileAsync(CancellationToken cancellationToken) + { + return GetChangedFileAsync(cancellationToken, () => { }); + } + + public void Dispose() + { + _fileWatcher.Dispose(); + } + } +} diff --git a/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher.cs b/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher.cs new file mode 100644 index 00000000..fa600371 --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher.cs @@ -0,0 +1,140 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.IO; +using System.Linq; +using Microsoft.Extensions.Logging; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class FileWatcher + { + private bool _disposed; + + private readonly IDictionary _watchers; + private readonly ILogger _logger; + + public FileWatcher(ILogger logger) + { + _logger = logger ?? throw new ArgumentNullException(nameof(logger)); + _watchers = new Dictionary(); + } + + public event Action? OnFileChange; + + public void WatchDirectory(string directory) + { + EnsureNotDisposed(); + AddDirectoryWatcher(directory); + } + + public void Dispose() + { + if (_disposed) + { + return; + } + + _disposed = true; + + foreach (var watcher in _watchers) + { + watcher.Value.OnFileChange -= WatcherChangedHandler!; + watcher.Value.OnError -= WatcherErrorHandler!; + watcher.Value.Dispose(); + } + + _watchers.Clear(); + } + + private void AddDirectoryWatcher(string directory) + { + directory = EnsureTrailingSlash(directory); + + var alreadyWatched = _watchers + .Where(d => directory.StartsWith(d.Key)) + .Any(); + + if (alreadyWatched) + { + return; + } + + var redundantWatchers = _watchers + .Where(d => d.Key.StartsWith(directory)) + .Select(d => d.Key) + .ToList(); + + if (redundantWatchers.Any()) + { + foreach (var watcher in redundantWatchers) + { + DisposeWatcher(watcher); + } + } + + var newWatcher = FileWatcherFactory.CreateWatcher(directory); + newWatcher.OnFileChange += WatcherChangedHandler!; + newWatcher.OnError += WatcherErrorHandler!; + newWatcher.EnableRaisingEvents = true; + + _watchers.Add(directory, newWatcher); + } + + private void WatcherErrorHandler(object sender, Exception error) + { + if (sender is IFileSystemWatcher watcher) + { + _logger.LogWarning($"The file watcher observing '{watcher.BasePath}' encountered an error: {error.Message}"); + } + } + + private void WatcherChangedHandler(object sender, string changedPath) + { + NotifyChange(changedPath); + } + + private void NotifyChange(string path) + { + if (OnFileChange != null) + { + OnFileChange(path); + } + } + + private void DisposeWatcher(string directory) + { + var watcher = _watchers[directory]; + _watchers.Remove(directory); + + watcher.EnableRaisingEvents = false; + + watcher.OnFileChange -= WatcherChangedHandler!; + watcher.OnError -= WatcherErrorHandler!; + + watcher.Dispose(); + } + + private void EnsureNotDisposed() + { + if (_disposed) + { + throw new ObjectDisposedException(nameof(FileWatcher)); + } + } + + private static string EnsureTrailingSlash(string path) + { + if (!string.IsNullOrEmpty(path) && + path[path.Length - 1] != Path.DirectorySeparatorChar) + { + return path + Path.DirectorySeparatorChar; + } + + return path; + } + } +} diff --git a/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/DotnetFileWatcher.cs b/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/DotnetFileWatcher.cs new file mode 100644 index 00000000..58712776 --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/DotnetFileWatcher.cs @@ -0,0 +1,154 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.ComponentModel; +using System.IO; + +namespace Microsoft.DotNet.Watcher.Internal +{ + internal class DotnetFileWatcher : IFileSystemWatcher + { + private volatile bool _disposed; + + private readonly Func _watcherFactory; + + private FileSystemWatcher? _fileSystemWatcher; + + private readonly object _createLock = new object(); + + public DotnetFileWatcher(string watchedDirectory) + : this(watchedDirectory, DefaultWatcherFactory) + { + } + + internal DotnetFileWatcher(string watchedDirectory, Func fileSystemWatcherFactory) + { + BasePath = watchedDirectory; + _watcherFactory = fileSystemWatcherFactory; + CreateFileSystemWatcher(); + } + + public event EventHandler? OnFileChange; + + public event EventHandler? OnError; + + public string BasePath { get; } + + private static FileSystemWatcher DefaultWatcherFactory(string watchedDirectory) + { + return new FileSystemWatcher(watchedDirectory); + } + + private void WatcherErrorHandler(object sender, ErrorEventArgs e) + { + if (_disposed) + { + return; + } + + var exception = e.GetException(); + + // Win32Exception may be triggered when setting EnableRaisingEvents on a file system type + // that is not supported, such as a network share. Don't attempt to recreate the watcher + // in this case as it will cause a StackOverflowException + if (!(exception is Win32Exception)) + { + // Recreate the watcher if it is a recoverable error. + CreateFileSystemWatcher(); + } + + OnError?.Invoke(this, exception); + } + + private void WatcherRenameHandler(object sender, RenamedEventArgs e) + { + if (_disposed) + { + return; + } + + NotifyChange(e.OldFullPath); + NotifyChange(e.FullPath); + + if (Directory.Exists(e.FullPath)) + { + foreach (var newLocation in Directory.EnumerateFileSystemEntries(e.FullPath, "*", SearchOption.AllDirectories)) + { + // Calculated previous path of this moved item. + var oldLocation = Path.Combine(e.OldFullPath, newLocation.Substring(e.FullPath.Length + 1)); + NotifyChange(oldLocation); + NotifyChange(newLocation); + } + } + } + + private void WatcherChangeHandler(object sender, FileSystemEventArgs e) + { + if (_disposed) + { + return; + } + + NotifyChange(e.FullPath); + } + + private void NotifyChange(string fullPath) + { + // Only report file changes + OnFileChange?.Invoke(this, fullPath); + } + + private void CreateFileSystemWatcher() + { + lock (_createLock) + { + bool enableEvents = false; + + if (_fileSystemWatcher != null) + { + enableEvents = _fileSystemWatcher.EnableRaisingEvents; + + DisposeInnerWatcher(); + } + + _fileSystemWatcher = _watcherFactory(BasePath); + _fileSystemWatcher.IncludeSubdirectories = true; + + _fileSystemWatcher.Created += WatcherChangeHandler; + _fileSystemWatcher.Deleted += WatcherChangeHandler; + _fileSystemWatcher.Changed += WatcherChangeHandler; + _fileSystemWatcher.Renamed += WatcherRenameHandler; + _fileSystemWatcher.Error += WatcherErrorHandler; + + _fileSystemWatcher.EnableRaisingEvents = enableEvents; + } + } + + private void DisposeInnerWatcher() + { + _fileSystemWatcher!.EnableRaisingEvents = false; + + _fileSystemWatcher.Created -= WatcherChangeHandler; + _fileSystemWatcher.Deleted -= WatcherChangeHandler; + _fileSystemWatcher.Changed -= WatcherChangeHandler; + _fileSystemWatcher.Renamed -= WatcherRenameHandler; + _fileSystemWatcher.Error -= WatcherErrorHandler; + + _fileSystemWatcher.Dispose(); + } + + public bool EnableRaisingEvents + { + get => _fileSystemWatcher!.EnableRaisingEvents; + set => _fileSystemWatcher!.EnableRaisingEvents = value; + } + + public void Dispose() + { + _disposed = true; + DisposeInnerWatcher(); + } + } +} diff --git a/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/FileWatcherFactory.cs b/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/FileWatcherFactory.cs new file mode 100644 index 00000000..15a9acef --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/FileWatcherFactory.cs @@ -0,0 +1,14 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public static class FileWatcherFactory + { + public static IFileSystemWatcher CreateWatcher(string watchedDirectory) + => new DotnetFileWatcher(watchedDirectory); + } +} diff --git a/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/IFileSystemWatcher.cs b/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/IFileSystemWatcher.cs new file mode 100644 index 00000000..6caa3b3d --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/IFileSystemWatcher.cs @@ -0,0 +1,19 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public interface IFileSystemWatcher : IDisposable + { + event EventHandler OnFileChange; + + event EventHandler OnError; + + string BasePath { get; } + + bool EnableRaisingEvents { get; set; } + } +} diff --git a/src/Microsoft.Tye.Hosting/Watch/Internal/MsBuildFileSetFactory.cs b/src/Microsoft.Tye.Hosting/Watch/Internal/MsBuildFileSetFactory.cs new file mode 100644 index 00000000..9d8b7948 --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/Internal/MsBuildFileSetFactory.cs @@ -0,0 +1,173 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.IO; +using System.Linq; +using System.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.Extensions.Logging; +using Microsoft.Tye; + +namespace Microsoft.DotNet.Watcher.Internal +{ + public class MsBuildFileSetFactory : IFileSetFactory + { + private const string TargetName = "GenerateWatchList"; + private const string WatchTargetsFileName = "DotNetWatch.targets"; + private readonly ILogger _logger; + private readonly string _projectFile; + private readonly bool _waitOnError; + private readonly IReadOnlyList _buildFlags; + + public MsBuildFileSetFactory(ILogger reporter, + string projectFile, + bool waitOnError, + bool trace) + : this(reporter, projectFile, trace) + { + _waitOnError = waitOnError; + } + + internal MsBuildFileSetFactory(ILogger logger, + string projectFile, + bool trace) + { + _logger = logger; + _projectFile = projectFile; + _buildFlags = InitializeArgs(FindTargetsFile(), trace); + } + + public async Task CreateAsync(CancellationToken cancellationToken) + { + var watchList = Path.Combine(Path.GetTempPath(), Path.GetRandomFileName()); + try + { + var projectDir = Path.GetDirectoryName(_projectFile); + + while (true) + { + cancellationToken.ThrowIfCancellationRequested(); + + var args = new StringBuilder(); + args.Append($"msbuild {_projectFile} /p:_DotNetWatchListFile={watchList}"); + foreach (var flag in _buildFlags) + { + args.Append(" "); + args.Append(flag); + } + + var processSpec = new ProcessSpec + { + Executable = "dotnet", + WorkingDirectory = projectDir!, + Arguments = args.ToString() + }; + + _logger.LogDebug($"Running MSBuild target '{TargetName}' on '{_projectFile}'"); + + var processResult = await ProcessUtil.RunAsync(processSpec, cancellationToken); + + if (processResult.ExitCode == 0 && File.Exists(watchList)) + { + var fileset = new FileSet( + File.ReadAllLines(watchList) + .Select(l => l?.Trim()) + .Where(l => !string.IsNullOrEmpty(l))!); + + _logger.LogDebug($"Watching {fileset.Count} file(s) for changes"); +#if DEBUG + + foreach (var file in fileset) + { + _logger.LogDebug($" -> {file}"); + } + + Debug.Assert(fileset.All(Path.IsPathRooted), "All files should be rooted paths"); +#endif + + return fileset; + } + + _logger.LogError($"Error(s) finding watch items project file '{Path.GetFileName(_projectFile)}'"); + + _logger.LogInformation($"MSBuild output from target '{TargetName}':"); + _logger.LogInformation(string.Empty); + + _logger.LogInformation(string.Empty); + + if (!_waitOnError) + { + return null!; + } + else + { + _logger.LogWarning("Fix the error to continue or press Ctrl+C to exit."); + + var fileSet = new FileSet(new[] { _projectFile }); + + using (var watcher = new FileSetWatcher(fileSet, _logger)) + { + await watcher.GetChangedFileAsync(cancellationToken); + + _logger.LogInformation($"File changed: {_projectFile}"); + } + } + } + } + finally + { + if (File.Exists(watchList)) + { + File.Delete(watchList); + } + } + } + + private IReadOnlyList InitializeArgs(string watchTargetsFile, bool trace) + { + var args = new List + { + "/nologo", + "/v:n", + "/t:" + TargetName, + "/p:DotNetWatchBuild=true", // extensibility point for users + "/p:DesignTimeBuild=true", // don't do expensive things + "/p:CustomAfterMicrosoftCommonTargets=" + watchTargetsFile, + "/p:CustomAfterMicrosoftCommonCrossTargetingTargets=" + watchTargetsFile, + }; + + if (trace) + { + // enables capturing markers to know which projects have been visited + args.Add("/p:_DotNetWatchTraceOutput=true"); + } + + return args; + } + + private string FindTargetsFile() + { + var assemblyDir = Path.GetDirectoryName(typeof(MsBuildFileSetFactory).Assembly.Location); + string[] searchPaths = new[] + { + Path.Combine(AppContext.BaseDirectory, "Watch", "assets"), + Path.Combine(assemblyDir!, "Watch", "assets"), + AppContext.BaseDirectory, + assemblyDir, + }!; + + var targetPath = searchPaths.Select(p => Path.Combine(p, WatchTargetsFileName)).FirstOrDefault(File.Exists); + if (targetPath == null) + { + _logger.LogError("Fatal error: could not find DotNetWatch.targets"); + return null!; + } + return targetPath; + } + } +} diff --git a/src/Microsoft.Tye.Hosting/Watch/Internal/MsBuildProjectFinder.cs b/src/Microsoft.Tye.Hosting/Watch/Internal/MsBuildProjectFinder.cs new file mode 100644 index 00000000..5344257a --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/Internal/MsBuildProjectFinder.cs @@ -0,0 +1,54 @@ +// Licensed to the .NET Foundation under one or more agreements. +// The .NET Foundation licenses this file to you under the MIT license. +// See the LICENSE file in the project root for more information. + +using System; +using System.IO; +using System.Linq; + +namespace Microsoft.DotNet.Watcher.Internal +{ + internal class MsBuildProjectFinder + { + /// + /// Finds a compatible MSBuild project. + /// The base directory to search + /// The filename of the project. Can be null. + /// + public static string FindMsBuildProject(string searchBase, string project) + { + var projectPath = project ?? searchBase; + + if (!Path.IsPathRooted(projectPath)) + { + projectPath = Path.Combine(searchBase, projectPath); + } + + if (Directory.Exists(projectPath)) + { + var projects = Directory.EnumerateFileSystemEntries(projectPath, "*.*proj", SearchOption.TopDirectoryOnly) + .Where(f => !".xproj".Equals(Path.GetExtension(f), StringComparison.OrdinalIgnoreCase)) + .ToList(); + + if (projects.Count > 1) + { + throw new FileNotFoundException($"The project file '{projectPath}' does not exist."); + } + + if (projects.Count == 0) + { + throw new FileNotFoundException($"Could not find a MSBuild project file in '{projectPath}'. Specify which project to use with the --project option."); + } + + return projects[0]; + } + + if (!File.Exists(projectPath)) + { + throw new FileNotFoundException($"The project file '{projectPath}' does not exist."); + } + + return projectPath; + } + } +} diff --git a/src/Microsoft.Tye.Hosting/Watch/assets/DotNetWatch.targets b/src/Microsoft.Tye.Hosting/Watch/assets/DotNetWatch.targets new file mode 100644 index 00000000..09422225 --- /dev/null +++ b/src/Microsoft.Tye.Hosting/Watch/assets/DotNetWatch.targets @@ -0,0 +1,66 @@ + + + + + + + + + <_CollectWatchItemsDependsOn Condition=" '$(TargetFrameworks)' != '' AND '$(TargetFramework)' == '' "> + _CollectWatchItemsPerFramework; + + <_CollectWatchItemsDependsOn Condition=" '$(TargetFramework)' != '' "> + _CoreCollectWatchItems; + + + + + + + + <_TargetFramework Include="$(TargetFrameworks)" /> + + + + + + + + + + + + + + + + + + <_WatchProjects Include="%(ProjectReference.Identity)" Condition="'%(ProjectReference.Watch)' != 'false'" /> + + + + + + + + diff --git a/src/tye/Program.RunCommand.cs b/src/tye/Program.RunCommand.cs index 2966ec0f..1eb75455 100644 --- a/src/tye/Program.RunCommand.cs +++ b/src/tye/Program.RunCommand.cs @@ -71,6 +71,12 @@ namespace Microsoft.Tye Description = "Launch dashboard on run.", Required = false }, + new Option("--watch") + { + Description = "Watches for code changes for all dotnet projects.", + Required = false + }, + StandardOptions.Verbosity, }; @@ -103,7 +109,8 @@ namespace Microsoft.Tye DistributedTraceProvider = args.Dtrace, LoggingProvider = args.Logs, MetricsProvider = args.Metrics, - LogVerbosity = args.Verbosity + LogVerbosity = args.Verbosity, + Watch = args.Watch }; options.Debug.AddRange(args.Debug); @@ -161,6 +168,8 @@ namespace Microsoft.Tye public int? Port { get; set; } public Verbosity Verbosity { get; set; } + + public bool Watch { get; set; } } } } diff --git a/test/E2ETest/TyeRunTests.cs b/test/E2ETest/TyeRunTests.cs index 4db9d5e5..831f7ccf 100644 --- a/test/E2ETest/TyeRunTests.cs +++ b/test/E2ETest/TyeRunTests.cs @@ -247,6 +247,57 @@ namespace E2ETest }); } + [Fact] + public async Task FrontendBackendWatchRunTest() + { + using var projectDirectory = CopyTestProjectDirectory("frontend-backend"); + + var projectFile = new FileInfo(Path.Combine(projectDirectory.DirectoryPath, "tye.yaml")); + var outputContext = new OutputContext(_sink, Verbosity.Debug); + var application = await ApplicationFactory.CreateAsync(outputContext, projectFile); + + var handler = new HttpClientHandler + { + ServerCertificateCustomValidationCallback = (a, b, c, d) => true, + AllowAutoRedirect = false + }; + + var client = new HttpClient(new RetryHandler(handler)); + + await RunHostingApplication(application, new HostOptions() { Watch = true }, async (app, uri) => + { + // make sure both are running + var frontendUri = await GetServiceUrl(client, uri, "frontend"); + var backendUri = await GetServiceUrl(client, uri, "backend"); + + var backendResponse = await client.GetAsync(backendUri); + var frontendResponse = await client.GetAsync(frontendUri); + + Assert.True(backendResponse.IsSuccessStatusCode); + Assert.True(frontendResponse.IsSuccessStatusCode); + + var startupPath = Path.Combine(projectDirectory.DirectoryPath, "frontend", "Startup.cs"); + File.AppendAllText(startupPath, "\n"); + + const int retries = 10; + for (var i = 0; i < retries; i++) + { + + var logs = await client.GetStringAsync(new Uri(uri, $"/api/v1/logs/frontend")); + + // "Application Started" should be logged twice due to the file change + if (logs.IndexOf("Application started") != logs.LastIndexOf("Application started")) + { + return; + } + + await Task.Delay(500); + } + + throw new Exception("Failed to relaunch project with dotnet watch"); + }); + } + [Fact] public async Task DockerBaseImageAndTagTest() {