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