mirror of https://github.com/dotnet/tye.git
committed by
GitHub
23 changed files with 1207 additions and 19 deletions
@ -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 |
|||
{ |
|||
/// <summary>
|
|||
/// Undo the processing which took place to create string[] args in Main, so that the next process will
|
|||
/// receive the same string[] args.
|
|||
/// </summary>
|
|||
/// <remarks>
|
|||
/// See https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/
|
|||
/// </remarks>
|
|||
/// <param name="args">The arguments to concatenate.</param>
|
|||
/// <returns>The escaped arguments, concatenated.</returns>
|
|||
public static string EscapeAndConcatenate(IEnumerable<string> 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; |
|||
} |
|||
} |
|||
@ -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<int>(); |
|||
GetAllChildIdsUnix(pid, children, timeout); |
|||
foreach (var childId in children) |
|||
{ |
|||
KillProcessUnix(childId, timeout); |
|||
} |
|||
KillProcessUnix(pid, timeout); |
|||
} |
|||
} |
|||
|
|||
private static void GetAllChildIdsUnix(int parentId, ISet<int> 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(); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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<string, string> EnvironmentVariables { get; set; } = new Dictionary<string, string>(); |
|||
public string? Arguments { get; set; } |
|||
public Action<string>? OutputData { get; set; } |
|||
public Action<string>? ErrorData { get; set; } |
|||
public Action<int>? OnStart { get; set; } |
|||
public string? ShortDisplayName() |
|||
=> Path.GetFileNameWithoutExtension(Executable); |
|||
} |
|||
} |
|||
@ -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<object>(); |
|||
cancellationToken.Register(state => ((TaskCompletionSource<object>)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}"); |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
} |
|||
@ -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<string> |
|||
{ |
|||
bool Contains(string filePath); |
|||
} |
|||
} |
|||
@ -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<IFileSet> CreateAsync(CancellationToken cancellationToken); |
|||
} |
|||
} |
|||
@ -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<string> _files; |
|||
|
|||
public FileSet(IEnumerable<string> files) |
|||
{ |
|||
_files = new HashSet<string>(files, StringComparer.OrdinalIgnoreCase); |
|||
} |
|||
|
|||
public bool Contains(string filePath) => _files.Contains(filePath); |
|||
|
|||
public int Count => _files.Count; |
|||
|
|||
public IEnumerator<string> GetEnumerator() => _files.GetEnumerator(); |
|||
IEnumerator IEnumerable.GetEnumerator() => _files.GetEnumerator(); |
|||
} |
|||
} |
|||
@ -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<string> GetChangedFileAsync(CancellationToken cancellationToken, Action startedWatching) |
|||
{ |
|||
foreach (var file in _fileSet) |
|||
{ |
|||
_fileWatcher.WatchDirectory(Path.GetDirectoryName(file)!); |
|||
} |
|||
|
|||
var tcs = new TaskCompletionSource<string>(); |
|||
cancellationToken.Register(() => tcs.TrySetResult(null!)); |
|||
|
|||
Action<string> 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<string> GetChangedFileAsync(CancellationToken cancellationToken) |
|||
{ |
|||
return GetChangedFileAsync(cancellationToken, () => { }); |
|||
} |
|||
|
|||
public void Dispose() |
|||
{ |
|||
_fileWatcher.Dispose(); |
|||
} |
|||
} |
|||
} |
|||
@ -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<string, IFileSystemWatcher> _watchers; |
|||
private readonly ILogger _logger; |
|||
|
|||
public FileWatcher(ILogger logger) |
|||
{ |
|||
_logger = logger ?? throw new ArgumentNullException(nameof(logger)); |
|||
_watchers = new Dictionary<string, IFileSystemWatcher>(); |
|||
} |
|||
|
|||
public event Action<string>? 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; |
|||
} |
|||
} |
|||
} |
|||
@ -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<string, FileSystemWatcher> _watcherFactory; |
|||
|
|||
private FileSystemWatcher? _fileSystemWatcher; |
|||
|
|||
private readonly object _createLock = new object(); |
|||
|
|||
public DotnetFileWatcher(string watchedDirectory) |
|||
: this(watchedDirectory, DefaultWatcherFactory) |
|||
{ |
|||
} |
|||
|
|||
internal DotnetFileWatcher(string watchedDirectory, Func<string, FileSystemWatcher> fileSystemWatcherFactory) |
|||
{ |
|||
BasePath = watchedDirectory; |
|||
_watcherFactory = fileSystemWatcherFactory; |
|||
CreateFileSystemWatcher(); |
|||
} |
|||
|
|||
public event EventHandler<string>? OnFileChange; |
|||
|
|||
public event EventHandler<Exception>? 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(); |
|||
} |
|||
} |
|||
} |
|||
@ -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); |
|||
} |
|||
} |
|||
@ -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<string> OnFileChange; |
|||
|
|||
event EventHandler<Exception> OnError; |
|||
|
|||
string BasePath { get; } |
|||
|
|||
bool EnableRaisingEvents { get; set; } |
|||
} |
|||
} |
|||
@ -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<string> _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<IFileSet> 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<string> InitializeArgs(string watchTargetsFile, bool trace) |
|||
{ |
|||
var args = new List<string> |
|||
{ |
|||
"/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; |
|||
} |
|||
} |
|||
} |
|||
@ -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 |
|||
{ |
|||
/// <summary>
|
|||
/// Finds a compatible MSBuild project.
|
|||
/// <param name="searchBase">The base directory to search</param>
|
|||
/// <param name="project">The filename of the project. Can be null.</param>
|
|||
/// </summary>
|
|||
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; |
|||
} |
|||
} |
|||
} |
|||
@ -0,0 +1,66 @@ |
|||
<Project xmlns = "http://schemas.microsoft.com/developer/msbuild/2003" > |
|||
<!-- |
|||
========================================================================= |
|||
GenerateWatchList |
|||
Main target called by dotnet-watch. It gathers MSBuild items and writes |
|||
them to a file. |
|||
========================================================================= |
|||
--> |
|||
<Target Name="GenerateWatchList" |
|||
DependsOnTargets="_CollectWatchItems"> |
|||
<WriteLinesToFile Overwrite="true" |
|||
File="$(_DotNetWatchListFile)" |
|||
Lines="@(Watch -> '%(FullPath)')" /> |
|||
</Target> |
|||
|
|||
<!-- |
|||
========================================================================= |
|||
_CollectWatchItems |
|||
Gathers all files to be watched. |
|||
Returns: @(Watch) |
|||
========================================================================= |
|||
--> |
|||
<PropertyGroup> |
|||
<_CollectWatchItemsDependsOn Condition=" '$(TargetFrameworks)' != '' AND '$(TargetFramework)' == '' "> |
|||
_CollectWatchItemsPerFramework; |
|||
</_CollectWatchItemsDependsOn> |
|||
<_CollectWatchItemsDependsOn Condition=" '$(TargetFramework)' != '' "> |
|||
_CoreCollectWatchItems; |
|||
</_CollectWatchItemsDependsOn> |
|||
</PropertyGroup> |
|||
|
|||
<Target Name="_CollectWatchItems" DependsOnTargets="$(_CollectWatchItemsDependsOn)" Returns="@(Watch)" /> |
|||
|
|||
<Target Name="_CollectWatchItemsPerFramework"> |
|||
<ItemGroup> |
|||
<_TargetFramework Include="$(TargetFrameworks)" /> |
|||
</ItemGroup> |
|||
|
|||
<MSBuild Projects="$(MSBuildProjectFullPath)" |
|||
Targets="_CoreCollectWatchItems" |
|||
Properties="TargetFramework=%(_TargetFramework.Identity)"> |
|||
<Output TaskParameter="TargetOutputs" ItemName="Watch" /> |
|||
</MSBuild> |
|||
</Target> |
|||
|
|||
<Target Name="_CoreCollectWatchItems" Returns="@(Watch)"> |
|||
<!-- message used to debug --> |
|||
<Message Importance="High" Text="Collecting watch items from '$(MSBuildProjectName)'" Condition="'$(_DotNetWatchTraceOutput)'=='true'" /> |
|||
|
|||
<Error Text="TargetFramework should be set" Condition="'$(TargetFramework)' == '' "/> |
|||
|
|||
<ItemGroup> |
|||
<Watch Include="%(Compile.FullPath)" Condition="'%(Compile.Watch)' != 'false'" /> |
|||
<Watch Include="%(EmbeddedResource.FullPath)" Condition="'%(EmbeddedResource.Watch)' != 'false'"/> |
|||
<Watch Include="$(MSBuildProjectFullPath)" /> |
|||
<_WatchProjects Include="%(ProjectReference.Identity)" Condition="'%(ProjectReference.Watch)' != 'false'" /> |
|||
</ItemGroup> |
|||
|
|||
<MSBuild Projects="@(_WatchProjects)" |
|||
Targets="_CollectWatchItems" |
|||
BuildInParallel="true"> |
|||
<Output TaskParameter="TargetOutputs" ItemName="Watch" /> |
|||
</MSBuild> |
|||
</Target> |
|||
|
|||
</Project> |
|||
Loading…
Reference in new issue