Skip to content

Rewrite dotnet watch command line parsing #39618

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 3 commits into from
Apr 2, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
409 changes: 174 additions & 235 deletions src/BuiltInTools/dotnet-watch/CommandLineOptions.cs

Large diffs are not rendered by default.

4 changes: 1 addition & 3 deletions src/BuiltInTools/dotnet-watch/DotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,8 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
using var combinedCancellationSource = CancellationTokenSource.CreateLinkedTokenSource(cancellationToken, currentRunCancellationSource.Token);
using var fileSetWatcher = new FileSetWatcher(fileSet, context.Reporter);

context.Reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: '{processSpec.GetArgumentsDisplay()}'");
var processTask = _processRunner.RunAsync(processSpec, combinedCancellationSource.Token);

context.Reporter.Output("Started", emoji: "🚀");
context.Reporter.Output("Started");

Task<FileItem?> fileSetTask;
Task finishedTask;
Expand Down
2 changes: 2 additions & 0 deletions src/BuiltInTools/dotnet-watch/EnvironmentOptions.cs
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ internal enum TestFlags
internal sealed record EnvironmentOptions(
string WorkingDirectory,
string MuxerPath,
bool IsPollingEnabled = false,
bool SuppressHandlingStaticContentFiles = false,
bool SuppressMSBuildIncrementalism = false,
bool SuppressLaunchBrowser = false,
Expand All @@ -27,6 +28,7 @@ internal sealed record EnvironmentOptions(
(
WorkingDirectory: Directory.GetCurrentDirectory(),
MuxerPath: GetMuxerPathFromEnvironment(),
IsPollingEnabled: EnvironmentVariables.IsPollingEnabled,
SuppressHandlingStaticContentFiles: EnvironmentVariables.SuppressHandlingStaticContentFiles,
SuppressMSBuildIncrementalism: EnvironmentVariables.SuppressMSBuildIncrementalism,
SuppressLaunchBrowser: EnvironmentVariables.SuppressLaunchBrowser,
Expand Down
14 changes: 14 additions & 0 deletions src/BuiltInTools/dotnet-watch/EnvironmentVariablesBuilder.cs
Original file line number Diff line number Diff line change
Expand Up @@ -38,5 +38,19 @@ public void AddToEnvironment(Dictionary<string, string> variables)
variables.Add(EnvironmentVariables.Names.DotnetStartupHooks, string.Join(s_startupHooksSeparator, DotNetStartupHooks));
variables.Add(EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies, string.Join(AssembliesSeparator, AspNetCoreHostingStartupAssemblies));
}

public IEnumerable<string> ToCommandLineDirectives()
{
foreach (var (name, value) in this)
{
yield return MakeDirective(name, value);
}

yield return MakeDirective(EnvironmentVariables.Names.DotnetStartupHooks, string.Join(s_startupHooksSeparator, DotNetStartupHooks));
yield return MakeDirective(EnvironmentVariables.Names.AspNetCoreHostingStartupAssemblies, string.Join(AssembliesSeparator, AspNetCoreHostingStartupAssemblies));

static string MakeDirective(string name, string value)
=> $"[env:{name}={value}]";
}
}
}
122 changes: 5 additions & 117 deletions src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -69,15 +69,13 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
environmentBuilder.Add(EnvironmentVariables.Names.DotnetModifiableAssemblies, "debug");
environmentBuilder.Add(EnvironmentVariables.Names.DotnetHotReloadNamedPipeName, namedPipeName);

processSpec.Executable = _context.EnvironmentOptions.MuxerPath;

await using var browserConnector = new BrowserConnector(_context);

for (var iteration = 0;;iteration++)
{
if (await BuildAsync(processSpec.WorkingDirectory, cancellationToken) is not (var project, var files))
{
_context.Reporter.Error("Failed to find a list of files to watch");
return;
}
var (project, files) = await _fileSetFactory.CreateAsync(cancellationToken);

if (!project.IsNetCoreApp60OrNewer())
{
Expand All @@ -89,8 +87,7 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella

if (iteration == 0)
{
ConfigureExecutable(processSpec, project, _context.LaunchSettingsProfile);
environmentBuilder.AddToEnvironment(processSpec.EnvironmentVariables);
processSpec.Arguments = [..environmentBuilder.ToCommandLineDirectives(), ..processSpec.Arguments ?? []];
}

processSpec.EnvironmentVariables[EnvironmentVariables.Names.DotnetWatchIteration] = (iteration + 1).ToString(CultureInfo.InvariantCulture);
Expand All @@ -115,10 +112,8 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
// when the solution captures state of the file after the changes has already been made.
await hotReload.InitializeAsync(project, namedPipeName, cancellationToken);

_context.Reporter.Verbose($"Running {processSpec.ShortDisplayName()} with the following arguments: '{processSpec.GetArgumentsDisplay()}'");
var processTask = _processRunner.RunAsync(processSpec, combinedCancellationSource.Token);

_context.Reporter.Output("Started", emoji: "🚀");
_context.Reporter.Output("Started");

Task<FileItem[]?> fileSetTask;
Task finishedTask;
Expand Down Expand Up @@ -280,113 +275,6 @@ public async Task WatchAsync(ProcessSpec processSpec, CancellationToken cancella
return default;
}

public async ValueTask<(ProjectInfo project, FileSet files)?> BuildAsync(string? workingDirectory, CancellationToken cancellationToken)
{
while (!cancellationToken.IsCancellationRequested)
{
var arguments = new List<string>()
{
"msbuild",
"/nologo",
"/restore",
"/t:Build",
};

if (_context.Options.TargetFramework != null)
{
arguments.Add($"/p:TargetFramework={_context.Options.TargetFramework}");
}

if (_context.Options.BuildProperties != null)
{
arguments.AddRange(_context.Options.BuildProperties.Select(p => $"/p:{p.name}={p.value}"));
}

var buildProcessSpec = new ProcessSpec
{
Executable = _context.EnvironmentOptions.MuxerPath,
Arguments = arguments,
WorkingDirectory = workingDirectory,
};

_context.Reporter.Output("Building...", emoji: "🔧");
var exitCode = await _processRunner.RunAsync(buildProcessSpec, cancellationToken);

var result = await _fileSetFactory.CreateAsync(cancellationToken);
if (exitCode == 0)
{
return result;
}

// If the build fails, we'll retry until we have a successful build.
using var fileSetWatcher = new FileSetWatcher(result.files, _context.Reporter);
_ = await fileSetWatcher.GetChangedFileAsync(
() => _context.Reporter.Warn("Waiting for a file to change before restarting dotnet...", emoji: "⏳"),
cancellationToken);
}

return null;
}

private void ConfigureExecutable(ProcessSpec processSpec, ProjectInfo project, LaunchSettingsProfile launchSettingsProfile)
{
// RunCommand property specifies the host to use to run the project.
// RunArguments then specifies the arguments to the host.
// Arguments to the executable should follow the host arguments.

processSpec.Executable = project.RunCommand;

if (!string.IsNullOrEmpty(project.RunArguments))
{
var escapedArguments = project.RunArguments;

if (processSpec.EscapedArguments != null)
{
escapedArguments += " " + processSpec.EscapedArguments;
}

if (processSpec.Arguments != null)
{
escapedArguments += " " + CommandLineUtilities.JoinArguments(processSpec.Arguments);
}

processSpec.EscapedArguments = escapedArguments;
processSpec.Arguments = null;
}

if (!string.IsNullOrEmpty(project.RunWorkingDirectory))
{
processSpec.WorkingDirectory = project.RunWorkingDirectory;
}

// ASPNETCORE_URLS is set by dotnet run
// (https://github.com/dotnet/sdk/blob/61d0eb932e5f34e1cd985e383dca5c1a34b28df7/src/Cli/dotnet/commands/dotnet-run/RunCommand.cs#L64):
if (!string.IsNullOrEmpty(launchSettingsProfile.ApplicationUrl))
{
processSpec.EnvironmentVariables[EnvironmentVariables.Names.AspNetCoreUrls] = launchSettingsProfile.ApplicationUrl;
}

var rootVariableName = EnvironmentVariableNames.TryGetDotNetRootVariableName(
project.RuntimeIdentifier ?? "",
project.DefaultAppHostRuntimeIdentifier ?? "",
project.TargetFrameworkVersion);

if (rootVariableName != null && string.IsNullOrEmpty(Environment.GetEnvironmentVariable(rootVariableName)))
{
processSpec.EnvironmentVariables[rootVariableName] = Path.GetDirectoryName(_context.EnvironmentOptions.MuxerPath)!;
}

if (launchSettingsProfile.EnvironmentVariables is { } envVariables)
{
foreach (var entry in envVariables)
{
var value = Environment.ExpandEnvironmentVariables(entry.Value);
// NOTE: MSBuild variables are not expanded like they are in VS
processSpec.EnvironmentVariables[entry.Key] = value;
}
}
}

private string GetRelativeFilePath(string path)
{
var relativePath = path;
Expand Down
111 changes: 60 additions & 51 deletions src/BuiltInTools/dotnet-watch/Internal/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -8,17 +8,8 @@

namespace Microsoft.DotNet.Watcher.Internal
{
internal sealed class ProcessRunner
internal sealed class ProcessRunner(IReporter reporter)
{
private readonly IReporter _reporter;

public ProcessRunner(IReporter reporter)
{
Ensure.NotNull(reporter, nameof(reporter));

_reporter = reporter;
}

// May not be necessary in the future. See https://github.com/dotnet/corefx/issues/12039
public async Task<int> RunAsync(ProcessSpec processSpec, CancellationToken cancellationToken)
{
Expand All @@ -28,63 +19,81 @@ public async Task<int> RunAsync(ProcessSpec processSpec, CancellationToken cance

var stopwatch = new Stopwatch();

using (var process = CreateProcess(processSpec))
using (var processState = new ProcessState(process, _reporter))
{
cancellationToken.Register(() => processState.TryKill());
using var process = CreateProcess(processSpec);
using var processState = new ProcessState(process, reporter);

cancellationToken.Register(() => processState.TryKill());

var readOutput = false;
var readError = false;
if (processSpec.IsOutputCaptured)
var readOutput = false;
var readError = false;
if (processSpec.IsOutputCaptured)
{
readOutput = true;
readError = true;
process.OutputDataReceived += (_, a) =>
{
readOutput = true;
readError = true;
process.OutputDataReceived += (_, a) =>
if (!string.IsNullOrEmpty(a.Data))
{
if (!string.IsNullOrEmpty(a.Data))
{
processSpec.OutputCapture.AddLine(a.Data);
}
};
process.ErrorDataReceived += (_, a) =>
{
if (!string.IsNullOrEmpty(a.Data))
{
processSpec.OutputCapture.AddLine(a.Data);
}
};
}
else if (processSpec.OnOutput != null)
processSpec.OutputCapture.AddLine(a.Data);
}
};
process.ErrorDataReceived += (_, a) =>
{
readOutput = true;
process.OutputDataReceived += processSpec.OnOutput;
}
if (!string.IsNullOrEmpty(a.Data))
{
processSpec.OutputCapture.AddLine(a.Data);
}
};
}
else if (processSpec.OnOutput != null)
{
readOutput = true;
process.OutputDataReceived += processSpec.OnOutput;
}

stopwatch.Start();
process.Start();
stopwatch.Start();

_reporter.Verbose($"Started '{processSpec.Executable}' with arguments '{processSpec.GetArgumentsDisplay()}': process id {process.Id}", emoji: "🚀");
int? processId = null;
try
{
if (process.Start())
{
processId = process.Id;
}
}
finally
{
var argsDisplay = processSpec.GetArgumentsDisplay();

if (readOutput)
if (processId.HasValue)
{
process.BeginOutputReadLine();
reporter.Verbose($"Launched '{processSpec.Executable}' with arguments '{argsDisplay}': process id {processId.Value}", emoji: "🚀");
}
if (readError)
else
{
process.BeginErrorReadLine();
reporter.Verbose($"Failed to launch '{processSpec.Executable}' with arguments '{argsDisplay}'");
}
}

await processState.Task;

exitCode = process.ExitCode;
stopwatch.Stop();
_reporter.Verbose($"Process id {process.Id} ran for {stopwatch.ElapsedMilliseconds}ms");
if (readOutput)
{
process.BeginOutputReadLine();
}
if (readError)
{
process.BeginErrorReadLine();
}

await processState.Task;

exitCode = process.ExitCode;
stopwatch.Stop();
reporter.Verbose($"Process id {process.Id} ran for {stopwatch.ElapsedMilliseconds}ms");

return exitCode;
}

private Process CreateProcess(ProcessSpec processSpec)
private static Process CreateProcess(ProcessSpec processSpec)
{
var process = new Process
{
Expand Down Expand Up @@ -119,7 +128,7 @@ private Process CreateProcess(ProcessSpec processSpec)
return process;
}

private class ProcessState : IDisposable
private sealed class ProcessState : IDisposable
{
private readonly IReporter _reporter;
private readonly Process _process;
Expand Down
2 changes: 0 additions & 2 deletions src/BuiltInTools/dotnet-watch/LaunchSettingsProfile.cs
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,6 @@ internal sealed class LaunchSettingsProfile
public string? CommandName { get; init; }
public bool LaunchBrowser { get; init; }
public string? LaunchUrl { get; init; }
public string? CommandLineArgs { get; init; }
public IReadOnlyDictionary<string, string>? EnvironmentVariables { get; init; }

internal static LaunchSettingsProfile? ReadLaunchProfile(string projectDirectory, string? launchProfileName, IReporter reporter)
{
Expand Down
Loading