Skip to content

Commit 1bcbc2a

Browse files
committed
Refactor for windows support
1 parent 5b15a76 commit 1bcbc2a

File tree

7 files changed

+187
-35
lines changed

7 files changed

+187
-35
lines changed

src/Cli/func/CancelKeyHandler.cs

Lines changed: 40 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See LICENSE in the project root for license information.
33

4+
using System.Runtime.InteropServices;
45
using Azure.Functions.Cli.Interfaces;
56
using Colors.Net;
67

@@ -10,13 +11,15 @@ internal static class CancelKeyHandler
1011
{
1112
private static readonly object _lock = new();
1213
private static IProcessManager _processManager;
13-
private static int _cancelKeyPressCount = 0;
14+
private static IConsoleReader _consoleReader;
1415
private static Action _onFirstCancel;
1516
private static Action _onSecondCancel;
17+
private static int _cancelKeyPressCount = 0;
1618

17-
internal static void Register(IProcessManager processManager, Action onFirstCancel = null, Action onSecondCancel = null)
19+
internal static void Register(IProcessManager processManager, IConsoleReader consoleReader, Action onFirstCancel = null, Action onSecondCancel = null)
1820
{
1921
_processManager = processManager ?? throw new ArgumentNullException(nameof(processManager));
22+
_consoleReader = consoleReader ?? throw new ArgumentNullException(nameof(consoleReader));
2023

2124
_onFirstCancel = onFirstCancel ?? (() => { });
2225
_onSecondCancel = onSecondCancel ?? (() => { });
@@ -32,28 +35,54 @@ internal static void HandleCancelKeyPress(object sender, ConsoleCancelEventArgs
3235

3336
if (_cancelKeyPressCount == 1)
3437
{
35-
ColoredConsole.WriteLine("Press Ctrl+C again to force exit.");
38+
var message = RuntimeInformation.IsOSPlatform(OSPlatform.Windows)
39+
? "Press 'q' to force exit."
40+
: "Press Ctrl+C again to force exit.";
41+
42+
ColoredConsole.WriteLine(message);
3643

3744
_onFirstCancel?.Invoke();
3845

3946
// Cancel first Ctrl+C to allow graceful cleanup
4047
e.Cancel = true;
4148

4249
_processManager.KillChildProcesses();
50+
51+
// Windows locks up the main thread processing the first Ctrl+C.
52+
// Start force-kill fallback that listens for `q` key press
53+
if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
54+
{
55+
Task.Run(() =>
56+
{
57+
while (true)
58+
{
59+
var key = _consoleReader.ReadKey(true);
60+
if (key.Key == ConsoleKey.Q)
61+
{
62+
ForceQuit();
63+
}
64+
}
65+
});
66+
}
4367
}
4468
else if (_cancelKeyPressCount >= 2)
4569
{
46-
ColoredConsole.WriteLine("Forcing exit...");
70+
ForceQuit();
71+
}
72+
}
73+
}
4774

48-
_onSecondCancel?.Invoke();
75+
private static void ForceQuit()
76+
{
77+
ColoredConsole.WriteLine("Forcing exit...");
4978

50-
Console.Out.Flush();
51-
Console.Error.Flush();
79+
_onSecondCancel?.Invoke();
5280

53-
// Hard exit
54-
_processManager.KillMainProcess();
55-
}
56-
}
81+
Console.Out.Flush();
82+
Console.Error.Flush();
83+
84+
// Hard exit
85+
_processManager.KillMainProcess();
5786
}
5887

5988
// Dispose method to unregister the event handler and reset state (for testing)

src/Cli/func/Common/ConsoleReader.cs

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
using Azure.Functions.Cli.Interfaces;
5+
6+
namespace Azure.Functions.Cli.Common;
7+
8+
public class ConsoleReader : IConsoleReader
9+
{
10+
public ConsoleKeyInfo ReadKey(bool intercept = true) => Console.ReadKey(intercept);
11+
}
Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
namespace Azure.Functions.Cli.Interfaces;
5+
6+
public interface IConsoleReader
7+
{
8+
public ConsoleKeyInfo ReadKey(bool intercept);
9+
}

src/Cli/func/Program.cs

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,7 +34,7 @@ internal static void Main(string[] args)
3434
_container = InitializeAutofacContainer();
3535
AppDomain.CurrentDomain.ProcessExit += CurrentDomain_ProcessExit;
3636

37-
CancelKeyHandler.Register(_container.Resolve<IProcessManager>());
37+
CancelKeyHandler.Register(_container.Resolve<IProcessManager>(), _container.Resolve<IConsoleReader>());
3838

3939
ConsoleApp.Run<Program>(args, _container);
4040
}
@@ -95,6 +95,10 @@ internal static IContainer InitializeAutofacContainer()
9595
.As<IProcessManager>()
9696
.SingleInstance();
9797

98+
builder.RegisterType<ConsoleReader>()
99+
.As<IConsoleReader>()
100+
.SingleInstance();
101+
98102
builder.RegisterType<SecretsManager>()
99103
.As<ISecretsManager>();
100104

Lines changed: 70 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
// Copyright (c) .NET Foundation. All rights reserved.
22
// Licensed under the MIT License. See LICENSE in the project root for license information.
33

4-
using Azure.Functions.Cli.Interfaces;
4+
using System.Runtime.InteropServices;
5+
using Azure.Functions.Cli.UnitTests.Helpers;
56
using FluentAssertions;
67
using Xunit;
78

@@ -10,14 +11,15 @@ namespace Azure.Functions.Cli.UnitTests;
1011
public class CancelKeyHandlerTests
1112
{
1213
[Fact]
13-
public void FirstCtrlC_ShouldInvokeOnFirstCancel_AndKillChildProcesses()
14+
public void CancelKeyHandler_FirstCancel_ShouldInvokeOnFirstCancel_AndKillChildProcesses()
1415
{
1516
try
1617
{
1718
// Arrange
1819
bool firstCancelCalled = false;
19-
var fakeProcessManager = new FakeProcessManager();
20-
CancelKeyHandler.Register(fakeProcessManager, onFirstCancel: () => firstCancelCalled = true);
20+
var fakeProcessManager = new TestProcessManager();
21+
var stubReader = new TestConsoleReader();
22+
CancelKeyHandler.Register(fakeProcessManager, stubReader, onFirstCancel: () => firstCancelCalled = true);
2123

2224
// Act
2325
var e = CreateFakeCancelEventArgs();
@@ -35,14 +37,15 @@ public void FirstCtrlC_ShouldInvokeOnFirstCancel_AndKillChildProcesses()
3537
}
3638

3739
[Fact]
38-
public void SecondCtrlC_ShouldInvokeOnSecondCancel_AndKillMainProcess()
40+
public void CancelKeyHandler_SecondCancel_ShouldInvokeOnSecondCancel_AndKillMainProcess()
3941
{
4042
try
4143
{
4244
// Arrange
4345
bool secondCancelCalled = false;
44-
var fakeProcessManager = new FakeProcessManager();
45-
CancelKeyHandler.Register(fakeProcessManager, onSecondCancel: () => secondCancelCalled = true);
46+
var fakeProcessManager = new TestProcessManager();
47+
var stubReader = new TestConsoleReader();
48+
CancelKeyHandler.Register(fakeProcessManager, stubReader, onSecondCancel: () => secondCancelCalled = true);
4649

4750
// Act
4851
var e = CreateFakeCancelEventArgs();
@@ -60,31 +63,75 @@ public void SecondCtrlC_ShouldInvokeOnSecondCancel_AndKillMainProcess()
6063
}
6164
}
6265

63-
private static ConsoleCancelEventArgs CreateFakeCancelEventArgs()
66+
[SkippableFact]
67+
public void CancelKeyHandler_Windows_FirstCancel_ShouldInvokeFirstOnCancel_AndKillChildProcesses()
6468
{
65-
var constructor = typeof(ConsoleCancelEventArgs)
66-
.GetConstructors(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0];
69+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
70+
{
71+
return; // Skip on non-Windows
72+
}
6773

68-
return (ConsoleCancelEventArgs)constructor.Invoke([ConsoleSpecialKey.ControlC]);
74+
try
75+
{
76+
bool firstCancelCalled = false;
77+
var fakeProcessManager = new TestProcessManager();
78+
var stubReader = new TestConsoleReader();
79+
CancelKeyHandler.Register(fakeProcessManager, consoleReader: stubReader, onFirstCancel: () => firstCancelCalled = true);
80+
81+
// Act
82+
var e = CreateFakeCancelEventArgs();
83+
CancelKeyHandler.HandleCancelKeyPress(null, e); // First Ctrl+C
84+
85+
// Assert
86+
firstCancelCalled.Should().BeTrue();
87+
fakeProcessManager.KillChildProcessesCalled.Should().BeTrue();
88+
fakeProcessManager.KillMainProcessCalled.Should().BeFalse();
89+
}
90+
finally
91+
{
92+
CancelKeyHandler.Dispose();
93+
}
6994
}
70-
}
7195

72-
internal class FakeProcessManager : IProcessManager
73-
{
74-
public bool KillChildProcessesCalled { get; private set; }
96+
[SkippableFact]
97+
public async Task CancelKeyHandler_Windows_FirstCancelWithQKey_ShouldInvokeOnSecondCancel_AndKillMainProcess()
98+
{
99+
if (!RuntimeInformation.IsOSPlatform(OSPlatform.Windows))
100+
{
101+
return; // Skip on non-Windows
102+
}
75103

76-
public bool KillMainProcessCalled { get; private set; }
104+
try
105+
{
106+
bool secondCancelCalled = false;
107+
var fakeProcessManager = new TestProcessManager();
108+
var stubReader = new TestConsoleReader();
109+
CancelKeyHandler.Register(fakeProcessManager, consoleReader: stubReader, onSecondCancel: () => secondCancelCalled = true);
77110

78-
public void KillChildProcesses() => KillChildProcessesCalled = true;
111+
var e = CreateFakeCancelEventArgs();
112+
CancelKeyHandler.HandleCancelKeyPress(null, e); // First Ctrl+C
79113

80-
public void KillMainProcess() => KillMainProcessCalled = true;
114+
// Simulate 'q' key press in the background
115+
stubReader.SimulateKeyPress(new ConsoleKeyInfo('q', ConsoleKey.Q, false, false, false));
81116

82-
// Other interface members omitted for this test
83-
public IEnumerable<IProcessInfo> GetProcessesByName(string processName) => null;
117+
// Briefly wait to allow `q` to be processed
118+
await Task.Delay(50);
84119

85-
public IProcessInfo GetCurrentProcess() => null;
120+
secondCancelCalled.Should().BeTrue();
121+
fakeProcessManager.KillChildProcessesCalled.Should().BeTrue();
122+
fakeProcessManager.KillMainProcessCalled.Should().BeTrue();
123+
}
124+
finally
125+
{
126+
CancelKeyHandler.Dispose();
127+
}
128+
}
86129

87-
public IProcessInfo GetProcessById(int processId) => null;
130+
private static ConsoleCancelEventArgs CreateFakeCancelEventArgs()
131+
{
132+
var constructor = typeof(ConsoleCancelEventArgs)
133+
.GetConstructors(System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)[0];
88134

89-
public bool RegisterChildProcess(System.Diagnostics.Process childProcess) => false;
135+
return (ConsoleCancelEventArgs)constructor.Invoke([ConsoleSpecialKey.ControlC]);
136+
}
90137
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
using Azure.Functions.Cli.Interfaces;
5+
6+
namespace Azure.Functions.Cli.UnitTests.Helpers;
7+
8+
internal class TestConsoleReader : IConsoleReader
9+
{
10+
private readonly Queue<ConsoleKeyInfo> _keys = new Queue<ConsoleKeyInfo>();
11+
12+
public void SimulateKeyPress(ConsoleKeyInfo key)
13+
{
14+
_keys.Enqueue(key);
15+
}
16+
17+
public ConsoleKeyInfo ReadKey(bool intercept = true)
18+
{
19+
while (_keys.Count == 0)
20+
{
21+
Thread.Sleep(10); // Prevent tight spin-loop, allow test to enqueue keys
22+
}
23+
24+
return _keys.Dequeue();
25+
}
26+
}
Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
// Copyright (c) .NET Foundation. All rights reserved.
2+
// Licensed under the MIT License. See LICENSE in the project root for license information.
3+
4+
using Azure.Functions.Cli.Interfaces;
5+
6+
namespace Azure.Functions.Cli.UnitTests.Helpers;
7+
8+
internal class TestProcessManager : IProcessManager
9+
{
10+
public bool KillChildProcessesCalled { get; private set; }
11+
12+
public bool KillMainProcessCalled { get; private set; }
13+
14+
public void KillChildProcesses() => KillChildProcessesCalled = true;
15+
16+
public void KillMainProcess() => KillMainProcessCalled = true;
17+
18+
// Other interface members omitted for this test
19+
public IEnumerable<IProcessInfo> GetProcessesByName(string processName) => null;
20+
21+
public IProcessInfo GetCurrentProcess() => null;
22+
23+
public IProcessInfo GetProcessById(int processId) => null;
24+
25+
public bool RegisterChildProcess(System.Diagnostics.Process childProcess) => false;
26+
}

0 commit comments

Comments
 (0)