|
| 1 | +# How We Handle `Console.ReadKey()` Being Uncancellable |
| 2 | + |
| 3 | +The C# API `Console.ReadKey()` is synchronous and uncancellable. This is problematic in a |
| 4 | +asynchronous application that needs to cancel it. |
| 5 | + |
| 6 | +## The Problem |
| 7 | + |
| 8 | +We host a multi-threaded application. One thread is always servicing the REPL, which runs |
| 9 | +PSReadLine, which loops on a `ReadKey` call. Other threads handle various PowerShell |
| 10 | +requests via LSP, some of which necessitate interrupting PSReadLine and taking over the |
| 11 | +foreground (such as: start debugging, run code). While we have a smart task queue which |
| 12 | +correctly handles the cancellation of tasks, including the delegate calling PSReadLine, we |
| 13 | +cannot cancel `ReadKey` because as a synchronous .NET API, it is uncancellable. |
| 14 | + |
| 15 | +So, no matter what, _at least one key must be consumed_ before PSReadLine's call to |
| 16 | +`ReadKey` is actually "canceled" (in this case, returned). This leads to bugs like [#3881] |
| 17 | +since the executed code is now using PowerShell's own prompting to get input from the |
| 18 | +user. Until our consumer (the `ReadKey` call) returns, the code behind the scenes of |
| 19 | +`$Host.UI.PromptForChoice()` won't get input. The actual fix would be for our `ReadKey` |
| 20 | +call to return without having received input after we canceled it (but we can't, so it |
| 21 | +doesn't). |
| 22 | + |
| 23 | +[#3881]: https://github.com/PowerShell/vscode-powershell/issues/3881 |
| 24 | + |
| 25 | +A non-exhaustive list of known issues likely caused by this: |
| 26 | + |
| 27 | +- [#3881](https://github.com/PowerShell/vscode-powershell/issues/3881) |
| 28 | +- [#3756](https://github.com/PowerShell/vscode-powershell/issues/3756) |
| 29 | +- [#2741](https://github.com/PowerShell/vscode-powershell/issues/2741) |
| 30 | +- [#3876](https://github.com/PowerShell/vscode-powershell/issues/3876) |
| 31 | +- [#2832](https://github.com/PowerShell/vscode-powershell/issues/2832) |
| 32 | +- [#2169](https://github.com/PowerShell/vscode-powershell/issues/2169) |
| 33 | +- [#1753](https://github.com/PowerShell/vscode-powershell/issues/1753) |
| 34 | +- [#3225](https://github.com/PowerShell/vscode-powershell/issues/3225) |
| 35 | + |
| 36 | +For what it's worth, Tyler and have had conversations with the .NET team about making |
| 37 | +`ReadKey` cancelable. [#801] is an ancient GitHub issue with .NET, and we have had |
| 38 | +internal conversations. |
| 39 | + |
| 40 | +[#801]: https://github.com/dotnet/runtime/issues/801 |
| 41 | + |
| 42 | +## Previous Workaround(s) |
| 43 | + |
| 44 | +A previous workaround for this was to reinvent PowerShell's prompt handlers so they use |
| 45 | +our `ReadKey` call, see [#1583]. This is awful! It duplicates a lot of code when |
| 46 | +everything works so almost right without any of this. Except a key needs to be entered to |
| 47 | +"cancel" `ReadKey`. |
| 48 | + |
| 49 | +[#1583]: https://github.com/PowerShell/PowerShellEditorServices/issues/1583 |
| 50 | + |
| 51 | +Now when I say "our `ReadKey`" call that's because we _already_ had some other workaround |
| 52 | +in place for this. Once upon a time (in the days of PowerShell 6 with older .NET Core |
| 53 | +versions), on macOS and Linux, if a thread was sitting in a `Console.ReadKey` loop, other |
| 54 | +`System.Console` APIs could not safely be called. For instance, `Console.CursorTop` is |
| 55 | +readily queried other events (such as the window resizing) and would deadlock, see |
| 56 | +[#1748]. So on these OS's we actually didn't use `Console.ReadKey` at all, but implemented |
| 57 | +a fake "`ReadKey`" that sits in a loop polling `Console.KeyAvailable`, see the |
| 58 | +[`ConsolePal.Unix`] implementation. |
| 59 | + |
| 60 | +[#1748]: https://github.com/PowerShell/PowerShellEditorServices/pull/1748#issuecomment-1079055612 |
| 61 | +[`ConsolePal.Unix`]: https://github.com/dotnet/runtime/blob/3ff8d262e504d03977edeb67da2b83d01c9ed2db/src/libraries/System.Console/src/System/ConsolePal.Unix.cs#L121-L138 |
| 62 | + |
| 63 | +This workaround led to other terrible behaviors, like the "typewriter" effect when pasting |
| 64 | +into our terminal, see [#3756]. Note that this issue only occurred on macOS and Linux, |
| 65 | +because on Windows we were still calling `Console.ReadKey`, but with a buffer to make it |
| 66 | +sort of cancellable, see [`ConsolePal.Windows`]. This is also the reason that [#3881] is |
| 67 | +Windows-specific. This makes pasting no macOS and Linux almost unusable, it takes minutes |
| 68 | +if you're pasting in a script to run. |
| 69 | + |
| 70 | +[#3756]: https://github.com/PowerShell/vscode-powershell/issues/3756 |
| 71 | +[`ConsolePal.Windows`]: https://github.com/dotnet/runtime/blob/3ff8d262e504d03977edeb67da2b83d01c9ed2db/src/libraries/System.Console/src/System/ConsolePal.Windows.cs#L307-L400 |
| 72 | + |
| 73 | +Another issue that is probably caused by these alternative "`ReadKey`" implementations is |
| 74 | +[#2741] where pasting totally fails. It seems like this has appeared before, and was |
| 75 | +previously fixed in [#2291]. |
| 76 | + |
| 77 | +[#2741]: https://github.com/PowerShell/vscode-powershell/issues/2741 |
| 78 | +[#2291]: https://github.com/PowerShell/vscode-powershell/issues/2291 |
| 79 | + |
| 80 | +As an aside, but important note: these custom "`ReadKey`" implementations are the reason |
| 81 | +we have a private [contract] with PSReadLine, where we literally override the `ReadKey` |
| 82 | +method in that library when we load it, because PSReadLine is what is actually looping |
| 83 | +over `ReadKey`. |
| 84 | + |
| 85 | +[contract]: https://github.com/PowerShell/PSReadLine/blob/dc38b451bee4bdf07f7200026be02516807faa09/PSReadLine/ConsoleLib.cs#L12-L17 |
| 86 | + |
| 87 | +## Explored But Inviable Workarounds |
| 88 | + |
| 89 | +Back to [#3881] ("PowerShell prompts ignore the first input character"): one workaround |
| 90 | +could be to use the macOS/Linux `KeyAvailable`-based `ReadKey` alternative. But this |
| 91 | +should be avoided for several reasons (typewriter effect, battery drain, kind of just |
| 92 | +plain awful). It could be better if we improved the polling logic to slow way down after |
| 93 | +no input and speed up to instantaneous with input (like when pasting), but it would still |
| 94 | +be just a workaround. |
| 95 | + |
| 96 | +An option I explored was to send a known ASCII control character every time the integrated |
| 97 | +console _received focus_ and have our `ReadKey` implementation ignore it (but return, |
| 98 | +since it received the key its stuck waiting for). This seemed like an ingenious solution, |
| 99 | +but unfortunately Visual Studio Code does not have an API for "on terminal focus" and it |
| 100 | +won't be getting one any time soon (I explored all the options in the [window] API, and |
| 101 | +confirmed with Tyler Leonhardt and Johannes Rieken, two VS Code developers). Theoretically |
| 102 | +we could have instead sent the character when our `RunCode` command is called but that |
| 103 | +only solves the problem some of the time. However, through this experiment I discovered |
| 104 | +that there is now an API to send arbitrary text over `stdin` to our extension-owned |
| 105 | +terminal, which is going to useful. |
| 106 | + |
| 107 | +[window]: https://code.visualstudio.com/api/references/vscode-api#window |
| 108 | + |
| 109 | +Another option explored was a custom `CancelReadKey` function that manually wrote a |
| 110 | +character to the PSES's own process's `stdin` in order to get `ReadKey` to return. While I |
| 111 | +was able to write the character (after using a P/Invoke to libc's `write()` function, |
| 112 | +because C#'s own `stdin` stream is opened, aptly, in read-only mode), it was not |
| 113 | +sufficient. For some reason, although the data was sent, `ReadKey` ignored it. Maybe |
| 114 | +`stdin` is redirected, or something else is going on, unfortunately I'm not sure. However, |
| 115 | +this exploration gave me the idea to hook up an LSP notification and have Code send a |
| 116 | +non-printing character when `CancelReadKey` is called, since Code is already hooked up to |
| 117 | +PSES's `stdin` and now has an API to directly write to it. More on this later. |
| 118 | + |
| 119 | +Another workaround for all these issues is to write our own `ReadKey`, in native code |
| 120 | +instead of C# (so as to avoid all the issues with the `System.Console` APIs). |
| 121 | +Theoretically, we could write a cross-platform Rust app that simply reads `stdin`, |
| 122 | +translates input to `KeyInfo` structures, and passes that over a named pipe back PSES, |
| 123 | +which consumes that input in a "`ReadKey`" delegate using a channel pattern queue. This |
| 124 | +delegate would spawn the subprocess and hook the parent process's `stdin` to its own |
| 125 | +`stdin` (just pipe it across), and when the delegate is canceled, it kills the child |
| 126 | +process and unhooks the `stdin` redirect, essentially making this native app a |
| 127 | +"cancellable `ReadKey`." We did not end up trying this approach due to the cost involved |
| 128 | +in prototype it, as we would essentially be writing our own native replacement for: |
| 129 | +[`Console`]. We'd also need to deal with the fact that `Console` doesn't like `stdin` |
| 130 | +being [redirected], as PSReadLine indicates is already an issue. |
| 131 | + |
| 132 | +[`Console`]: https://github.com/dotnet/runtime/blob/main/src/libraries/System.Console/src/System/Console.cs |
| 133 | +[redirected]: https://github.com/PowerShell/PSReadLine/blob/f46f15d2d634e2060bc0eabe4c81fc13a5a64a3a/PSReadLine/ReadLine.cs#L343-L356 |
| 134 | + |
| 135 | +## Current Working Solution |
| 136 | + |
| 137 | +After trying a few different workarounds, something finally clicked and I combined several |
| 138 | +of the ideas. I realized that we already have an external process writing to PSES's |
| 139 | +`stdin`, and that's VS Code itself. Moreover, it now has a `SendText` API for the object |
| 140 | +representing the extension owned terminal (which is hosting PSES). So in [#1751], I wired |
| 141 | +up the cancellation token in our "safe, cancellable" `ReadKey` to send an LSP notification |
| 142 | +to the client called `sendKeyPress`, which on the client side simply uses that API to send |
| 143 | +a character (we eventually chose `p` because it's easy to see if something has gone wrong) |
| 144 | +to the terminal, _just as if the user had pressed a key_. This causes `Console.ReadKey` to |
| 145 | +return, since it received the input it was waiting on, and because we know that we |
| 146 | +requested a cancellation (through the token), we can ignore that input and move forward |
| 147 | +just as if the .NET API itself were canceled. Several things came together to make this |
| 148 | +solution viable: |
| 149 | + |
| 150 | +- The pipeline execution threading rewrite meant that we don't have race conditions around |
| 151 | + processing input |
| 152 | +- VS Code added an API for us to write directly to our process's `stdin`. |
| 153 | +- We dropped support for PowerShell 6 meaning that the .NET `System.Console` APIs on macOS |
| 154 | + and Linux no longer deadlock each other. |
| 155 | + |
| 156 | +This workaround resolved many issues, and the same workaround could be able applied to |
| 157 | +other LSP clients that host a terminal (like Vim). Moreover, we deleted over a thousand |
| 158 | +lines of code and added less than eighty! We did have to bake this workaround for a while, |
| 159 | +and it required PR [#3274] to PSReadLine, as well as a later race condition fix in PR |
| 160 | +[#3294]. I still hope that one day we can have an asynchronous `Console.ReadKey` API that |
| 161 | +accepts a cancellation token, and so does not require this fake input to return and free |
| 162 | +up our thread. |
| 163 | + |
| 164 | +[#1751]: https://github.com/PowerShell/PowerShellEditorServices/pull/1751 |
| 165 | +[#3274]: https://github.com/PowerShell/PSReadLine/pull/3274 |
| 166 | +[#3294]: https://github.com/PowerShell/PSReadLine/pull/3294 |
0 commit comments