Skip to content

Commit 39ced5f

Browse files
committed
"Cancel" Console.ReadKey() via sendKeyPress() notification to client
This "rewrite" uses the `console.sendText()` API in VS Code to respond to a notification and send a fake key press, allowing us to "cancel" `ReadKey` by getting it to return. Since we know we requested the cancellation, we just drop the fake key press. This means we no longer have to poll on macOS/Linux, and no longer have the ghost "double key-press" issue on Windows. This fixes a myriad of problems and opens up a lot of simplification.
1 parent 3667e80 commit 39ced5f

File tree

5 files changed

+38
-100
lines changed

5 files changed

+38
-100
lines changed

src/PowerShellEditorServices/Services/PowerShell/Console/ConsoleProxy.cs

+9-7
Original file line numberDiff line numberDiff line change
@@ -4,16 +4,23 @@
44
using System;
55
using System.Runtime.InteropServices;
66
using System.Threading;
7-
using System.Threading.Tasks;
87

98
namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Console
109
{
1110
/// <summary>
1211
/// Provides asynchronous implementations of the <see cref="Console" /> API's as well as
1312
/// synchronous implementations that work around platform specific issues.
13+
/// NOTE: We're missing GetCursorPosition.
1414
/// </summary>
1515
internal static class ConsoleProxy
1616
{
17+
internal static readonly ConsoleKeyInfo s_nullKeyInfo = new(
18+
keyChar: ' ',
19+
ConsoleKey.DownArrow,
20+
shift: false,
21+
alt: false,
22+
control: false);
23+
1724
private static readonly IConsoleOperations s_consoleProxy;
1825

1926
[System.Diagnostics.CodeAnalysis.SuppressMessage("Performance", "CA1810:Initialize reference type static fields inline", Justification = "Platform specific initialization")]
@@ -100,12 +107,7 @@ internal static ConsoleKeyInfo SafeReadKey(bool intercept, CancellationToken can
100107
}
101108
catch (OperationCanceledException)
102109
{
103-
return new ConsoleKeyInfo(
104-
keyChar: ' ',
105-
ConsoleKey.DownArrow,
106-
shift: false,
107-
alt: false,
108-
control: false);
110+
return s_nullKeyInfo;
109111
}
110112
}
111113
}

src/PowerShellEditorServices/Services/PowerShell/Console/LegacyReadLine.cs

-1
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
// Copyright (c) Microsoft Corporation.
22
// Licensed under the MIT License.
33

4-
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Execution;
54
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Host;
65
using Microsoft.PowerShell.EditorServices.Services.PowerShell.Utility;
76
using System.Collections.Generic;

src/PowerShellEditorServices/Services/PowerShell/Console/UnixConsoleOperations.cs

+3-88
Original file line numberDiff line numberDiff line change
@@ -9,55 +9,25 @@ namespace Microsoft.PowerShell.EditorServices.Services.PowerShell.Console
99
{
1010
internal class UnixConsoleOperations : IConsoleOperations
1111
{
12-
private const int LongWaitForKeySleepTime = 300;
13-
14-
private const int ShortWaitForKeyTimeout = 5000;
15-
16-
private const int ShortWaitForKeySpinUntilSleepTime = 30;
17-
18-
private static readonly ManualResetEventSlim s_waitHandle = new();
19-
2012
private static readonly SemaphoreSlim s_readKeyHandle = AsyncUtils.CreateSimpleLockingSemaphore();
2113

2214
private static readonly SemaphoreSlim s_stdInHandle = AsyncUtils.CreateSimpleLockingSemaphore();
2315

24-
private Func<CancellationToken, bool> WaitForKeyAvailable;
25-
26-
/// <summary>
27-
/// Switch between long and short wait periods depending on if the user has recently (last 5
28-
/// seconds) pressed a key to avoid preventing the CPU from entering low power mode.
29-
/// </summary>
30-
internal UnixConsoleOperations() => WaitForKeyAvailable = LongWaitForKey;
31-
3216
public ConsoleKeyInfo ReadKey(bool intercept, CancellationToken cancellationToken)
3317
{
34-
s_readKeyHandle.Wait(cancellationToken);
35-
36-
// On Unix platforms System.Console.ReadKey has an internal lock on stdin. Because
37-
// of this, if a ReadKey call is pending in one thread and in another thread
38-
// Console.CursorLeft is called, both threads block until a key is pressed.
39-
try
40-
{
41-
// The WaitForKeyAvailable delegate switches between a long delay between waits and
42-
// a short timeout depending on how recently a key has been pressed. This allows us
43-
// to let the CPU enter low power mode without compromising responsiveness.
44-
while (!WaitForKeyAvailable(cancellationToken)) { }
45-
}
46-
finally
47-
{
48-
s_readKeyHandle.Release();
49-
}
50-
5118
// A key has been pressed, so acquire a lock on our internal stdin handle. This is done
5219
// so any of our calls to cursor position API's do not release ReadKey.
5320
s_stdInHandle.Wait(cancellationToken);
21+
s_readKeyHandle.Wait(cancellationToken);
5422
try
5523
{
5624
return System.Console.ReadKey(intercept);
5725
}
5826
finally
5927
{
28+
s_readKeyHandle.Release();
6029
s_stdInHandle.Release();
30+
cancellationToken.ThrowIfCancellationRequested();
6131
}
6232
}
6333

@@ -90,60 +60,5 @@ public int GetCursorTop(CancellationToken cancellationToken)
9060
s_stdInHandle.Release();
9161
}
9262
}
93-
94-
private bool LongWaitForKey(CancellationToken cancellationToken)
95-
{
96-
// Wait for a key to be buffered (in other words, wait for Console.KeyAvailable to become
97-
// true) with a long delay between checks.
98-
while (!IsKeyAvailable(cancellationToken))
99-
{
100-
s_waitHandle.Wait(LongWaitForKeySleepTime, cancellationToken);
101-
}
102-
103-
// As soon as a key is buffered, return true and switch the wait logic to be more
104-
// responsive, but also more expensive.
105-
WaitForKeyAvailable = ShortWaitForKey;
106-
return true;
107-
}
108-
109-
private bool ShortWaitForKey(CancellationToken cancellationToken)
110-
{
111-
// Check frequently for a new key to be buffered.
112-
if (SpinUntilKeyAvailable(ShortWaitForKeyTimeout, cancellationToken))
113-
{
114-
cancellationToken.ThrowIfCancellationRequested();
115-
return true;
116-
}
117-
118-
// If the user has not pressed a key before the end of the SpinUntil timeout then
119-
// the user is idle and we can switch back to long delays between KeyAvailable checks.
120-
cancellationToken.ThrowIfCancellationRequested();
121-
WaitForKeyAvailable = LongWaitForKey;
122-
return false;
123-
}
124-
125-
private static bool SpinUntilKeyAvailable(int millisecondsTimeout, CancellationToken cancellationToken)
126-
{
127-
return SpinWait.SpinUntil(
128-
() =>
129-
{
130-
s_waitHandle.Wait(ShortWaitForKeySpinUntilSleepTime, cancellationToken);
131-
return IsKeyAvailable(cancellationToken);
132-
},
133-
millisecondsTimeout);
134-
}
135-
136-
private static bool IsKeyAvailable(CancellationToken cancellationToken)
137-
{
138-
s_stdInHandle.Wait(cancellationToken);
139-
try
140-
{
141-
return System.Console.KeyAvailable;
142-
}
143-
finally
144-
{
145-
s_stdInHandle.Release();
146-
}
147-
}
14863
}
14964
}

src/PowerShellEditorServices/Services/PowerShell/Host/EditorServicesConsolePSHostUserInterface.cs

+2-2
Original file line numberDiff line numberDiff line change
@@ -48,9 +48,9 @@ public EditorServicesConsolePSHostUserInterface(
4848

4949
public override PSCredential PromptForCredential(string caption, string message, string userName, string targetName) => _underlyingHostUI.PromptForCredential(caption, message, userName, targetName);
5050

51-
public override string ReadLine() => _readLineProvider.ReadLine.ReadLine(CancellationToken.None);
51+
public override string ReadLine() => _underlyingHostUI.ReadLine();
5252

53-
public override SecureString ReadLineAsSecureString() => _readLineProvider.ReadLine.ReadSecureLine(CancellationToken.None);
53+
public override SecureString ReadLineAsSecureString() => _underlyingHostUI.ReadLineAsSecureString();
5454

5555
public override void Write(ConsoleColor foregroundColor, ConsoleColor backgroundColor, string value) => _underlyingHostUI.Write(foregroundColor, backgroundColor, value);
5656

src/PowerShellEditorServices/Services/PowerShell/Host/PsesInternalHost.cs

+24-2
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,8 @@ internal class PsesInternalHost : PSHost, IHostSupportsInteractiveSession, IRuns
7373

7474
private bool _skipNextPrompt;
7575

76+
private CancellationToken _readKeyCancellationToken;
77+
7678
private bool _resettingRunspace;
7779

7880
public PsesInternalHost(
@@ -690,7 +692,18 @@ public void WriteWithPrompt(PSCommand command, CancellationToken cancellationTok
690692
UI.WriteLine(command.GetInvocationText());
691693
}
692694

693-
private string InvokeReadLine(CancellationToken cancellationToken) => _readLineProvider.ReadLine.ReadLine(cancellationToken);
695+
private string InvokeReadLine(CancellationToken cancellationToken)
696+
{
697+
try
698+
{
699+
_readKeyCancellationToken = cancellationToken;
700+
return _readLineProvider.ReadLine.ReadLine(cancellationToken);
701+
}
702+
finally
703+
{
704+
_readKeyCancellationToken = CancellationToken.None;
705+
}
706+
}
694707

695708
private void InvokeInput(string input, CancellationToken cancellationToken)
696709
{
@@ -869,7 +882,16 @@ private ConsoleKeyInfo ReadKey(bool intercept)
869882
// This isn't functionally required,
870883
// but helps us determine when the prompt needs a newline added
871884

872-
_lastKey = ConsoleProxy.SafeReadKey(intercept, CancellationToken.None);
885+
// NOTE: This requests that the client (the Code extension) send a non-printing key back
886+
// to the terminal on stdin, emulating a user pressing a button. This allows
887+
// PSReadLine's thread waiting on Console.ReadKey to return. Normally we'd just cancel
888+
// this call, but the .NET API ReadKey is not cancellable, and is stuck until we send
889+
// input. This leads to a myriad of problems, but we circumvent them by pretending to
890+
// press a key.
891+
using CancellationTokenRegistration registration = _readKeyCancellationToken.Register(
892+
() => _languageServer?.SendNotification("powerShell/sendKeyPress")
893+
);
894+
_lastKey = ConsoleProxy.SafeReadKey(intercept, _readKeyCancellationToken);
873895
return _lastKey.Value;
874896
}
875897

0 commit comments

Comments
 (0)