terminal: Cannot use stream-based I/O to ConPTY/Console and receive WINDOWS_BUFFER_SIZE_EVENT simultaneously

Microsoft Windows [Version 10.0.18362.1]

To monitor console buffer resize events (and viewport resize events in more recent builds), ReadConsoleInput must be used to look for INPUT_BUFFER events with an event type of 4 ( WINDOW_BUFFER_SIZE_EVENT ).

My problem is that when I open a pseudoconsole session for a new cmd.exe process and I want to use bidirectional VT codes (stream based) for stdin/stdout for maximum cross platform portability. That is to say, I don’t want to use the windows console APIs because they are windows specific. This seems to make it very difficult - without gag-inducing and complex task synchronization - to watch for resize events at the same time. When stdin/stdout are flushed, any INPUT_BUFFER records in ReadConsoleInput are too, including WINDOWS_BUFFER_SIZE_EVENT records.

VT allows capturing mouse movement - or at least it should in the future since ConPTY doesn’t seem to forward mouse coords/actions yet, but there is no equivalent VT sequence for resizes of the viewport. Here’s some demo/repro code:

My specific NuGet dependencies for the repro are:

  • Vanara.PInvoke.Kernel32 / 2.3.4
  • Pipelines.Sockets.Unofficial / 2.0.7
using System;
using System.IO;
using System.IO.Pipelines;
using System.Runtime.InteropServices;
using System.Threading;
using System.Threading.Tasks;

using Pipelines.Sockets.Unofficial;
using Vanara.PInvoke;

namespace resizetest
{
    internal class Program
    {
        private static async Task Main(string[] args)
        {
            // run for 30 seconds
            const int timeout = 30000;

            var source = new CancellationTokenSource(timeout); // 30 seconds
            var token = source.Token;
            var handle = Kernel32.GetStdHandle(Kernel32.StdHandleType.STD_INPUT_HANDLE);
            
            Kernel32.CONSOLE_MODE mode = default;

            if (!Kernel32.GetConsoleMode(handle, ref mode))
            {
                throw Marshal.GetExceptionForHR(Marshal.GetLastWin32Error());
            }
            
            mode |= Kernel32.CONSOLE_MODE.ENABLE_WINDOW_INPUT;
            mode |= Kernel32.CONSOLE_MODE.ENABLE_VIRTUAL_TERMINAL_INPUT;
            mode &= ~Kernel32.CONSOLE_MODE.ENABLE_MOUSE_INPUT;

            if (!Kernel32.SetConsoleMode(handle, mode))
            {
                throw Marshal.GetExceptionForHR(Marshal.GetLastWin32Error());
            }

            Task drainStdin = Task.Run(async () =>
            {
                // UNCOMMENT THESE LINES TO ENABLE CAPTURE OF RESIZE EVENTS
                //await Task.Delay(timeout, token);
                //return;

                Stream stdin = Console.OpenStandardInput();
                PipeReader reader = StreamConnection.GetReader(stdin);

                while (!token.IsCancellationRequested)
                {
                    var result = await reader.ReadAsync(token);
                    if (result.IsCanceled || result.IsCompleted)
                    {
                        break;
                    }

                    // do stuff...

                    Console.WriteLine("stdin: read {0} byte(s)", result.Buffer.Length);
                }
            }, token);

            Task readInputBuffer = Task.Run(async () =>
            {
                while (!token.IsCancellationRequested)
                {
                    await Task.Delay(10, token);

                    if (!Kernel32.GetNumberOfConsoleInputEvents(handle, out var count))
                    {
                        throw Marshal.GetExceptionForHR(Marshal.GetLastWin32Error());
                    }

                    if (count > 0)
                    {
                        var buffer = new Kernel32.INPUT_RECORD[count];

                        if (!Kernel32.ReadConsoleInput(handle, buffer, count, out var total)) {
                            throw Marshal.GetExceptionForHR(Marshal.GetLastWin32Error());
                        }

                        if (total > 0)
                        {
                            // TODO: de-bounce and only send last resize? 
                            for (int index = 0; index < total; index++)
                            {
                                var record = buffer[index];

                                Console.WriteLine("type: {0}", record.EventType);

                                if (record.EventType == Kernel32.EVENT_TYPE.WINDOW_BUFFER_SIZE_EVENT) // resize
                                {
                                    var size = record.Event.WindowBufferSizeEvent.dwSize;
                                    Console.WriteLine("buffer: resize to {{{0}, {1}}}", size.X, size.Y);
                                }
                            }
                        }
                    }
                }

            }, token);

            Console.WriteLine("Running");

            try
            {
                await Task.WhenAll(readInputBuffer, drainStdin);
            }
            catch (OperationCanceledException)
            {
                // timeout
            }

            Console.WriteLine("press any key...");
            Console.ReadKey(true);

        }
    }
}

TL;DR – it’s very hard, if not impossible, to watch for viewport resizing and also use a pure VT implementation (no console API).

Solution? I’d suggest draining the input buffer if streaming but leaving resize, menu, mouse (if enabled) events etc. This may be a memory leak (but probably fixed max, circular buffer etc) if the default behaviour changed, so I guess it should be opt-in.

About this issue

  • Original URL
  • State: closed
  • Created 5 years ago
  • Comments: 15 (7 by maintainers)

Commits related to this issue

Most upvoted comments

Excellent to hear. If you have a useful reusable component that helps make this easier, I’d be happy to accept a pull into this repo in the tools directory so we can have it for others to use as well.

Yes, sorry, uhhh… When the calls are serviced, it’s the same queue underlying both APIs inside the conhost.exe process. So if you try to ReadFile a thing (or ReadConsole) and it hits the queue and a non KEY_EVENT is sitting there, it’s discarded and moves onto the next event.

Also, I have a massive pile of e-mail in my inbox this morning so I’m not remotely closer to looking at your private repro that you shared with me. Please bear with me. I’ll try to get to it.

OK so you’ve got this:

(A) Conhost in standard window visible mode <----> (B) Multiplexer application <------> © Conhost in ConPty mode <-----> (D) Client application

A is the server on top displaying stuff. B is the client of the A server and is simultaneously holding the server end of the C ConPty. The client end of the C ConPty is going to the D client application.

D is probably speaking classic Win32 to the C conhost in ConPTY mode. The C conhost in ConPTY is then presumably speaking VT out to B (and vice versa). The B multiplexer on startup right after attaching to A tells the A handles that it wants the virtual terminal output and input modes so it can get/give information in those formats over the STDIN and STDOUT pipes to make things easy with the information it’s receiving from the other side from C.

And I think your problem is that between A and B, it’s difficult or nigh impossible to know when the A window has resized and have an event come into B in a format that is easy to consume.

Sound right?