diff --git a/docs/reference/commandline/tye-run.md b/docs/reference/commandline/tye-run.md
index a98342bb..69ea2339 100644
--- a/docs/reference/commandline/tye-run.md
+++ b/docs/reference/commandline/tye-run.md
@@ -62,6 +62,10 @@ If a directory path is specified, `tye run` will default to using these files, i
The default value is `info`
+- `--watch`
+
+ Watches for file changes in all projects that are built by tye. Uses [`dotnet watch`](https://docs.microsoft.com/en-us/aspnet/core/tutorials/dotnet-watch?view=aspnetcore-3.1) to monitor for file changes.
+
## Examples
- Run an application in the current directory:
diff --git a/src/Microsoft.Tye.Core/ArgumentEscaper.cs b/src/Microsoft.Tye.Core/ArgumentEscaper.cs
new file mode 100644
index 00000000..897468cb
--- /dev/null
+++ b/src/Microsoft.Tye.Core/ArgumentEscaper.cs
@@ -0,0 +1,107 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Collections.Generic;
+using System.Linq;
+using System.Text;
+
+namespace Microsoft.Tye
+{
+ public static class ArgumentEscaper
+ {
+ ///
+ /// Undo the processing which took place to create string[] args in Main, so that the next process will
+ /// receive the same string[] args.
+ ///
+ ///
+ /// See https://blogs.msdn.microsoft.com/twistylittlepassagesallalike/2011/04/23/everyone-quotes-command-line-arguments-the-wrong-way/
+ ///
+ /// The arguments to concatenate.
+ /// The escaped arguments, concatenated.
+ public static string EscapeAndConcatenate(IEnumerable args)
+ => string.Join(" ", args.Select(EscapeSingleArg));
+
+ private static string EscapeSingleArg(string arg)
+ {
+ var sb = new StringBuilder();
+
+ var needsQuotes = ShouldSurroundWithQuotes(arg);
+ var isQuoted = needsQuotes || IsSurroundedWithQuotes(arg);
+
+ if (needsQuotes)
+ {
+ sb.Append('"');
+ }
+
+ for (int i = 0; i < arg.Length; ++i)
+ {
+ var backslashes = 0;
+
+ // Consume all backslashes
+ while (i < arg.Length && arg[i] == '\\')
+ {
+ backslashes++;
+ i++;
+ }
+
+ if (i == arg.Length && isQuoted)
+ {
+ // Escape any backslashes at the end of the arg when the argument is also quoted.
+ // This ensures the outside quote is interpreted as an argument delimiter
+ sb.Append('\\', 2 * backslashes);
+ }
+ else if (i == arg.Length)
+ {
+ // At then end of the arg, which isn't quoted,
+ // just add the backslashes, no need to escape
+ sb.Append('\\', backslashes);
+ }
+ else if (arg[i] == '"')
+ {
+ // Escape any preceding backslashes and the quote
+ sb.Append('\\', (2 * backslashes) + 1);
+ sb.Append('"');
+ }
+ else
+ {
+ // Output any consumed backslashes and the character
+ sb.Append('\\', backslashes);
+ sb.Append(arg[i]);
+ }
+ }
+
+ if (needsQuotes)
+ {
+ sb.Append('"');
+ }
+
+ return sb.ToString();
+ }
+
+ private static bool ShouldSurroundWithQuotes(string argument)
+ {
+ // Don't quote already quoted strings
+ if (IsSurroundedWithQuotes(argument))
+ {
+ return false;
+ }
+
+ // Only quote if whitespace exists in the string
+ return ContainsWhitespace(argument);
+ }
+
+ private static bool IsSurroundedWithQuotes(string argument)
+ {
+ if (argument.Length <= 1)
+ {
+ return false;
+ }
+
+ return argument[0] == '"' && argument[argument.Length - 1] == '"';
+ }
+
+ private static bool ContainsWhitespace(string argument)
+ => argument.IndexOfAny(new[] { ' ', '\t', '\n' }) >= 0;
+ }
+}
diff --git a/src/Microsoft.Tye.Core/HostOptions.cs b/src/Microsoft.Tye.Core/HostOptions.cs
index 965059ad..73135cfd 100644
--- a/src/Microsoft.Tye.Core/HostOptions.cs
+++ b/src/Microsoft.Tye.Core/HostOptions.cs
@@ -25,5 +25,7 @@ namespace Microsoft.Tye
public int? Port { get; set; }
public Verbosity LogVerbosity { get; set; } = Verbosity.Debug;
+
+ public bool Watch { get; set; }
}
}
diff --git a/src/Microsoft.Tye.Core/ProcessExtensions.cs b/src/Microsoft.Tye.Core/ProcessExtensions.cs
new file mode 100644
index 00000000..a3885af2
--- /dev/null
+++ b/src/Microsoft.Tye.Core/ProcessExtensions.cs
@@ -0,0 +1,122 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.ComponentModel;
+using System.Diagnostics;
+using System.IO;
+using System.Runtime.InteropServices;
+
+namespace Microsoft.Tye
+{
+ internal static class ProcessExtensions
+ {
+ private static readonly bool _isWindows = RuntimeInformation.IsOSPlatform(OSPlatform.Windows);
+ private static readonly TimeSpan _defaultTimeout = TimeSpan.FromSeconds(30);
+
+ public static void KillTree(this System.Diagnostics.Process process) => process.KillTree(_defaultTimeout);
+
+ public static void KillTree(this System.Diagnostics.Process process, TimeSpan timeout)
+ {
+ var pid = process.Id;
+ if (_isWindows)
+ {
+ RunProcessAndWaitForExit(
+ "taskkill",
+ $"/T /F /PID {pid}",
+ timeout,
+ out var _);
+ }
+ else
+ {
+ var children = new HashSet();
+ GetAllChildIdsUnix(pid, children, timeout);
+ foreach (var childId in children)
+ {
+ KillProcessUnix(childId, timeout);
+ }
+ KillProcessUnix(pid, timeout);
+ }
+ }
+
+ private static void GetAllChildIdsUnix(int parentId, ISet children, TimeSpan timeout)
+ {
+ try
+ {
+ RunProcessAndWaitForExit(
+ "pgrep",
+ $"-P {parentId}",
+ timeout,
+ out var stdout);
+
+ if (!string.IsNullOrEmpty(stdout))
+ {
+ using (var reader = new StringReader(stdout))
+ {
+ while (true)
+ {
+ var text = reader.ReadLine();
+ if (text == null)
+ {
+ return;
+ }
+
+ if (int.TryParse(text, out var id))
+ {
+ children.Add(id);
+ // Recursively get the children
+ GetAllChildIdsUnix(id, children, timeout);
+ }
+ }
+ }
+ }
+ }
+ catch (Win32Exception ex) when (ex.Message.Contains("No such file or directory"))
+ {
+ // This probably means that pgrep isn't installed. Nothing to be done?
+ }
+ }
+
+ private static void KillProcessUnix(int processId, TimeSpan timeout)
+ {
+ try
+ {
+ RunProcessAndWaitForExit(
+ "kill",
+ $"-TERM {processId}",
+ timeout,
+ out var stdout);
+ }
+ catch (Win32Exception ex) when (ex.Message.Contains("No such file or directory"))
+ {
+ // This probably means that the process is already dead
+ }
+ }
+
+ private static void RunProcessAndWaitForExit(string fileName, string arguments, TimeSpan timeout, out string? stdout)
+ {
+ var startInfo = new ProcessStartInfo
+ {
+ FileName = fileName,
+ Arguments = arguments,
+ RedirectStandardOutput = true,
+ RedirectStandardError = true,
+ UseShellExecute = false,
+ };
+
+ var process = System.Diagnostics.Process.Start(startInfo);
+
+ stdout = null;
+ if (process.WaitForExit((int)timeout.TotalMilliseconds))
+ {
+ stdout = process.StandardOutput.ReadToEnd();
+ }
+ else
+ {
+ process.Kill();
+ }
+ }
+ }
+}
diff --git a/src/Microsoft.Tye.Core/ProcessSpec.cs b/src/Microsoft.Tye.Core/ProcessSpec.cs
new file mode 100644
index 00000000..7952726c
--- /dev/null
+++ b/src/Microsoft.Tye.Core/ProcessSpec.cs
@@ -0,0 +1,24 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System;
+using System.Collections.Generic;
+using System.Diagnostics;
+using System.IO;
+
+namespace Microsoft.Tye
+{
+ public class ProcessSpec
+ {
+ public string? Executable { get; set; }
+ public string? WorkingDirectory { get; set; }
+ public IDictionary EnvironmentVariables { get; set; } = new Dictionary();
+ public string? Arguments { get; set; }
+ public Action? OutputData { get; set; }
+ public Action? ErrorData { get; set; }
+ public Action? OnStart { get; set; }
+ public string? ShortDisplayName()
+ => Path.GetFileNameWithoutExtension(Executable);
+ }
+}
diff --git a/src/Microsoft.Tye.Core/ProcessUtil.cs b/src/Microsoft.Tye.Core/ProcessUtil.cs
index 60d9a133..0c3f6000 100644
--- a/src/Microsoft.Tye.Core/ProcessUtil.cs
+++ b/src/Microsoft.Tye.Core/ProcessUtil.cs
@@ -42,7 +42,6 @@ namespace Microsoft.Tye
EnableRaisingEvents = true
};
-
if (workingDirectory != null)
{
process.StartInfo.WorkingDirectory = workingDirectory;
@@ -140,6 +139,11 @@ namespace Microsoft.Tye
return await processLifetimeTask.Task;
}
+ public static Task RunAsync(ProcessSpec processSpec, CancellationToken cancellationToken = default, bool throwOnError = true)
+ {
+ return RunAsync(processSpec.Executable!, processSpec.Arguments!, processSpec.WorkingDirectory, throwOnError: throwOnError, processSpec.EnvironmentVariables, processSpec.OutputData, processSpec.ErrorData, processSpec.OnStart, cancellationToken);
+ }
+
public static void KillProcess(int pid)
{
try
diff --git a/src/Microsoft.Tye.Hosting/Microsoft.Tye.Hosting.csproj b/src/Microsoft.Tye.Hosting/Microsoft.Tye.Hosting.csproj
index d9a13d02..7b22cd00 100644
--- a/src/Microsoft.Tye.Hosting/Microsoft.Tye.Hosting.csproj
+++ b/src/Microsoft.Tye.Hosting/Microsoft.Tye.Hosting.csproj
@@ -13,6 +13,10 @@
+
+
+
+
diff --git a/src/Microsoft.Tye.Hosting/ProcessRunner.cs b/src/Microsoft.Tye.Hosting/ProcessRunner.cs
index da731591..d156677f 100644
--- a/src/Microsoft.Tye.Hosting/ProcessRunner.cs
+++ b/src/Microsoft.Tye.Hosting/ProcessRunner.cs
@@ -11,6 +11,8 @@ using System.Runtime.InteropServices;
using System.Text;
using System.Threading;
using System.Threading.Tasks;
+using Microsoft.DotNet.Watcher;
+using Microsoft.DotNet.Watcher.Internal;
using Microsoft.Extensions.Logging;
using Microsoft.Tye.Hosting.Model;
@@ -22,7 +24,6 @@ namespace Microsoft.Tye.Hosting
private readonly ILogger _logger;
private readonly ProcessRunnerOptions _options;
-
private readonly ReplicaRegistry _replicaRegistry;
public ProcessRunner(ILogger logger, ReplicaRegistry replicaRegistry, ProcessRunnerOptions options)
@@ -268,15 +269,18 @@ namespace Microsoft.Tye.Hosting
try
{
service.Logs.OnNext($"[{replica}]:{path} {args}");
-
- var result = await ProcessUtil.RunAsync(
- path,
- args,
- environmentVariables: environment,
- workingDirectory: workingDirectory,
- outputDataReceived: data => service.Logs.OnNext($"[{replica}]: {data}"),
- errorDataReceived: data => service.Logs.OnNext($"[{replica}]: {data}"),
- onStart: pid =>
+ var processInfo = new ProcessSpec
+ {
+ Executable = path,
+ WorkingDirectory = workingDirectory,
+ Arguments = args,
+ EnvironmentVariables = environment,
+ OutputData = data =>
+ {
+ service.Logs.OnNext($"[{replica}]: {data}");
+ },
+ ErrorData = data => service.Logs.OnNext($"[{replica}]: {data}"),
+ OnStart = pid =>
{
if (hasPorts)
{
@@ -294,16 +298,34 @@ namespace Microsoft.Tye.Hosting
WriteReplicaToStore(pid.ToString());
service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Started, status));
- },
- throwOnError: false,
- cancellationToken: status.StoppingTokenSource.Token);
+ }
+ };
- status.ExitCode = result.ExitCode;
+ if (_options.Watch && service.Description.RunInfo is ProjectRunInfo runInfo)
+ {
+ var projectFile = runInfo.ProjectFile.FullName;
- if (status.Pid != null)
+ var fileSetFactory = new MsBuildFileSetFactory(_logger,
+ projectFile,
+ waitOnError: true,
+ trace: false);
+ environment["DOTNET_WATCH"] = "1";
+
+ await new DotNetWatcher(_logger)
+ .WatchAsync(processInfo, fileSetFactory, replica, status.StoppingTokenSource.Token);
+ }
+ else
{
- service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Stopped, status));
+ var result = await ProcessUtil.RunAsync(processInfo, status.StoppingTokenSource.Token, throwOnError: false);
+
+ status.ExitCode = result.ExitCode;
+
+ if (status.Pid != null)
+ {
+ service.ReplicaEvents.OnNext(new ReplicaEvent(ReplicaState.Stopped, status));
+ }
}
+
}
catch (Exception ex)
{
diff --git a/src/Microsoft.Tye.Hosting/ProcessRunnerOptions.cs b/src/Microsoft.Tye.Hosting/ProcessRunnerOptions.cs
index c311753f..69981427 100644
--- a/src/Microsoft.Tye.Hosting/ProcessRunnerOptions.cs
+++ b/src/Microsoft.Tye.Hosting/ProcessRunnerOptions.cs
@@ -13,6 +13,7 @@ namespace Microsoft.Tye.Hosting
public bool BuildProjects { get; set; }
public string[]? ServicesToDebug { get; set; }
public bool DebugAllServices { get; set; }
+ public bool Watch { get; set; }
public static ProcessRunnerOptions FromHostOptions(HostOptions options)
{
@@ -21,7 +22,8 @@ namespace Microsoft.Tye.Hosting
BuildProjects = !options.NoBuild,
DebugMode = options.Debug.Any(),
ServicesToDebug = options.Debug.ToArray(),
- DebugAllServices = options.Debug?.Contains("*", StringComparer.OrdinalIgnoreCase) ?? false
+ DebugAllServices = options.Debug?.Contains("*", StringComparer.OrdinalIgnoreCase) ?? false,
+ Watch = options.Watch
};
}
}
diff --git a/src/Microsoft.Tye.Hosting/Watch/DotNetWatcher.cs b/src/Microsoft.Tye.Hosting/Watch/DotNetWatcher.cs
new file mode 100644
index 00000000..77fb42e1
--- /dev/null
+++ b/src/Microsoft.Tye.Hosting/Watch/DotNetWatcher.cs
@@ -0,0 +1,102 @@
+// Licensed to the .NET Foundation under one or more agreements.
+// The .NET Foundation licenses this file to you under the MIT license.
+// See the LICENSE file in the project root for more information.
+
+using System.Globalization;
+using System.Threading;
+using System.Threading.Tasks;
+using Microsoft.DotNet.Watcher.Internal;
+using Microsoft.Extensions.Logging;
+using Microsoft.Tye;
+
+namespace Microsoft.DotNet.Watcher
+{
+ public class DotNetWatcher
+ {
+ private readonly ILogger _logger;
+
+ public DotNetWatcher(ILogger logger)
+ {
+ _logger = logger;
+ }
+
+ public async Task WatchAsync(ProcessSpec processSpec, IFileSetFactory fileSetFactory, string replica,
+ CancellationToken cancellationToken)
+ {
+ var cancelledTaskSource = new TaskCompletionSource