Skip to content
Draft
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
2 changes: 1 addition & 1 deletion Directory.Build.targets
Original file line number Diff line number Diff line change
Expand Up @@ -73,7 +73,7 @@
<ItemGroup Condition="$(MicrosoftAspNetCoreAppRefPackageVersion.StartsWith('$(_TargetFrameworkVersionWithoutV)'))">
<KnownFrameworkReference Update="Microsoft.AspNetCore.App">
<LatestRuntimeFrameworkVersion>$(MicrosoftAspNetCoreAppRefPackageVersion)</LatestRuntimeFrameworkVersion>
<RuntimePackRuntimeIdentifiers>${SupportedRuntimeIdentifiers}</RuntimePackRuntimeIdentifiers>
<RuntimePackRuntimeIdentifiers>$(SupportedRuntimeIdentifiers)</RuntimePackRuntimeIdentifiers>
<TargetingPackVersion>$(MicrosoftAspNetCoreAppRefPackageVersion)</TargetingPackVersion>
<DefaultRuntimeFrameworkVersion>$(MicrosoftAspNetCoreAppRefPackageVersion)</DefaultRuntimeFrameworkVersion>
</KnownFrameworkReference>
Expand Down
1 change: 1 addition & 0 deletions sdk.slnx
Original file line number Diff line number Diff line change
Expand Up @@ -312,6 +312,7 @@
<Project Path="test/dotnet-format.UnitTests/dotnet-format.UnitTests.csproj" />
<Project Path="test/dotnet-MsiInstallation.Tests/dotnet-MsiInstallation.Tests.csproj" />
<Project Path="test/dotnet-new.IntegrationTests/dotnet-new.IntegrationTests.csproj" />
<Project Path="test/dotnet-watch-test-browser/dotnet-watch-test-browser.csproj" />
<Project Path="test/dotnet-watch.Tests/dotnet-watch.Tests.csproj" />
<Project Path="test/dotnet.Tests/dotnet.Tests.csproj" />
<Project Path="test/EndToEnd.Tests/EndToEnd.Tests.csproj" />
Expand Down
2 changes: 1 addition & 1 deletion src/BuiltInTools/HotReloadClient/HotReloadClients.cs
Original file line number Diff line number Diff line change
Expand Up @@ -173,7 +173,7 @@ public async Task ApplyStaticAssetUpdatesAsync(IEnumerable<(string filePath, str
#endif
content = ImmutableCollectionsMarshal.AsImmutableArray(blob);
}
catch (Exception e)
catch (Exception e) when (e is not OperationCanceledException)
{
ClientLogger.LogError("Failed to read file {FilePath}: {Message}", filePath, e.Message);
continue;
Expand Down
1 change: 1 addition & 0 deletions src/BuiltInTools/HotReloadClient/Logging/LogEvents.cs
Original file line number Diff line number Diff line change
Expand Up @@ -30,4 +30,5 @@ public static void Log(this ILogger logger, LogEvent logEvent, params object[] a
public static readonly LogEvent UpdatingDiagnostics = Create(LogLevel.Debug, "Updating diagnostics.");
public static readonly LogEvent SendingStaticAssetUpdateRequest = Create(LogLevel.Debug, "Sending static asset update request to connected browsers: '{0}'.");
public static readonly LogEvent RefreshServerRunningAt = Create(LogLevel.Debug, "Refresh server running at {0}.");
public static readonly LogEvent ConnectedToRefreshServer = Create(LogLevel.Debug, "Connected to refresh server.");
}
Original file line number Diff line number Diff line change
Expand Up @@ -163,7 +163,7 @@ public async Task WaitForClientConnectionAsync(CancellationToken cancellationTok
}, progressCancellationSource.Token);

// Work around lack of Task.WaitAsync(cancellationToken) on .NET Framework:
cancellationToken.Register(() => _browserConnected.SetCanceled());
cancellationToken.Register(() => _browserConnected.TrySetCanceled());

try
{
Expand Down
2 changes: 1 addition & 1 deletion src/BuiltInTools/HotReloadClient/Web/BrowserConnection.cs
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,7 @@ public BrowserConnection(WebSocket clientSocket, string? sharedSecret, ILoggerFa
ServerLogger = loggerFactory.CreateLogger(ServerLogComponentName, displayName);
AgentLogger = loggerFactory.CreateLogger(AgentLogComponentName, displayName);

ServerLogger.LogDebug("Connected to referesh server.");
ServerLogger.Log(LogEvents.ConnectedToRefreshServer);
}

public void Dispose()
Expand Down
3 changes: 2 additions & 1 deletion src/BuiltInTools/dotnet-watch.slnf
Original file line number Diff line number Diff line change
Expand Up @@ -18,10 +18,11 @@
"src\\BuiltInTools\\HotReloadClient\\Microsoft.DotNet.HotReload.Client.shproj",
"src\\BuiltInTools\\dotnet-watch\\dotnet-watch.csproj",
"test\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests\\Microsoft.AspNetCore.Watch.BrowserRefresh.Tests.csproj",
"test\\Microsoft.DotNet.HotReload.Client.Tests\\Microsoft.DotNet.HotReload.Client.Tests.csproj",
"test\\Microsoft.Extensions.DotNetDeltaApplier.Tests\\Microsoft.Extensions.DotNetDeltaApplier.Tests.csproj",
"test\\Microsoft.NET.TestFramework\\Microsoft.NET.TestFramework.csproj",
"test\\Microsoft.WebTools.AspireService.Tests\\Microsoft.WebTools.AspireService.Tests.csproj",
"test\\Microsoft.DotNet.HotReload.Client.Tests\\Microsoft.DotNet.HotReload.Client.Tests.csproj",
"test\\dotnet-watch-test-browser\\dotnet-watch-test-browser.csproj",
"test\\dotnet-watch.Tests\\dotnet-watch.Tests.csproj"
]
}
Expand Down
50 changes: 20 additions & 30 deletions src/BuiltInTools/dotnet-watch/Browser/BrowserLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,14 @@
using System.Collections.Immutable;
using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using System.Runtime.CompilerServices;
using Microsoft.Build.Graph;
using Microsoft.DotNet.HotReload;
using Microsoft.Extensions.Logging;

namespace Microsoft.DotNet.Watch;

internal sealed class BrowserLauncher(ILogger logger, EnvironmentOptions environmentOptions)
internal sealed class BrowserLauncher(ILogger logger, IProcessOutputReporter processOutputReporter, EnvironmentOptions environmentOptions)
{
// interlocked
private ImmutableHashSet<ProjectInstanceId> _browserLaunchAttempted = [];
Expand Down Expand Up @@ -61,18 +62,13 @@ public static string GetLaunchUrl(string? profileLaunchUrl, string outputLaunchU

private void LaunchBrowser(string launchUrl, AbstractBrowserRefreshServer? server)
{
var fileName = launchUrl;
var (fileName, arg, useShellExecute) = environmentOptions.BrowserPath is { } browserPath
? (browserPath, launchUrl, false)
: (launchUrl, null, true);

var args = string.Empty;
if (environmentOptions.BrowserPath is { } browserPath)
{
args = fileName;
fileName = browserPath;
}
logger.Log(MessageDescriptor.LaunchingBrowser, fileName, arg);

logger.LogDebug("Launching browser: {FileName} {Args}", fileName, args);

if (environmentOptions.TestFlags != TestFlags.None)
if (environmentOptions.TestFlags != TestFlags.None && environmentOptions.BrowserPath == null)
{
if (environmentOptions.TestFlags.HasFlag(TestFlags.MockBrowser))
{
Expand All @@ -83,29 +79,23 @@ private void LaunchBrowser(string launchUrl, AbstractBrowserRefreshServer? serve
return;
}

var info = new ProcessStartInfo
// dotnet-watch, by default, relies on URL file association to launch browsers. On Windows and MacOS, this works fairly well
// where URLs are associated with the default browser. On Linux, this is a bit murky.
// From emperical observation, it's noted that failing to launch a browser results in either Process.Start returning a null-value
// or for the process to have immediately exited.
// We can use this to provide a helpful message.
var processSpec = new ProcessSpec()
{
FileName = fileName,
Arguments = args,
UseShellExecute = true,
Executable = fileName,
Arguments = arg != null ? [arg] : [],
UseShellExecute = useShellExecute,
OnOutput = environmentOptions.TestFlags.HasFlag(TestFlags.RedirectBrowserOutput) ? processOutputReporter.ReportOutput : null,
};

try
{
using var browserProcess = Process.Start(info);
if (browserProcess is null or { HasExited: true })
{
// dotnet-watch, by default, relies on URL file association to launch browsers. On Windows and MacOS, this works fairly well
// where URLs are associated with the default browser. On Linux, this is a bit murky.
// From emperical observation, it's noted that failing to launch a browser results in either Process.Start returning a null-value
// or for the process to have immediately exited.
// We can use this to provide a helpful message.
logger.LogInformation("Unable to launch the browser. Url '{Url}'.", launchUrl);
}
}
catch (Exception e)
using var browserProcess = ProcessRunner.TryStartProcess(processSpec, logger);
if (browserProcess is null or { HasExited: true })
{
logger.LogDebug("Failed to launch a browser: {Message}", e.Message);
logger.LogWarning("Unable to launch the browser. Url '{Url}'.", launchUrl);
}
}

Expand Down
4 changes: 4 additions & 0 deletions src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,10 @@ internal static class ProjectGraphUtilities
}
catch (Exception e) when (e is not OperationCanceledException)
{
// ProejctGraph aggregates OperationCanceledException exception,
// throw here to propagate the cancellation.
cancellationToken.ThrowIfCancellationRequested();

logger.LogDebug("Failed to load project graph.");

if (e is AggregateException { InnerExceptions: var innerExceptions })
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,11 @@ internal enum TestFlags
/// This allows tests to trigger key based events.
/// </summary>
ReadKeyFromStdin = 1 << 3,

/// <summary>
/// Redirects the output of the launched browser process to watch output.
/// </summary>
RedirectBrowserOutput = 1 << 4,
}

internal sealed record EnvironmentOptions(
Expand Down
16 changes: 10 additions & 6 deletions src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs
Original file line number Diff line number Diff line change
Expand Up @@ -363,7 +363,7 @@ private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates2 updat
_logger.Log(MessageDescriptor.RestartNeededToApplyChanges);
}

var diagnosticsToDisplayInApp = new List<string>();
var errorsToDisplayInApp = new List<string>();

// Display errors first, then warnings:
ReportCompilationDiagnostics(DiagnosticSeverity.Error);
Expand All @@ -373,7 +373,7 @@ private async ValueTask DisplayResultsAsync(WatchHotReloadService.Updates2 updat
// report or clear diagnostics in the browser UI
await ForEachProjectAsync(
_runningProjects,
(project, cancellationToken) => project.Clients.ReportCompilationErrorsInApplicationAsync([.. diagnosticsToDisplayInApp], cancellationToken).AsTask() ?? Task.CompletedTask,
(project, cancellationToken) => project.Clients.ReportCompilationErrorsInApplicationAsync([.. errorsToDisplayInApp], cancellationToken).AsTask() ?? Task.CompletedTask,
cancellationToken);

void ReportCompilationDiagnostics(DiagnosticSeverity severity)
Expand Down Expand Up @@ -437,16 +437,20 @@ void ReportRudeEdits()
bool IsAutoRestartEnabled(ProjectId id)
=> runningProjectInfos.TryGetValue(id, out var info) && info.RestartWhenChangesHaveNoEffect;

void ReportDiagnostic(Diagnostic diagnostic, MessageDescriptor descriptor, string prefix = "")
void ReportDiagnostic(Diagnostic diagnostic, MessageDescriptor descriptor, string autoPrefix = "")
{
var display = CSharpDiagnosticFormatter.Instance.Format(diagnostic);
var args = new[] { prefix, display };
var args = new[] { autoPrefix, display };

_logger.Log(descriptor, args);

if (descriptor.Severity != MessageSeverity.None)
if (autoPrefix != "")
{
diagnosticsToDisplayInApp.Add(descriptor.GetMessage(args));
errorsToDisplayInApp.Add(MessageDescriptor.RestartingApplicationToApplyChanges.GetMessage());
}
else if (descriptor.Severity != MessageSeverity.None)
{
errorsToDisplayInApp.Add(descriptor.GetMessage(args));
}
}

Expand Down
106 changes: 54 additions & 52 deletions src/BuiltInTools/dotnet-watch/Process/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -9,10 +9,15 @@ namespace Microsoft.DotNet.Watch
{
internal sealed class ProcessRunner(TimeSpan processCleanupTimeout)
{
private sealed class ProcessState
private sealed class ProcessState(Process process) : IDisposable
{
public Process Process { get; } = process;

public int ProcessId;
public bool HasExited;

public void Dispose()
=> Process.Dispose();
}

// For testing purposes only, lock on access.
Expand All @@ -31,75 +36,40 @@ public static IReadOnlyCollection<int> GetRunningApplicationProcesses()
/// </summary>
public async Task<int> RunAsync(ProcessSpec processSpec, ILogger logger, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken)
{
var state = new ProcessState();
var stopwatch = new Stopwatch();

var onOutput = processSpec.OnOutput;

using var process = CreateProcess(processSpec, onOutput, state, logger);

stopwatch.Start();

Exception? launchException = null;
try
{
if (!process.Start())
{
throw new InvalidOperationException("Process can't be started.");
}

state.ProcessId = process.Id;

if (processSpec.IsUserApplication)
{
lock (s_runningApplicationProcesses)
{
s_runningApplicationProcesses.Add(state.ProcessId);
}
}

if (onOutput != null)
{
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}
}
catch (Exception e)
{
launchException = e;
}

var argsDisplay = processSpec.GetArgumentsDisplay();
if (launchException == null)
using var state = TryStartProcessImpl(processSpec, logger);
if (state == null)
{
logger.Log(MessageDescriptor.LaunchedProcess, processSpec.Executable, argsDisplay, state.ProcessId);
}
else
{
logger.Log(MessageDescriptor.FailedToLaunchProcess, processSpec.Executable, argsDisplay, launchException.Message);
return int.MinValue;
}

if (launchResult != null)
if (processSpec.IsUserApplication)
{
launchResult.ProcessId = process.Id;
lock (s_runningApplicationProcesses)
{
s_runningApplicationProcesses.Add(state.ProcessId);
}
}

launchResult?.ProcessId = state.ProcessId;

int? exitCode = null;

try
{
try
{
await process.WaitForExitAsync(processTerminationToken);
await state.Process.WaitForExitAsync(processTerminationToken);
}
catch (OperationCanceledException)
{
// Process termination requested via cancellation token.
// Either Ctrl+C was pressed or the process is being restarted.

// Non-cancellable to not leave orphaned processes around blocking resources:
await TerminateProcessAsync(process, processSpec, state, logger, CancellationToken.None);
await TerminateProcessAsync(state.Process, processSpec, state, logger, CancellationToken.None);
}
}
catch (Exception e)
Expand All @@ -125,14 +95,14 @@ public async Task<int> RunAsync(ProcessSpec processSpec, ILogger logger, Process

try
{
exitCode = process.ExitCode;
exitCode = state.Process.ExitCode;
}
catch
{
exitCode = null;
}

logger.Log(MessageDescriptor.ProcessRunAndExited, process.Id, stopwatch.ElapsedMilliseconds, exitCode);
logger.Log(MessageDescriptor.ProcessRunAndExited, state.ProcessId, stopwatch.ElapsedMilliseconds, exitCode);

if (processSpec.IsUserApplication)
{
Expand All @@ -159,21 +129,28 @@ public async Task<int> RunAsync(ProcessSpec processSpec, ILogger logger, Process
return exitCode ?? int.MinValue;
}

private static Process CreateProcess(ProcessSpec processSpec, Action<OutputLine>? onOutput, ProcessState state, ILogger logger)
internal static Process? TryStartProcess(ProcessSpec processSpec, ILogger logger)
=> TryStartProcessImpl(processSpec, logger)?.Process;

private static ProcessState? TryStartProcessImpl(ProcessSpec processSpec, ILogger logger)
{
var onOutput = processSpec.OnOutput;

var process = new Process
{
EnableRaisingEvents = true,
StartInfo =
{
FileName = processSpec.Executable,
UseShellExecute = false,
UseShellExecute = processSpec.UseShellExecute,
WorkingDirectory = processSpec.WorkingDirectory,
RedirectStandardOutput = onOutput != null,
RedirectStandardError = onOutput != null,
}
};

var state = new ProcessState(process);

if (processSpec.IsUserApplication && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
process.StartInfo.CreateNewProcessGroup = true;
Expand Down Expand Up @@ -229,7 +206,32 @@ private static Process CreateProcess(ProcessSpec processSpec, Action<OutputLine>
};
}

return process;
var argsDisplay = processSpec.GetArgumentsDisplay();

try
{
if (!process.Start())
{
throw new InvalidOperationException("Process can't be started.");
}
state.ProcessId = process.Id;

if (onOutput != null)
{
process.BeginOutputReadLine();
process.BeginErrorReadLine();
}

logger.Log(MessageDescriptor.LaunchedProcess, processSpec.Executable, argsDisplay, state.ProcessId);
return state;
}
catch (Exception e)
{
logger.Log(MessageDescriptor.FailedToLaunchProcess, processSpec.Executable, argsDisplay, e.Message);

state.Dispose();
return null;
}
}

private async ValueTask TerminateProcessAsync(Process process, ProcessSpec processSpec, ProcessState state, ILogger logger, CancellationToken cancellationToken)
Expand Down
Loading
Loading