Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Console.ReadKey throws an InvalidOperationException when other console-attached process dies #88697

Closed
jazzdelightsme opened this issue Jul 11, 2023 · 9 comments · Fixed by #98684
Labels
area-System.Console in-pr There is an active PR which will close this issue when it is merged
Milestone

Comments

@jazzdelightsme
Copy link
Contributor

Description

The Console.ReadKey function can throw an InvalidOperationException when "application does not have a console or when console input has been redirected". That makes sense. However, there is another case where it throws an InvalidOperationException, which happens when there are multiple processes attached to the same console, both waiting for input, and one dies or is killed.

Here is the code in question, from ConsolePal.Windows.cs (here)

while (true)
{
    r = Interop.Kernel32.ReadConsoleInput(InputHandle, out ir, 1, out int numEventsRead);
    if (!r || numEventsRead == 0)
    {
        // This will fail when stdin is redirected from a file or pipe.
        // We could theoretically call Console.Read here, but I
        // think we might do some things incorrectly then.
        throw new InvalidOperationException(SR.InvalidOperation_ConsoleReadKeyOnFile);
    }

And the same problem exists in KeyAvailable, here:

bool r = Interop.Kernel32.PeekConsoleInput(InputHandle, out Interop.INPUT_RECORD ir, 1, out int numEventsRead);
if (!r)
{
    int errorCode = Marshal.GetLastPInvokeError();
    if (errorCode == Interop.Errors.ERROR_INVALID_HANDLE)
        throw new InvalidOperationException(SR.InvalidOperation_ConsoleKeyAvailableOnFile);
    throw Win32Marshal.GetExceptionForWin32Error(errorCode, "stdin");
}

This can happen infrequently and effectively at random, so it is not desirable for every single app that uses Console.ReadKey to have to know that it needs to handle this exception itself. Ideally, these APIs could recognize this situation (0 records returned) and simply re-issue the read, instead of blowing up.

Q: But isn't it unusual for multiple processes to be reading input from the same console at the same time?
A: Yes, but it does happen. A common way I've encountered this situation is with build systems: you've got some build coordinator process, spawning bajillions of other console processes (doing build things), and the user hits ctrl+c to cancel it. Due to race conditions between processes being spawned and getting attached to the console, you can end up with some stragglers still attached to the console after the main build coordinator has exited and returned control to the shell process. In this case the user can mash on ctrl+c a bit more, to get back to their shell / get the console back to a usable state, but due to this bug, if their shell is managed code (like PowerShell), then there is a chance their shell will die. :'(

Reproduction Steps

In pwsh:

dotnet new console --name ConsoleReadkeyRepro
cd .\ConsoleReadkeyRepro
Set-Content -Path .\Program.cs -Value @"
using System.Diagnostics;

Console.WriteLine("Hello, World!");

if( (null == args) || (args.Length == 0) )
{
    Console.WriteLine( "(parent process)" );

    Thread spawner = new Thread( () => {

        ProcessStartInfo psi = new ProcessStartInfo( "dotnet", "run --no-build child" );
        psi.UseShellExecute = false;

        Console.WriteLine( "Spawning child:" );
        using( Process? p = Process.Start( psi ) )
        {
            Thread.Sleep( 4000 );
            Console.WriteLine( "killing child" );
            p?.Kill();
        }

    });
    spawner.Start();
    Thread.Sleep( 2000 );

    Console.WriteLine( "parent now also asking for a key..." );
    try
    {
        var ki = Console.ReadKey();
    }
    catch( Exception e )
    {
        Console.WriteLine( "unexpected exception: {0}", e );
        return -1;
    }
}
else
{
    Console.WriteLine( "(child process)" );

    while( true )
    {
        Console.WriteLine( "feed me..." );
        var ki = Console.ReadKey( true );
    }
}
return 0;
"@
dotnet build
dotnet run --no-build

Expected behavior

Console.ReadKey and Console.KeyAvailable should not throw just because some random other process attached to the same console died.

Actual behavior

Console.ReadKey and Console.KeyAvailable throw an InvalidOperationException just because some random other process attached to the same console died.

Regression?

I do not believe this is a regression.

Known Workarounds

Every single caller of these APIs could wrap them with try/catch to manually deal with the InvalidOperationException. Or not run attached to a console that runs any other programs. (This is obviously unfortunate and impractical.)

Configuration

Windows 11, dotnet version 7.0.304.

Other information

No response

@ghost ghost added the untriaged New issue has not been triaged by the area owner label Jul 11, 2023
@ghost
Copy link

ghost commented Jul 11, 2023

Tagging subscribers to this area: @dotnet/area-system-console
See info in area-owners.md if you want to be subscribed.

Issue Details

Description

The Console.ReadKey function can throw an InvalidOperationException when "application does not have a console or when console input has been redirected". That makes sense. However, there is another case where it throws an InvalidOperationException, which happens when there are multiple processes attached to the same console, both waiting for input, and one dies or is killed.

Here is the code in question, from ConsolePal.Windows.cs (here)

while (true)
{
    r = Interop.Kernel32.ReadConsoleInput(InputHandle, out ir, 1, out int numEventsRead);
    if (!r || numEventsRead == 0)
    {
        // This will fail when stdin is redirected from a file or pipe.
        // We could theoretically call Console.Read here, but I
        // think we might do some things incorrectly then.
        throw new InvalidOperationException(SR.InvalidOperation_ConsoleReadKeyOnFile);
    }

And the same problem exists in KeyAvailable, here:

bool r = Interop.Kernel32.PeekConsoleInput(InputHandle, out Interop.INPUT_RECORD ir, 1, out int numEventsRead);
if (!r)
{
    int errorCode = Marshal.GetLastPInvokeError();
    if (errorCode == Interop.Errors.ERROR_INVALID_HANDLE)
        throw new InvalidOperationException(SR.InvalidOperation_ConsoleKeyAvailableOnFile);
    throw Win32Marshal.GetExceptionForWin32Error(errorCode, "stdin");
}

This can happen infrequently and effectively at random, so it is not desirable for every single app that uses Console.ReadKey to have to know that it needs to handle this exception itself. Ideally, these APIs could recognize this situation (0 records returned) and simply re-issue the read, instead of blowing up.

Q: But isn't it unusual for multiple processes to be reading input from the same console at the same time?
A: Yes, but it does happen. A common way I've encountered this situation is with build systems: you've got some build coordinator process, spawning bajillions of other console processes (doing build things), and the user hits ctrl+c to cancel it. Due to race conditions between processes being spawned and getting attached to the console, you can end up with some stragglers still attached to the console after the main build coordinator has exited and returned control to the shell process. In this case the user can mash on ctrl+c a bit more, to get back to their shell / get the console back to a usable state, but due to this bug, if their shell is managed code (like PowerShell), then there is a chance their shell will die. :'(

Reproduction Steps

In pwsh:

dotnet new console --name ConsoleReadkeyRepro
cd .\ConsoleReadkeyRepro
Set-Content -Path .\Program.cs -Value @"
using System.Diagnostics;

Console.WriteLine("Hello, World!");

if( (null == args) || (args.Length == 0) )
{
    Console.WriteLine( "(parent process)" );

    Thread spawner = new Thread( () => {

        ProcessStartInfo psi = new ProcessStartInfo( "dotnet", "run --no-build child" );
        psi.UseShellExecute = false;

        Console.WriteLine( "Spawning child:" );
        using( Process? p = Process.Start( psi ) )
        {
            Thread.Sleep( 4000 );
            Console.WriteLine( "killing child" );
            p?.Kill();
        }

    });
    spawner.Start();
    Thread.Sleep( 2000 );

    Console.WriteLine( "parent now also asking for a key..." );
    try
    {
        var ki = Console.ReadKey();
    }
    catch( Exception e )
    {
        Console.WriteLine( "unexpected exception: {0}", e );
        return -1;
    }
}
else
{
    Console.WriteLine( "(child process)" );

    while( true )
    {
        Console.WriteLine( "feed me..." );
        var ki = Console.ReadKey( true );
    }
}
return 0;
"@
dotnet build
dotnet run --no-build

Expected behavior

Console.ReadKey and Console.KeyAvailable should not throw just because some random other process attached to the same console died.

Actual behavior

Console.ReadKey and Console.KeyAvailable throw an InvalidOperationException just because some random other process attached to the same console died.

Regression?

I do not believe this is a regression.

Known Workarounds

Every single caller of these APIs could wrap them with try/catch to manually deal with the InvalidOperationException. Or not run attached to a console that runs any other programs. (This is obviously unfortunate and impractical.)

Configuration

Windows 11, dotnet version 7.0.304.

Other information

No response

Author: jazzdelightsme
Assignees: -
Labels:

area-System.Console

Milestone: -

jazzdelightsme added a commit to jazzdelightsme/PSReadLine that referenced this issue Jul 25, 2023
Addresses PowerShell#3744

There is a problem with the .NET Console API, described in this Issue:

    dotnet/runtime#88697

This change works around the problem by handling the
InvalidOperationException that might spuriously fly out of KeyAvailable
and ReadKey (with a limited number of retries, in case there is
something *else* pathological going on).
@adamsitnik
Copy link
Member

Hi @jazzdelightsme

Thank you for a detailed bug report. Does the error code allow us to differentiate such scenario from the two other scenarios where we want to throw? If that is the case, then I am supportive of introducing such a change.

@adamsitnik adamsitnik removed the untriaged New issue has not been triaged by the area owner label Aug 1, 2023
@adamsitnik adamsitnik added this to the Future milestone Aug 1, 2023
@jazzdelightsme
Copy link
Contributor Author

jazzdelightsme commented Aug 1, 2023

Does the error code allow us to differentiate such scenario from the two other scenarios where we want to throw?

Looking at the source for ReadConsoleInput, I see that it sets the last Win32 error on failure, but that doesn't matter: this particular case is actually a success case (r is true; it's just that numEventsRead is 0 ("we successfully read 0 input records")). So mainly, we should just honor r.

        while (true)
        {
            r = Interop.Kernel32.ReadConsoleInput(InputHandle, out ir, 1, out int numEventsRead);
 /* old:    if (!r || numEventsRead == 0) */
 /* NEW: */ if (!r)
            {
                // This will fail when stdin is redirected from a file or pipe.
                // We could theoretically call Console.Read here, but I
                // think we might do some things incorrectly then.
                throw new InvalidOperationException(SR.InvalidOperation_ConsoleReadKeyOnFile);
            }

 /* NEW: */ if (numEventsRead == 0)
 /* NEW: */ {
 /* NEW: */     // This can happen when there are multiple console-attached processes
 /* NEW: */     // waiting for input, and another one is terminated while we are waiting
 /* NEW: */     // for input.
 /* NEW: */     //
 /* NEW: */     // (It's a rare case to have multiple console-attached processes waiting
 /* NEW: */     // for input, but it can happen sometimes, such as when ctrl+c'ing a build
 /* NEW: */     // process that is spawning tons of child processes-- sometimes, due to
 /* NEW: */     // the order in which processes exit, a managed shell process (like pwsh)
 /* NEW: */     // might get back to the prompt and start trying to read input while there
 /* NEW: */     // are still child processes getting cleaned up.)
 /* NEW: */     //
 /* NEW: */     // In this case, we just need to retry the read.
 /* NEW: */     continue;
 /* NEW: */ }

So the real question is: "if stdin is redirected from a file or pipe, will ReadConsoleInput ever successfully read 0 input records?"

  • If "no": fine, we are done; we just pay attention to r and we should be good.
  • If "yes": assuming we would want to preserve prior behavior, I believe we could use IsInputRedirectedCore to detect that. Like so:
        while (true)
        {
            r = Interop.Kernel32.ReadConsoleInput(InputHandle, out ir, 1, out int numEventsRead);
 /* old:    if (!r || numEventsRead == 0) */
 /* new: */ if (!r)
            {
                // This will fail when stdin is redirected from a file or pipe.
                // We could theoretically call Console.Read here, but I
                // think we might do some things incorrectly then.
                throw new InvalidOperationException(SR.InvalidOperation_ConsoleReadKeyOnFile);
            }

 /* new: */ if (numEventsRead == 0)
 /* new: */ {
 /* NEW: */     if (IsInputRedirectedCore())
 /* NEW: */     {
 /* NEW: */         // To ensure we preserve prior behavior... see comment above.
 /* NEW: */         throw new InvalidOperationException(SR.InvalidOperation_ConsoleReadKeyOnFile);
 /* NEW: */     }
 /* NEW: */     else
 /* NEW: */     {
 /* new: */         // This can happen when there are multiple console-attached processes
 /* new: */         // waiting for input, and another one is terminated while we are waiting
 /* new: */         // for input.
 /* new: */         //
 /* new: */         // (It's a rare case to have multiple console-attached processes waiting
 /* new: */         // for input, but it can happen sometimes, such as when ctrl+c'ing a build
 /* new: */         // process that is spawning tons of child processes-- sometimes, due to
 /* new: */         // the order in which processes exit, a managed shell process (like pwsh)
 /* new: */         // might get back to the prompt and start trying to read input while there
 /* new: */         // are still child processes getting cleaned up.)
 /* new: */         //
 /* new: */         // In this case, we just need to retry the read.
 /* new: */         continue;
 /* NEW: */     }
 /* new: */ }

This second option seems safe enough that I don't know if it's even worth trying to chase down the answer to the question.

Now, for KeyAvailable... when I was fiddling around with this stuff, I thought I remembered hitting a crash with that API, too, but... it's already just properly honoring the return code!

                    bool r = Interop.Kernel32.PeekConsoleInput(InputHandle, out Interop.INPUT_RECORD ir, 1, out int numEventsRead);
/* fine, right? */  if (!r)
                    {
                        int errorCode = Marshal.GetLastPInvokeError();
                        if (errorCode == Interop.Errors.ERROR_INVALID_HANDLE)
                            throw new InvalidOperationException(SR.InvalidOperation_ConsoleKeyAvailableOnFile);
                        throw Win32Marshal.GetExceptionForWin32Error(errorCode, "stdin");
                    }

So... I may have just been hallucinating. Or maybe it stuck in my head because I saw this code and saw that it was doing it correctly; whereas the other location wasn't.

Indeed, modifying my repro to use KeyAvailable in the parent process, like this:

    //var ki = Console.ReadKey();
    while( true )
    {
        bool ka = Console.KeyAvailable;

        if( ka )
        {
            Console.WriteLine( "key available, exiting" );
            break;
        }
    }

I wasn't able to hit a crash. So I think that's my bad; I think just ReadKey needs the fix.

@adamsitnik
Copy link
Member

@jazzdelightsme Thank you for investigating it further. The proposed solution with ReadConsoleInput sounds very reasonable to me. Would you like to send a PR with a fix?

BTW it's interesting that the official docs say that it's impossible to read 0 characters on success:

The function does not return until at least one input record has been read.

@jazzdelightsme
Copy link
Contributor Author

@zadjii-msft any comment on this? Is it a doc bug, or a code bug?

BTW it's interesting that the official docs say that it's impossible to read 0 characters on success:

@zadjii-msft
Copy link

Bear with me as I collect some notes

ApiDispatchers::ServerGetConsoleInput looks like the guy that handles ReadConsoleInput
https://github.com/microsoft/terminal/blob/b556594793006ba49934d9e5291cbf235e18b4b3/src/server/ApiDispatchers.cpp#L127-L140

That calls into ApiRoutines::GetConsoleInputImpl
https://github.com/microsoft/terminal/blob/b556594793006ba49934d9e5291cbf235e18b4b3/src/host/directio.cpp#L73-L78

That always passes WaitForData as true, in the read from the input buffer.
Doc comment on that method:

// - WaitForData - if true, wait until an event is input (if there aren't enough to fill client buffer). if false, return immediately

oh but then I think we start getting into the realm of the console read waits. Those are nasty to try and track down. My gut says it's not impossible that there's a case where a client disconnecting might try to complete the other waits that were pending. Tracking that down would be trickier. I also have no idea which is right, certainly not off the top of my head.

@jazzdelightsme
Copy link
Contributor Author

Thanks, @zadjii-msft! Okay, do you want an Issue in Terminal for this? If we're probably not going to do anything about it, and we don't really need to have it for reference, maybe this Issue is enough...

@adamsitnik, yes, I can create a PR. I've been a bit bogged down, bear with me...

@zadjii-msft
Copy link

FWIW, in microsoft/terminal#15859, we did decide that this was a Conhostv2 bug that regressed some time since conhost v1. We tossed it onto our backlog ☺️

jazzdelightsme added a commit to jazzdelightsme/runtime that referenced this issue Feb 20, 2024
This change fixes a problem where Console.ReadKey inappropriately throws
an InvalidOperationException.

The Console.ReadKey function can throw an InvalidOperationException when
the "application does not have a console or when console input has been
redirected". That makes sense.

However, there is *another* case where it throws an
InvalidOperationException, which happens when there are multiple
processes attached to the same console, which are waiting for input, and
one dies or is killed. In this case, the native read function returns a
"success" return code, but the "out" parameter indicating the number of
records read is 0, and in this case, Console.ReadKey is throwing, but it
should not be.

Note that this behavior of the underlying platform is "almost certainly"
a bug (microsoft/terminal#15859); however, it
is longstanding behavior, so we would like to avoid blowing up apps who
have the misfortune of stumbling into this situation.

More details in the linked Issue:

Fix dotnet#88697
@ghost ghost added the in-pr There is an active PR which will close this issue when it is merged label Feb 20, 2024
@jazzdelightsme
Copy link
Contributor Author

jazzdelightsme commented Feb 20, 2024

@adamsitnik sorry for the long delay on this; I've finally put together a PR.

I'm having some trouble testing it... I ran the System.Console tests, but I hardcoded an exception, just to verify that the tests were covering this code, but it didn't cause a problem, so either I'm holding it wrong (highly likely, since this is a large and complex project, and I don't have experience with it), or this code isn't covered by a test (not totally unreasonable, since this is in a PAL layer and it is in code that explicitly wouldn't be hit when console IO is redirected, which one would imagine would be the case in a unit testing scenario...). Would there be an easy way for me to run my repro code against a private System.Console library?

adamsitnik pushed a commit that referenced this issue May 22, 2024
This change fixes a problem where Console.ReadKey inappropriately throws
an InvalidOperationException.

The Console.ReadKey function can throw an InvalidOperationException when
the "application does not have a console or when console input has been
redirected". That makes sense.

However, there is *another* case where it throws an
InvalidOperationException, which happens when there are multiple
processes attached to the same console, which are waiting for input, and
one dies or is killed. In this case, the native read function returns a
"success" return code, but the "out" parameter indicating the number of
records read is 0, and in this case, Console.ReadKey is throwing, but it
should not be.

Note that this behavior of the underlying platform is "almost certainly"
a bug (microsoft/terminal#15859); however, it
is longstanding behavior, so we would like to avoid blowing up apps who
have the misfortune of stumbling into this situation.

More details in the linked Issue:

Fix #88697
@adamsitnik adamsitnik modified the milestones: Future, 9.0.0 May 22, 2024
steveharter pushed a commit to steveharter/runtime that referenced this issue May 28, 2024
…98684)

This change fixes a problem where Console.ReadKey inappropriately throws
an InvalidOperationException.

The Console.ReadKey function can throw an InvalidOperationException when
the "application does not have a console or when console input has been
redirected". That makes sense.

However, there is *another* case where it throws an
InvalidOperationException, which happens when there are multiple
processes attached to the same console, which are waiting for input, and
one dies or is killed. In this case, the native read function returns a
"success" return code, but the "out" parameter indicating the number of
records read is 0, and in this case, Console.ReadKey is throwing, but it
should not be.

Note that this behavior of the underlying platform is "almost certainly"
a bug (microsoft/terminal#15859); however, it
is longstanding behavior, so we would like to avoid blowing up apps who
have the misfortune of stumbling into this situation.

More details in the linked Issue:

Fix dotnet#88697
Ruihan-Yin pushed a commit to Ruihan-Yin/runtime that referenced this issue May 30, 2024
…98684)

This change fixes a problem where Console.ReadKey inappropriately throws
an InvalidOperationException.

The Console.ReadKey function can throw an InvalidOperationException when
the "application does not have a console or when console input has been
redirected". That makes sense.

However, there is *another* case where it throws an
InvalidOperationException, which happens when there are multiple
processes attached to the same console, which are waiting for input, and
one dies or is killed. In this case, the native read function returns a
"success" return code, but the "out" parameter indicating the number of
records read is 0, and in this case, Console.ReadKey is throwing, but it
should not be.

Note that this behavior of the underlying platform is "almost certainly"
a bug (microsoft/terminal#15859); however, it
is longstanding behavior, so we would like to avoid blowing up apps who
have the misfortune of stumbling into this situation.

More details in the linked Issue:

Fix dotnet#88697
@github-actions github-actions bot locked and limited conversation to collaborators Jun 21, 2024
Sign up for free to subscribe to this conversation on GitHub. Already have an account? Sign in.
Labels
area-System.Console in-pr There is an active PR which will close this issue when it is merged
Projects
None yet
Development

Successfully merging a pull request may close this issue.

3 participants