Browse Source

Adds --watch as an option for tye run (#526)

pull/535/head
Justin Kotalik 6 years ago
committed by GitHub
parent
commit
e7cb54b801
No known key found for this signature in database GPG Key ID: 4AEE18F83AFDEB23
  1. 4
      docs/reference/commandline/tye-run.md
  2. 107
      src/Microsoft.Tye.Core/ArgumentEscaper.cs
  3. 2
      src/Microsoft.Tye.Core/HostOptions.cs
  4. 122
      src/Microsoft.Tye.Core/ProcessExtensions.cs
  5. 24
      src/Microsoft.Tye.Core/ProcessSpec.cs
  6. 6
      src/Microsoft.Tye.Core/ProcessUtil.cs
  7. 4
      src/Microsoft.Tye.Hosting/Microsoft.Tye.Hosting.csproj
  8. 54
      src/Microsoft.Tye.Hosting/ProcessRunner.cs
  9. 4
      src/Microsoft.Tye.Hosting/ProcessRunnerOptions.cs
  10. 102
      src/Microsoft.Tye.Hosting/Watch/DotNetWatcher.cs
  11. 13
      src/Microsoft.Tye.Hosting/Watch/IFileSet.cs
  12. 14
      src/Microsoft.Tye.Hosting/Watch/IFileSetFactory.cs
  13. 27
      src/Microsoft.Tye.Hosting/Watch/Internal/FileSet.cs
  14. 61
      src/Microsoft.Tye.Hosting/Watch/Internal/FileSetWatcher.cs
  15. 140
      src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher.cs
  16. 154
      src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/DotnetFileWatcher.cs
  17. 14
      src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/FileWatcherFactory.cs
  18. 19
      src/Microsoft.Tye.Hosting/Watch/Internal/FileWatcher/IFileSystemWatcher.cs
  19. 173
      src/Microsoft.Tye.Hosting/Watch/Internal/MsBuildFileSetFactory.cs
  20. 54
      src/Microsoft.Tye.Hosting/Watch/Internal/MsBuildProjectFinder.cs
  21. 66
      src/Microsoft.Tye.Hosting/Watch/assets/DotNetWatch.targets
  22. 11
      src/tye/Program.RunCommand.cs
  23. 51
      test/E2ETest/TyeRunTests.cs

4
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:

107
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
{
/// <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;
}
}

2
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; }
}
}

122
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<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();
}
}
}
}

24
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<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);
}
}

6
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<ProcessResult> 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

4
src/Microsoft.Tye.Hosting/Microsoft.Tye.Hosting.csproj

@ -13,6 +13,10 @@
<ItemGroup>
<EmbeddedResource Include="wwwroot\**" />
</ItemGroup>
<ItemGroup>
<None Include="Watch\assets\**\*" CopyToOutputDirectory="PreserveNewest" CopyToPublishDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Bedrock.Framework" Version="0.1.38-alpha.gd25d5b37ad" />

54
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)
{

4
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
};
}
}

102
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<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}");
}
}
}
}
}
}

13
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<string>
{
bool Contains(string filePath);
}
}

14
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<IFileSet> CreateAsync(CancellationToken cancellationToken);
}
}

27
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<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();
}
}

61
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<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();
}
}
}

140
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<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;
}
}
}

154
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<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();
}
}
}

14
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);
}
}

19
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<string> OnFileChange;
event EventHandler<Exception> OnError;
string BasePath { get; }
bool EnableRaisingEvents { get; set; }
}
}

173
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<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;
}
}
}

54
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
{
/// <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;
}
}
}

66
src/Microsoft.Tye.Hosting/Watch/assets/DotNetWatch.targets

@ -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>

11
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; }
}
}
}

51
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()
{

Loading…
Cancel
Save