Skip to content

Commit fc80484

Browse files
authored
Send Ctrl+C to launched process before killing it on Windows (#49847)
1 parent 88d3439 commit fc80484

25 files changed

+270
-134
lines changed

src/BuiltInTools/AspireService/AspireServerService.cs

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -170,7 +170,7 @@ private async ValueTask SendNotificationAsync<TNotification>(TNotification notif
170170
var jsonSerialized = JsonSerializer.SerializeToUtf8Bytes(notification, JsonSerializerOptions);
171171
await SendMessageAsync(dcpId, jsonSerialized, cancelationToken);
172172
}
173-
catch (Exception e) when (LogAndPropagate(e))
173+
catch (Exception e) when (e is not OperationCanceledException && LogAndPropagate(e))
174174
{
175175
}
176176

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

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

src/BuiltInTools/DotNetDeltaApplier/Microsoft.Extensions.DotNetDeltaApplier.csproj

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -25,6 +25,9 @@
2525
<ItemGroup Condition="'$(DotNetBuildSourceOnly)' == 'true' and '$(TargetFramework)' == 'net6.0'">
2626
<FrameworkReference Update="Microsoft.NETCore.App" TargetingPackVersion="6.0.0" />
2727
</ItemGroup>
28+
<ItemGroup>
29+
<Compile Include="..\dotnet-watch\Utilities\ProcessUtilities.cs" Link="ProcessUtilities.cs" />
30+
</ItemGroup>
2831

2932
<ItemGroup>
3033
<InternalsVisibleTo Include="Microsoft.Extensions.DotNetDeltaApplier.Tests" />

src/BuiltInTools/DotNetDeltaApplier/StartupHook.cs

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@
44
using System.Diagnostics;
55
using System.IO.Pipes;
66
using Microsoft.DotNet.HotReload;
7+
using Microsoft.DotNet.Watch;
78

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

61-
RegisterPosixSignalHandlers();
62+
RegisterSignalHandlers();
6263

6364
var agent = new HotReloadAgent();
6465
try
@@ -79,27 +80,34 @@ public static void Initialize()
7980
}
8081
}
8182

82-
private static void RegisterPosixSignalHandlers()
83+
private static void RegisterSignalHandlers()
8384
{
85+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
86+
{
87+
ProcessUtilities.EnableWindowsCtrlCHandling(Log);
88+
}
89+
else
90+
{
8491
#if NET10_0_OR_GREATER
85-
// Register a handler for SIGTERM to allow graceful shutdown of the application on Unix.
86-
// See https://github.com/dotnet/docs/issues/46226.
92+
// Register a handler for SIGTERM to allow graceful shutdown of the application on Unix.
93+
// See https://github.com/dotnet/docs/issues/46226.
8794

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

95-
if (!context.Cancel)
98+
s_signalRegistration = PosixSignalRegistration.Create(PosixSignal.SIGTERM, context =>
9699
{
97-
Environment.Exit(0);
98-
}
99-
});
100+
Log($"SIGTERM received. Cancel={context.Cancel}");
100101

101-
Log("Posix signal handlers registered.");
102+
if (!context.Cancel)
103+
{
104+
Environment.Exit(0);
105+
}
106+
});
107+
108+
Log("Posix signal handlers registered.");
102109
#endif
110+
}
103111
}
104112

105113
private static async ValueTask InitializeAsync(NamedPipeClientStream pipeClient, HotReloadAgent agent, CancellationToken cancellationToken)

src/BuiltInTools/dotnet-watch/Aspire/AspireServiceFactory.cs

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -157,11 +157,7 @@ async Task StartChannelReader(CancellationToken cancellationToken)
157157
await _service.NotifyLogMessageAsync(dcpId, sessionId, isStdErr: line.IsError, data: line.Content, cancellationToken);
158158
}
159159
}
160-
catch (OperationCanceledException)
161-
{
162-
// nop
163-
}
164-
catch (Exception e)
160+
catch (Exception e) when (e is not OperationCanceledException)
165161
{
166162
Reporter.Error($"Unexpected error reading output of session '{sessionId}': {e}");
167163
}

src/BuiltInTools/dotnet-watch/Build/ProjectGraphUtilities.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@ internal static class ProjectGraphUtilities
3939

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

src/BuiltInTools/dotnet-watch/HotReload/CompilationHandler.cs

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ public async ValueTask StartSessionAsync(CancellationToken cancellationToken)
130130
};
131131

132132
var launchResult = new ProcessLaunchResult();
133-
var runningProcess = _processRunner.RunAsync(processSpec, processReporter, isUserApplication: true, launchResult, processTerminationSource.Token);
133+
var runningProcess = _processRunner.RunAsync(processSpec, processReporter, launchResult, processTerminationSource.Token);
134134
if (launchResult.ProcessId == null)
135135
{
136136
// error already reported

src/BuiltInTools/dotnet-watch/HotReload/HotReloadDotNetWatcher.cs

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -796,6 +796,7 @@ await FileWatcher.WaitForFileChangeAsync(
796796
{
797797
Executable = _context.EnvironmentOptions.MuxerPath,
798798
WorkingDirectory = Path.GetDirectoryName(projectPath)!,
799+
IsUserApplication = false,
799800
OnOutput = line =>
800801
{
801802
lock (buildOutput)
@@ -804,12 +805,12 @@ await FileWatcher.WaitForFileChangeAsync(
804805
}
805806
},
806807
// pass user-specified build arguments last to override defaults:
807-
Arguments = ["build", projectPath, "-consoleLoggerParameters:NoSummary;Verbosity=minimal", .. binLogArguments, .. buildArguments]
808+
Arguments = ["build", projectPath, "-consoleLoggerParameters:NoSummary;Verbosity=minimal", .. binLogArguments, .. buildArguments],
808809
};
809810

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

812-
var exitCode = await _context.ProcessRunner.RunAsync(processSpec, _context.Reporter, isUserApplication: false, launchResult: null, cancellationToken);
813+
var exitCode = await _context.ProcessRunner.RunAsync(processSpec, _context.Reporter, launchResult: null, cancellationToken);
813814
return (exitCode == 0, buildOutput.ToImmutableArray(), projectPath);
814815
}
815816

src/BuiltInTools/dotnet-watch/Process/ProcessRunner.cs

Lines changed: 54 additions & 62 deletions
Original file line numberDiff line numberDiff line change
@@ -6,24 +6,29 @@
66

77
namespace Microsoft.DotNet.Watch
88
{
9-
internal sealed class ProcessRunner(
10-
TimeSpan processCleanupTimeout,
11-
CancellationToken shutdownCancellationToken)
9+
internal sealed class ProcessRunner(TimeSpan processCleanupTimeout)
1210
{
13-
private const int SIGKILL = 9;
14-
private const int SIGTERM = 15;
15-
1611
private sealed class ProcessState
1712
{
1813
public int ProcessId;
1914
public bool HasExited;
2015
}
2116

17+
// For testing purposes only, lock on access.
18+
private static readonly HashSet<int> s_runningApplicationProcesses = [];
19+
20+
public static IReadOnlyCollection<int> GetRunningApplicationProcesses()
21+
{
22+
lock (s_runningApplicationProcesses)
23+
{
24+
return [.. s_runningApplicationProcesses];
25+
}
26+
}
27+
2228
/// <summary>
2329
/// Launches a process.
2430
/// </summary>
25-
/// <param name="isUserApplication">True if the process is a user application, false if it is a helper process (e.g. msbuild).</param>
26-
public async Task<int> RunAsync(ProcessSpec processSpec, IReporter reporter, bool isUserApplication, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken)
31+
public async Task<int> RunAsync(ProcessSpec processSpec, IReporter reporter, ProcessLaunchResult? launchResult, CancellationToken processTerminationToken)
2732
{
2833
var state = new ProcessState();
2934
var stopwatch = new Stopwatch();
@@ -49,6 +54,14 @@ public async Task<int> RunAsync(ProcessSpec processSpec, IReporter reporter, boo
4954

5055
state.ProcessId = process.Id;
5156

57+
if (processSpec.IsUserApplication)
58+
{
59+
lock (s_runningApplicationProcesses)
60+
{
61+
s_runningApplicationProcesses.Add(state.ProcessId);
62+
}
63+
}
64+
5265
if (onOutput != null)
5366
{
5467
process.BeginOutputReadLine();
@@ -90,12 +103,12 @@ public async Task<int> RunAsync(ProcessSpec processSpec, IReporter reporter, boo
90103
// Either Ctrl+C was pressed or the process is being restarted.
91104

92105
// Non-cancellable to not leave orphaned processes around blocking resources:
93-
await TerminateProcessAsync(process, state, reporter, CancellationToken.None);
106+
await TerminateProcessAsync(process, processSpec, state, reporter, CancellationToken.None);
94107
}
95108
}
96109
catch (Exception e)
97110
{
98-
if (isUserApplication)
111+
if (processSpec.IsUserApplication)
99112
{
100113
reporter.Error($"Application failed: {e.Message}");
101114
}
@@ -104,6 +117,14 @@ public async Task<int> RunAsync(ProcessSpec processSpec, IReporter reporter, boo
104117
{
105118
stopwatch.Stop();
106119

120+
if (processSpec.IsUserApplication)
121+
{
122+
lock (s_runningApplicationProcesses)
123+
{
124+
s_runningApplicationProcesses.Remove(state.ProcessId);
125+
}
126+
}
127+
107128
state.HasExited = true;
108129

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

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

120-
if (isUserApplication)
141+
if (processSpec.IsUserApplication)
121142
{
122143
if (exitCode == 0)
123144
{
@@ -157,6 +178,11 @@ private static Process CreateProcess(ProcessSpec processSpec, Action<OutputLine>
157178
}
158179
};
159180

181+
if (processSpec.IsUserApplication && RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
182+
{
183+
process.StartInfo.CreateNewProcessGroup = true;
184+
}
185+
160186
if (processSpec.EscapedArguments is not null)
161187
{
162188
process.StartInfo.Arguments = processSpec.EscapedArguments;
@@ -210,28 +236,21 @@ private static Process CreateProcess(ProcessSpec processSpec, Action<OutputLine>
210236
return process;
211237
}
212238

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

224-
return;
225-
}
226-
else
227-
{
228-
// Ctrl+C hasn't been sent, send SIGTERM now:
229-
TerminateProcess(process, state, reporter, force: false);
230-
}
243+
// Ctrl+C hasn't been sent.
244+
TerminateProcess(process, state, reporter, forceOnly);
245+
246+
if (forceOnly)
247+
{
248+
_ = await WaitForExitAsync(process, state, timeout: null, reporter, cancellationToken);
249+
return;
231250
}
232251

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

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

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

335353
if (force)
336354
{
337355
process.Kill();
338356
}
339-
#if TODO
340357
else
341358
{
342-
const uint CTRL_C_EVENT = 0;
343-
344-
[DllImport("kernel32.dll", SetLastError = true)]
345-
static extern bool GenerateConsoleCtrlEvent(uint dwCtrlEvent, uint dwProcessGroupId);
346-
347-
[DllImport("kernel32.dll", SetLastError = true)]
348-
static extern bool AttachConsole(uint dwProcessId);
349-
350-
[DllImport("kernel32.dll", SetLastError = true)]
351-
static extern bool FreeConsole();
352-
353-
if (AttachConsole((uint)state.ProcessId) &&
354-
GenerateConsoleCtrlEvent(CTRL_C_EVENT, 0) &&
355-
FreeConsole())
356-
{
357-
return;
358-
}
359-
360-
var error = Marshal.GetLastPInvokeError();
361-
reporter.Verbose($"Failed to send Ctrl+C to process {state.ProcessId}: {Marshal.GetPInvokeErrorMessage(error)} (code {error})");
359+
ProcessUtilities.SendWindowsCtrlCEvent(processId, m => reporter.Verbose(m));
362360
}
363-
#endif
364361
}
365362

366363
private static void TerminateUnixProcess(ProcessState state, IReporter reporter, bool force)
367364
{
368-
[DllImport("libc", SetLastError = true, EntryPoint = "kill")]
369-
static extern int sys_kill(int pid, int sig);
370-
371365
reporter.Verbose($"Terminating process {state.ProcessId} ({(force ? "SIGKILL" : "SIGTERM")}).");
372366

373-
var result = sys_kill(state.ProcessId, force ? SIGKILL : SIGTERM);
374-
if (result != 0)
375-
{
376-
var error = Marshal.GetLastPInvokeError();
377-
reporter.Verbose($"Error while sending SIGTERM to process {state.ProcessId}: {Marshal.GetPInvokeErrorMessage(error)} (code {error}).");
378-
}
367+
ProcessUtilities.SendPosixSignal(
368+
state.ProcessId,
369+
signal: force ? ProcessUtilities.SIGKILL : ProcessUtilities.SIGTERM,
370+
log: m => reporter.Verbose(m));
379371
}
380372
}
381373
}

src/BuiltInTools/dotnet-watch/Process/ProcessSpec.cs

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,11 @@ internal sealed class ProcessSpec
1515
public ProcessExitAction? OnExit { get; set; }
1616
public CancellationToken CancelOutputCapture { get; set; }
1717

18+
/// <summary>
19+
/// True if the process is a user application, false if it is a helper process (e.g. dotnet build).</param>
20+
/// </summary>
21+
public bool IsUserApplication { get; set; }
22+
1823
public string? ShortDisplayName()
1924
=> Path.GetFileNameWithoutExtension(Executable);
2025

src/BuiltInTools/dotnet-watch/Process/ProjectLauncher.cs

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -47,8 +47,9 @@ public EnvironmentOptions EnvironmentOptions
4747
var processSpec = new ProcessSpec
4848
{
4949
Executable = EnvironmentOptions.MuxerPath,
50+
IsUserApplication = true,
5051
WorkingDirectory = projectOptions.WorkingDirectory,
51-
OnOutput = onOutput
52+
OnOutput = onOutput,
5253
};
5354

5455
var environmentBuilder = EnvironmentVariablesBuilder.FromCurrentEnvironment();

0 commit comments

Comments
 (0)