Skip to content
Merged
Show file tree
Hide file tree
Changes from 2 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
6 changes: 3 additions & 3 deletions src/BuiltInTools/AspireService/AspireServerService.cs
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ private async ValueTask SendNotificationAsync<TNotification>(TNotification notif
var jsonSerialized = JsonSerializer.SerializeToUtf8Bytes(notification, JsonSerializerOptions);
await SendMessageAsync(dcpId, jsonSerialized, cancelationToken);
}
catch (Exception e) when (LogAndPropagate(e))
catch (Exception e) when (e is not OperationCanceledException && LogAndPropagate(e))
{
}

Expand Down Expand Up @@ -340,7 +340,7 @@ private async Task HandleStartSessionRequestAsync(HttpContext context)
context.Response.StatusCode = (int)HttpStatusCode.Created;
context.Response.Headers.Location = $"{context.Request.Scheme}://{context.Request.Host}{context.Request.Path}/{sessionId}";
}
catch (Exception e)
catch (Exception e) when (e is not OperationCanceledException)
{
Log($"Failed to start project{(projectPath == null ? "" : $" '{projectPath}'")}: {e}");

Expand Down Expand Up @@ -419,7 +419,7 @@ private async ValueTask HandleStopSessionRequestAsync(HttpContext context, strin
var sessionExists = await _aspireServerEvents.StopSessionAsync(context.GetDcpId(), sessionId, _shutdownCancellationTokenSource.Token);
context.Response.StatusCode = (int)(sessionExists ? HttpStatusCode.OK : HttpStatusCode.NoContent);
}
catch (Exception e)
catch (Exception e) when (e is not OperationCanceledException)
{
Log($"[#{sessionId}] Failed to stop: {e}");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,9 @@
<ItemGroup Condition="'$(DotNetBuildSourceOnly)' == 'true' and '$(TargetFramework)' == 'net6.0'">
<FrameworkReference Update="Microsoft.NETCore.App" TargetingPackVersion="6.0.0" />
</ItemGroup>
<ItemGroup>
<Compile Include="..\dotnet-watch\Utilities\ProcessUtilities.cs" Link="ProcessUtilities.cs" />
</ItemGroup>

<ItemGroup>
<InternalsVisibleTo Include="Microsoft.Extensions.DotNetDeltaApplier.Tests" />
Expand Down
38 changes: 23 additions & 15 deletions src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
using System.Diagnostics;
using System.IO.Pipes;
using Microsoft.DotNet.HotReload;
using Microsoft.DotNet.Watch;

/// <summary>
/// The runtime startup hook looks for top-level type named "StartupHook".
Expand Down Expand Up @@ -58,7 +59,7 @@ public static void Initialize()
return;
}

RegisterPosixSignalHandlers();
RegisterSignalHandlers();

var agent = new HotReloadAgent();
try
Expand All @@ -79,27 +80,34 @@ public static void Initialize()
}
}

private static void RegisterPosixSignalHandlers()
private static void RegisterSignalHandlers()
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
ProcessUtilities.EnableWindowsCtrlCHandling(Log);
}
else
{
#if NET10_0_OR_GREATER
// Register a handler for SIGTERM to allow graceful shutdown of the application on Unix.
// See https://github.com/dotnet/docs/issues/46226.
// Register a handler for SIGTERM to allow graceful shutdown of the application on Unix.
// See https://github.com/dotnet/docs/issues/46226.

// Note: registered handlers are executed in reverse order of their registration.
// Since the startup hook is executed before any code of the application, it is the first handler registered and thus the last to run.

s_signalRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
{
Log($"SIGTERM received. Cancel={context.Cancel}");
// Note: registered handlers are executed in reverse order of their registration.
// Since the startup hook is executed before any code of the application, it is the first handler registered and thus the last to run.

if (!context.Cancel)
s_signalRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
{
Environment.Exit(0);
}
});
Log($"SIGTERM received. Cancel={context.Cancel}");

Log("Posix signal handlers registered.");
if (!context.Cancel)
{
Environment.Exit(0);
}
});

Log("Posix signal handlers registered.");
#endif
}
}

private static async ValueTask InitializeAsync(NamedPipeClientStream pipeClient, HotReloadAgent agent, CancellationToken cancellationToken)
Expand Down
6 changes: 1 addition & 5 deletions src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs
Original file line number Diff line number Diff line change
Expand Up @@ -157,11 +157,7 @@ async Task StartChannelReader(CancellationToken cancellationToken)
await _service.NotifyLogMessageAsync(dcpId, sessionId, isStdErr: line.IsError, data: line.Content, cancellationToken);
}
}
catch (OperationCanceledException)
{
// nop
}
catch (Exception e)
catch (Exception e) when (e is not OperationCanceledException)
{
Reporter.Error($"Unexpected error reading output of session '{sessionId}': {e}");
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,7 +39,7 @@ internal static class ProjectGraphUtilities

return new ProjectGraph([entryPoint], collection, projectInstanceFactory: null, cancellationToken);
}
catch (Exception e)
catch (Exception e) when (e is not OperationCanceledException)
{
reporter.Verbose("Failed to load project graph.");

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,7 +130,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken)
};

var launchResult = new ProcessLaunchResult();
var runningProcess = _processRunner.RunAsync(processSpec, processReporter, isUserApplication: true, launchResult, processTerminationSource.Token);
var runningProcess = _processRunner.RunAsync(processSpec, processReporter, launchResult, processTerminationSource.Token);
if (launchResult.ProcessId == null)
{
// error already reported
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -796,6 +796,7 @@ await FileWatcher.WaitForFileChangeAsync(
{
Executable = _context.EnvironmentOptions.MuxerPath,
WorkingDirectory = Path.GetDirectoryName(projectPath)!,
IsUserApplication = false,
OnOutput = line =>
{
lock (buildOutput)
Expand All @@ -804,12 +805,12 @@ await FileWatcher.WaitForFileChangeAsync(
}
},
// pass user-specified build arguments last to override defaults:
Arguments = ["build", projectPath, "-consoleLoggerParameters:NoSummary;Verbosity=minimal", .. binLogArguments, .. buildArguments]
Arguments = ["build", projectPath, "-consoleLoggerParameters:NoSummary;Verbosity=minimal", .. binLogArguments, .. buildArguments],
};

_context.Reporter.Output($"Building {projectPath} ...");

var exitCode = await _context.ProcessRunner.RunAsync(processSpec, _context.Reporter, isUserApplication: false, launchResult: null, cancellationToken);
var exitCode = await _context.ProcessRunner.RunAsync(processSpec, _context.Reporter, launchResult: null, cancellationToken);
return (exitCode == 0, buildOutput.ToImmutableArray(), projectPath);
}

Expand Down
116 changes: 54 additions & 62 deletions src/BuiltInTools/dotnet-watch/Process/ProcessRunner.cs
Original file line number Diff line number Diff line change
Expand Up @@ -6,24 +6,29 @@

namespace Microsoft.DotNet.Watch
{
internal sealed class ProcessRunner(
TimeSpan processCleanupTimeout,
CancellationToken shutdownCancellationToken)
internal sealed class ProcessRunner(TimeSpan processCleanupTimeout)
{
private const int SIGKILL = 9;
private const int SIGTERM = 15;

private sealed class ProcessState
{
public int ProcessId;
public bool HasExited;
}

// For testing purposes only, lock on access.
private static readonly HashSet<int> s_runningApplicationProcesses = [];

public static IReadOnlyCollection<int> GetRunningApplicationProcesses()
{
lock (s_runningApplicationProcesses)
{
return [.. s_runningApplicationProcesses];
}
}

/// <summary>
/// Launches a process.
/// </summary>
/// <param name="isUserApplication">True if the process is a user application, false if it is a helper process (e.g. msbuild).</param>
public async Task<int> RunAsync(ProcessSpec processSpec, IReporter reporter, bool isUserApplication, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken)
public async Task<int> RunAsync(ProcessSpec processSpec, IReporter reporter, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken)
{
var state = new ProcessState();
var stopwatch = new Stopwatch();
Expand All @@ -49,6 +54,14 @@ public async Task<int> RunAsync(ProcessSpec processSpec, IReporter reporter, boo

state.ProcessId = process.Id;

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

if (onOutput != null)
{
process.BeginOutputReadLine();
Expand Down Expand Up @@ -90,12 +103,12 @@ public async Task<int> RunAsync(ProcessSpec processSpec, IReporter reporter, boo
// Either Ctrl+C was pressed or the process is being restarted.

// Non-cancellable to not leave orphaned processes around blocking resources:
await TerminateProcessAsync(process, state, reporter, CancellationToken.None);
await TerminateProcessAsync(process, processSpec, state, reporter, CancellationToken.None);
}
}
catch (Exception e)
{
if (isUserApplication)
if (processSpec.IsUserApplication)
{
reporter.Error($"Application failed: {e.Message}");
}
Expand All @@ -104,6 +117,14 @@ public async Task<int> RunAsync(ProcessSpec processSpec, IReporter reporter, boo
{
stopwatch.Stop();

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

state.HasExited = true;

try
Expand All @@ -117,7 +138,7 @@ public async Task<int> RunAsync(ProcessSpec processSpec, IReporter reporter, boo

reporter.Verbose($"Process id {process.Id} ran for {stopwatch.ElapsedMilliseconds}ms and exited with exit code {exitCode}.");

if (isUserApplication)
if (processSpec.IsUserApplication)
{
if (exitCode == 0)
{
Expand Down Expand Up @@ -157,6 +178,11 @@ private static Process CreateProcess(ProcessSpec processSpec, Action<OutputLine>
}
};

if (processSpec.IsUserApplication && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
process.StartInfo.CreateNewProcessGroup = true;
}

if (processSpec.EscapedArguments is not null)
{
process.StartInfo.Arguments = processSpec.EscapedArguments;
Expand Down Expand Up @@ -210,28 +236,21 @@ private static Process CreateProcess(ProcessSpec processSpec, Action<OutputLine>
return process;
}

private async ValueTask TerminateProcessAsync(Process process, ProcessState state, IReporter reporter, CancellationToken cancellationToken)
private async ValueTask TerminateProcessAsync(Process process, ProcessSpec processSpec, ProcessState state, IReporter reporter, CancellationToken cancellationToken)
{
if (!shutdownCancellationToken.IsCancellationRequested)
{
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
{
// Ctrl+C hasn't been sent, force termination.
// We don't have means to terminate gracefully on Windows (https://github.com/dotnet/runtime/issues/109432)
TerminateProcess(process, state, reporter, force: true);
_ = await WaitForExitAsync(process, state, timeout: null, reporter, cancellationToken);
var forceOnly = RuntimeInformation.IsOSPlatform(OSPlatform.Windows) && !processSpec.IsUserApplication;

return;
}
else
{
// Ctrl+C hasn't been sent, send SIGTERM now:
TerminateProcess(process, state, reporter, force: false);
}
// Ctrl+C hasn't been sent.
TerminateProcess(process, state, reporter, forceOnly);

if (forceOnly)
{
_ = await WaitForExitAsync(process, state, timeout: null, reporter, cancellationToken);
return;
}

// Ctlr+C/SIGTERM has been sent, wait for the process to exit gracefully.
if (processCleanupTimeout.Milliseconds == 0 ||
if (processCleanupTimeout.TotalMilliseconds == 0 ||
!await WaitForExitAsync(process, state, processCleanupTimeout, reporter, cancellationToken))
{
// Force termination if the process is still running after the timeout.
Expand Down Expand Up @@ -327,55 +346,28 @@ private static void TerminateProcess(Process process, ProcessState state, IRepor

private static void TerminateWindowsProcess(Process process, ProcessState state, IReporter reporter, bool force)
{
// Needs API: https://github.com/dotnet/runtime/issues/109432
// Code below does not work because the process creation needs CREATE_NEW_PROCESS_GROUP flag.
var processId = state.ProcessId;

reporter.Verbose($"Terminating process {state.ProcessId}.");
reporter.Verbose($"Terminating process {processId} ({(force ? "Kill" : "Ctrl+C")}).");

if (force)
{
process.Kill();
}
#if TODO
else
{
const uint CTRL_C_EVENT = 0;

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool AttachConsole(uint dwProcessId);

[DllImport("kernel32.dll", SetLastError = true)]
static extern bool FreeConsole();

if (AttachConsole((uint)state.ProcessId) &&
GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) &&
FreeConsole())
{
return;
}

var error = Marshal.GetLastPInvokeError();
reporter.Verbose($"Failed to send Ctrl+C to process {state.ProcessId}: {Marshal.GetPInvokeErrorMessage(error)} (code {error})");
ProcessUtilities.SendWindowsCtrlCEvent(processId, m => reporter.Verbose(m));
}
#endif
}

private static void TerminateUnixProcess(ProcessState state, IReporter reporter, bool force)
{
[DllImport("libc", SetLastError = true, EntryPoint = "kill")]
static extern int sys_kill(int pid, int sig);

reporter.Verbose($"Terminating process {state.ProcessId} ({(force ? "SIGKILL" : "SIGTERM")}).");

var result = sys_kill(state.ProcessId, force ? SIGKILL : SIGTERM);
if (result != 0)
{
var error = Marshal.GetLastPInvokeError();
reporter.Verbose($"Error while sending SIGTERM to process {state.ProcessId}: {Marshal.GetPInvokeErrorMessage(error)} (code {error}).");
}
ProcessUtilities.SendPosixSignal(
state.ProcessId,
signal: force ? ProcessUtilities.SIGKILL : ProcessUtilities.SIGTERM,
log: m => reporter.Verbose(m));
}
}
}
5 changes: 5 additions & 0 deletions src/BuiltInTools/dotnet-watch/Process/ProcessSpec.cs
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,11 @@ internal sealed class ProcessSpec
public ProcessExitAction? OnExit { get; set; }
public CancellationToken CancelOutputCapture { get; set; }

/// <summary>
/// True if the process is a user application, false if it is a helper process (e.g. dotnet build).</param>
/// </summary>
public bool IsUserApplication { get; set; }

public string? ShortDisplayName()
=> Path.GetFileNameWithoutExtension(Executable);

Expand Down
3 changes: 2 additions & 1 deletion src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs
Original file line number Diff line number Diff line change
Expand Up @@ -47,8 +47,9 @@ public EnvironmentOptions EnvironmentOptions
var processSpec = new ProcessSpec
{
Executable = EnvironmentOptions.MuxerPath,
IsUserApplication = true,
WorkingDirectory = projectOptions.WorkingDirectory,
OnOutput = onOutput
OnOutput = onOutput,
};

var environmentBuilder = EnvironmentVariablesBuilder.FromCurrentEnvironment();
Expand Down
Loading
Loading