Skip to content

Commit ac1a9d7

Browse files
authored
[release/9.0.2xx]: Enable Ctrl+C forwarding to child processes (#46012)
1 parent 889b814 commit ac1a9d7

File tree

14 files changed

+83
-81
lines changed

14 files changed

+83
-81
lines changed

src/BuiltInTools/dotnet-watch/EnvironmentOptions.cs

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,7 @@ internal enum TestFlags
2727
internal sealed record EnvironmentOptions(
2828
string WorkingDirectory,
2929
string MuxerPath,
30+
TimeSpan ProcessCleanupTimeout,
3031
bool IsPollingEnabled = false,
3132
bool SuppressHandlingStaticContentFiles = false,
3233
bool SuppressMSBuildIncrementalism = false,
@@ -40,6 +41,7 @@ internal sealed record EnvironmentOptions(
4041
(
4142
WorkingDirectory: Directory.GetCurrentDirectory(),
4243
MuxerPath: GetMuxerPathFromEnvironment(),
44+
ProcessCleanupTimeout: EnvironmentVariables.ProcessCleanupTimeout,
4345
IsPollingEnabled: EnvironmentVariables.IsPollingEnabled,
4446
SuppressHandlingStaticContentFiles: EnvironmentVariables.SuppressHandlingStaticContentFiles,
4547
SuppressMSBuildIncrementalism: EnvironmentVariables.SuppressMSBuildIncrementalism,

src/BuiltInTools/dotnet-watch/EnvironmentVariables.cs

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -22,6 +22,7 @@ public static partial class Names
2222
public static bool IsPollingEnabled => ReadBool("DOTNET_USE_POLLING_FILE_WATCHER");
2323
public static bool SuppressEmojis => ReadBool("DOTNET_WATCH_SUPPRESS_EMOJIS");
2424
public static bool RestartOnRudeEdit => ReadBool("DOTNET_WATCH_RESTART_ON_RUDE_EDIT");
25+
public static TimeSpan ProcessCleanupTimeout => ReadTimeSpan("DOTNET_WATCH_PROCESS_CLEANUP_TIMEOUT_MS");
2526

2627
public static string SdkRootDirectory =>
2728
#if DEBUG
@@ -43,4 +44,7 @@ public static partial class Names
4344

4445
private static bool ReadBool(string variableName)
4546
=> Environment.GetEnvironmentVariable(variableName) is var value && (value == "1" || bool.TryParse(value, out var boolValue) && boolValue);
47+
48+
private static TimeSpan ReadTimeSpan(string variableName)
49+
=> Environment.GetEnvironmentVariable(variableName) is var value && long.TryParse(value, out var intValue) && intValue >= 0 ? TimeSpan.FromMilliseconds(intValue) : TimeSpan.FromSeconds(5);
4650
}

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

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ namespace Microsoft.DotNet.Watch
1515
internal sealed class CompilationHandler : IDisposable
1616
{
1717
public readonly IncrementalMSBuildWorkspace Workspace;
18+
public readonly EnvironmentOptions EnvironmentOptions;
1819

1920
private readonly IReporter _reporter;
2021
private readonly WatchHotReloadService _hotReloadService;
@@ -43,13 +44,17 @@ internal sealed class CompilationHandler : IDisposable
4344
/// </summary>
4445
private ImmutableArray<string> _currentAggregateCapabilities;
4546

47+
private readonly CancellationToken _shutdownCancellationToken;
48+
4649
private bool _isDisposed;
4750

48-
public CompilationHandler(IReporter reporter)
51+
public CompilationHandler(IReporter reporter, EnvironmentOptions environmentOptions, CancellationToken shutdownCancellationToken)
4952
{
5053
_reporter = reporter;
54+
EnvironmentOptions = environmentOptions;
5155
Workspace = new IncrementalMSBuildWorkspace(reporter);
5256
_hotReloadService = new WatchHotReloadService(Workspace.CurrentSolution.Services, GetAggregateCapabilitiesAsync);
57+
_shutdownCancellationToken = shutdownCancellationToken;
5358
}
5459

5560
public void Dispose()
@@ -154,6 +159,7 @@ private static DeltaApplier CreateDeltaApplier(HotReloadProfile profile, Project
154159
var runningProject = new RunningProject(
155160
projectNode,
156161
projectOptions,
162+
EnvironmentOptions,
157163
deltaApplier,
158164
processReporter,
159165
browserRefreshServer,
@@ -542,16 +548,10 @@ private void UpdateRunningProjects(Func<ImmutableDictionary<string, ImmutableArr
542548
}
543549
}
544550

545-
private static async ValueTask<IReadOnlyList<int>> TerminateRunningProjects(IEnumerable<RunningProject> projects, CancellationToken cancellationToken)
551+
private async ValueTask<IReadOnlyList<int>> TerminateRunningProjects(IEnumerable<RunningProject> projects, CancellationToken cancellationToken)
546552
{
547-
// cancel first, this will cause the process tasks to complete:
548-
foreach (var project in projects)
549-
{
550-
project.ProcessTerminationSource.Cancel();
551-
}
552-
553553
// wait for all tasks to complete:
554-
return await Task.WhenAll(projects.Select(p => p.RunningProcess)).WaitAsync(cancellationToken);
554+
return await Task.WhenAll(projects.Select(p => p.TerminateAsync(_shutdownCancellationToken).AsTask())).WaitAsync(cancellationToken);
555555
}
556556

557557
private static Task ForEachProjectAsync(ImmutableDictionary<string, ImmutableArray<RunningProject>> projects, Func<RunningProject, CancellationToken, Task> action, CancellationToken cancellationToken)

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

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ namespace Microsoft.DotNet.Watch
1212
internal sealed class RunningProject(
1313
ProjectGraphNode projectNode,
1414
ProjectOptions options,
15+
EnvironmentOptions environmentOptions,
1516
DeltaApplier deltaApplier,
1617
IReporter reporter,
1718
BrowserRefreshServer? browserRefreshServer,
@@ -68,5 +69,24 @@ public async ValueTask WaitForProcessRunningAsync(CancellationToken cancellation
6869
{
6970
await DeltaApplier.WaitForProcessRunningAsync(cancellationToken);
7071
}
72+
73+
public async ValueTask<int> TerminateAsync(CancellationToken shutdownCancellationToken)
74+
{
75+
if (shutdownCancellationToken.IsCancellationRequested)
76+
{
77+
// Ctrl+C sent, wait for the process to exit
78+
try
79+
{
80+
_ = await RunningProcess.WaitAsync(environmentOptions.ProcessCleanupTimeout, CancellationToken.None);
81+
}
82+
catch (TimeoutException)
83+
{
84+
// nop
85+
}
86+
}
87+
88+
ProcessTerminationSource.Cancel();
89+
return await RunningProcess;
90+
}
7191
}
7292
}

src/BuiltInTools/dotnet-watch/HotReloadDotNetWatcher.cs

Lines changed: 12 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -72,7 +72,6 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
7272
var waitForFileChangeBeforeRestarting = true;
7373
EvaluationResult? evaluationResult = null;
7474
RunningProject? rootRunningProject = null;
75-
Task<ImmutableList<ChangedFile>>? fileWatcherTask = null;
7675
IRuntimeProcessLauncher? runtimeProcessLauncher = null;
7776
CompilationHandler? compilationHandler = null;
7877
Action<ChangedPath>? fileChangedCallback = null;
@@ -101,7 +100,7 @@ public override async Task WatchAsync(CancellationToken shutdownCancellationToke
101100

102101
await using var browserConnector = new BrowserConnector(Context);
103102
var projectMap = new ProjectNodeMap(evaluationResult.ProjectGraph, Context.Reporter);
104-
compilationHandler = new CompilationHandler(Context.Reporter);
103+
compilationHandler = new CompilationHandler(Context.Reporter, Context.EnvironmentOptions, shutdownCancellationToken);
105104
var staticFileHandler = new StaticFileHandler(Context.Reporter, projectMap, browserConnector);
106105
var scopedCssFileHandler = new ScopedCssFileHandler(Context.Reporter, projectMap, browserConnector);
107106
var projectLauncher = new ProjectLauncher(Context, projectMap, browserConnector, compilationHandler, iteration);
@@ -517,38 +516,24 @@ async Task<ImmutableList<ChangedFile>> CaptureChangedFilesSnapshot(ImmutableDict
517516
await compilationHandler.TerminateNonRootProcessesAndDispose(CancellationToken.None);
518517
}
519518

520-
if (!rootProcessTerminationSource.IsCancellationRequested)
519+
if (rootRunningProject != null)
521520
{
522-
rootProcessTerminationSource.Cancel();
521+
await rootRunningProject.TerminateAsync(shutdownCancellationToken);
523522
}
524523

525-
try
526-
{
527-
// Wait for the root process to exit.
528-
await Task.WhenAll(new[] { (Task?)rootRunningProject?.RunningProcess, fileWatcherTask }.Where(t => t != null)!);
529-
}
530-
catch (OperationCanceledException) when (!shutdownCancellationToken.IsCancellationRequested)
524+
if (runtimeProcessLauncher != null)
531525
{
532-
// nop
526+
await runtimeProcessLauncher.DisposeAsync();
533527
}
534-
finally
535-
{
536-
fileWatcherTask = null;
537528

538-
if (runtimeProcessLauncher != null)
539-
{
540-
await runtimeProcessLauncher.DisposeAsync();
541-
}
542-
543-
rootRunningProject?.Dispose();
529+
rootRunningProject?.Dispose();
544530

545-
if (waitForFileChangeBeforeRestarting &&
546-
!shutdownCancellationToken.IsCancellationRequested &&
547-
!forceRestartCancellationSource.IsCancellationRequested)
548-
{
549-
using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token);
550-
await WaitForFileChangeBeforeRestarting(fileWatcher, evaluationResult, shutdownOrForcedRestartSource.Token);
551-
}
531+
if (waitForFileChangeBeforeRestarting &&
532+
!shutdownCancellationToken.IsCancellationRequested &&
533+
!forceRestartCancellationSource.IsCancellationRequested)
534+
{
535+
using var shutdownOrForcedRestartSource = CancellationTokenSource.CreateLinkedTokenSource(shutdownCancellationToken, forceRestartCancellationSource.Token);
536+
await WaitForFileChangeBeforeRestarting(fileWatcher, evaluationResult, shutdownOrForcedRestartSource.Token);
552537
}
553538
}
554539
}

src/BuiltInTools/dotnet-watch/Internal/PhysicalConsole.cs

Lines changed: 10 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -17,27 +17,7 @@ internal sealed class PhysicalConsole : IConsole
1717
public PhysicalConsole(TestFlags testFlags)
1818
{
1919
Console.OutputEncoding = Encoding.UTF8;
20-
21-
bool readFromStdin;
22-
if (testFlags.HasFlag(TestFlags.ReadKeyFromStdin))
23-
{
24-
readFromStdin = true;
25-
}
26-
else
27-
{
28-
try
29-
{
30-
Console.TreatControlCAsInput = true;
31-
readFromStdin = false;
32-
}
33-
catch
34-
{
35-
// fails when stdin is redirected
36-
readFromStdin = true;
37-
}
38-
}
39-
40-
_ = readFromStdin ? ListenToStandardInputAsync() : ListenToConsoleKeyPressAsync();
20+
_ = testFlags.HasFlag(TestFlags.ReadKeyFromStdin) ? ListenToStandardInputAsync() : ListenToConsoleKeyPressAsync();
4121
}
4222

4323
private async Task ListenToStandardInputAsync()
@@ -73,14 +53,22 @@ private async Task ListenToStandardInputAsync()
7353
}
7454

7555
private Task ListenToConsoleKeyPressAsync()
76-
=> Task.Factory.StartNew(() =>
56+
{
57+
Console.CancelKeyPress += (s, e) =>
58+
{
59+
e.Cancel = true;
60+
KeyPressed?.Invoke(new ConsoleKeyInfo(CtrlC, ConsoleKey.C, shift: false, alt: false, control: true));
61+
};
62+
63+
return Task.Factory.StartNew(() =>
7764
{
7865
while (true)
7966
{
8067
var key = Console.ReadKey(intercept: true);
8168
KeyPressed?.Invoke(key);
8269
}
8370
}, TaskCreationOptions.LongRunning);
71+
}
8472

8573
public TextWriter Error => Console.Error;
8674
public TextWriter Out => Console.Out;

src/BuiltInTools/dotnet-watch/Properties/launchSettings.json

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,8 @@
88
"DOTNET_WATCH_DEBUG_SDK_DIRECTORY": "$(RepoRoot)artifacts\\bin\\redist\\$(Configuration)\\dotnet\\sdk\\$(Version)",
99
"DCP_IDE_REQUEST_TIMEOUT_SECONDS": "100000",
1010
"DCP_IDE_NOTIFICATION_TIMEOUT_SECONDS": "100000",
11-
"DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS": "100000"
11+
"DCP_IDE_NOTIFICATION_KEEPALIVE_SECONDS": "100000",
12+
"DOTNET_WATCH_PROCESS_CLEANUP_TIMEOUT_MS": "0"
1213
}
1314
}
1415
}

test/dotnet-watch.Tests/HotReload/ApplyDeltaTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -697,6 +697,8 @@ public async Task Aspire()
697697
App.AssertOutputContains("dotnet watch 🔥 Project baselines updated.");
698698
App.AssertOutputContains($"dotnet watch ⭐ Starting project: {serviceProjectPath}");
699699

700+
// Note: sending Ctrl+C via standard input is not the same as sending real Ctrl+C.
701+
// The latter terminates the processes gracefully on Windows, so exit codes -1 are actually not reported.
700702
App.SendControlC();
701703

702704
await App.AssertOutputLineStartsWith("dotnet watch 🛑 Shutdown requested. Press Ctrl+C again to force exit.");
@@ -711,8 +713,8 @@ public async Task Aspire()
711713
{
712714
// Unix process may return exit code = 128 + SIGTERM
713715
// Exited with error code 143
714-
await App.AssertOutputLine(line => line.Contains($"[WatchAspire.ApiService ({tfm})] Exited"), failure: _ => false);
715-
await App.AssertOutputLine(line => line.Contains($"[WatchAspire.AppHost ({tfm})] Exited"), failure: _ => false);
716+
await App.AssertOutputLine(line => line.Contains($"[WatchAspire.ApiService ({tfm})] Exited"));
717+
await App.AssertOutputLine(line => line.Contains($"[WatchAspire.AppHost ({tfm})] Exited"));
716718
}
717719

718720
await App.AssertOutputLineStartsWith("dotnet watch ⭐ Waiting for server to shutdown ...");

test/dotnet-watch.Tests/HotReload/CompilationHandlerTests.cs

Lines changed: 4 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -20,14 +20,16 @@ public async Task ReferenceOutputAssembly_False()
2020
var reporter = new TestReporter(Logger);
2121
var options = TestOptions.GetProjectOptions(["--project", hostProject]);
2222

23+
var environmentOptions = TestOptions.GetEnvironmentOptions(Environment.CurrentDirectory, "dotnet");
24+
2325
var factory = new MSBuildFileSetFactory(
2426
rootProjectFile: options.ProjectPath,
2527
buildArguments: [],
26-
environmentOptions: new EnvironmentOptions(Environment.CurrentDirectory, "dotnet"),
28+
environmentOptions: environmentOptions,
2729
reporter);
2830

2931
var projectGraph = factory.TryLoadProjectGraph(projectGraphRequired: false);
30-
var handler = new CompilationHandler(reporter);
32+
var handler = new CompilationHandler(reporter, environmentOptions, CancellationToken.None);
3133

3234
await handler.Workspace.UpdateProjectConeAsync(hostProject, CancellationToken.None);
3335

test/dotnet-watch.Tests/HotReload/RuntimeProcessLauncherTests.cs

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -100,7 +100,10 @@ private RunningWatcher StartWatcher(TestAsset testAsset, string[] args, string w
100100
var program = Program.TryCreate(
101101
TestOptions.GetCommandLineOptions(["--verbose", ..args, "--project", projectPath]),
102102
console,
103-
TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset),
103+
TestOptions.GetEnvironmentOptions(workingDirectory, TestContext.Current.ToolsetUnderTest.DotNetHostPath, testAsset) with
104+
{
105+
ProcessCleanupTimeout = TimeSpan.FromSeconds(0),
106+
},
104107
reporter,
105108
out var errorCode);
106109

0 commit comments

Comments
 (0)