terminal icon indicating copy to clipboard operation
terminal copied to clipboard

Process.CloseMainWindow() and Process.Close() are ignored for processes running in terminal

Open denisbredikhin opened this issue 3 years ago • 7 comments

Windows Terminal version

1.11.2921.0

Windows build number

10.0.22000.258

Other Software

.NET Framework 4.7.1

Steps to reproduce

Configure Terminal as default terminal application. Write application (in our case .NET framework 4.7.1 WPF application) that starts some other console process: var startInfo = new ProcessStartInfo { UseShellExecute = false }; var process = new Process { EnableRaisingEvents = true, StartInfo = startInfo }; process.Start();

then try to stop this process with the following code: Process process = Process.GetProcessById(processId); if (!process.HasExited) { process.CloseMainWindow(); if (!process.HasExited) { if (!process.WaitForExit(5000)) { process.Close(); } } }

Expected Behavior

Process is stopped and the terminal tab is closed (same as in the case cmd.exe is the default terminal application).

Actual Behavior

Nothing happens.

denisbredikhin avatar Nov 25 '21 09:11 denisbredikhin

process.CloseMainWindow();

The console session [1] that gets created by Windows Terminal is a pseudoconsole session, for which the root process doesn't effectively own any window. To make the above work, the window that gets returned by GetConsoleWindow() in a pseudoconsole session would have to implement an effective owner, and the window manager would have to special case the reported owner for windows of type "PseudoConsoleWindow", just like it already does for windows of type "ConsoleWindowClass".

In a classic console session, the window that's returned by GetConsoleWindow() -- if there is one [2] -- stores the process ID and thread ID of its effective owner as window data. The console host maintains the effective owner as the root process in the console's list of processes. This shifts to the next process when the current root process either exits or detaches via FreeConsole(). See SetConsoleWindowOwner(). The window-manager function GetWindowThreadProcessId() is special cased for "ConsoleWindowClass" to return the window's effective owner instead of the real owner. The following example uses a Python shell in a classic console session, in which Python is the root process.

>>> (kernel32.GetCurrentProcessId(), kernel32.GetCurrentThreadId())
(5556, 2720)
>>> proc_list = (ctypes.c_ulong * 10)()
>>> n = kernel32.GetConsoleProcessList(proc_list, len(proc_list))
>>> proc_list[:n]
[5556]
>>> hwnd = kernel32.GetConsoleWindow()
>>> tid = user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
>>> (pid.value, tid)
(5556, 2720)
>>> (user32.GetWindowLongPtrW(hwnd, 0), user32.GetWindowLongPtrW(hwnd, 4))
(5556, 2720)

A process can be gracefully terminated by enumerating the desktop's top-level windows and message-only windows in order to post WM_CLOSE to the windows that the process owns, as determined by GetWindowThreadProcessId(). For example, this is what "taskkill.exe" does without the /F (force) option. This means that a process that allocates a classic console session can be closed gracefully via taskkill /pid <pid>. Every process in the console session has to exit when the console is closed. Each gets a CTRL_CLOSE_EVENT with up to "HungAppTimeout" (5000 ms default) to handle it.

Anyway, none of this is implemented for the dummy window that gets returned by GetConsoleWindow() in a pseudoconsole session. Here's another Python example, but this time under Windows terminal. This example shows that dummy window is owned by "openconsole.exe".

>>> hwnd = kernel32.GetConsoleWindow()
>>> pid = ctypes.c_ulong()
>>> tid = user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid))
>>> hproc = kernel32.OpenProcess(0x0200_0000, False, pid)
>>> name = (ctypes.c_wchar * 256)()
>>> psapi.GetModuleBaseNameW(hproc, None, name, len(name))
15
>>> name.value
'OpenConsole.exe'

[1] A console session has nothing inherently to do with cmd.exe, which is a shell, not a terminal. The host for a console session is "conhost.exe", or the build distributed with Windows Terminal, "openconsole.exe. An application can inherit, allocate, or attach to a console session, which is a system resource. [2] A console session can be created without a window, per the CREATE_NO_WINDOW process creation flag. In this case GetConsoleWindow() returns NULL.

eryksun avatar Nov 25 '21 12:11 eryksun

The window-manager function GetWindowThreadProcessId() is special cased for "ConsoleWindowClass" to return the window's effective owner instead of the real owner.

omfg

is that the solution to #2988 that I've been looking for? I can't find the particular code in the OS that's looking exactly for ConsoleWindowClass, but there's a lot of code that does that. Maybe we need to be setting the pseudoconsole HWND to that class name (even if it's a lie)

zadjii-msft avatar Nov 29 '21 15:11 zadjii-msft

I can't find the particular code in the OS that's looking exactly for ConsoleWindowClass, but there's a lot of code that does that.

It's implemented by the system call NtUserQueryWindow(). Information classes 0 and 2 return the window owner's process ID and thread ID. For example:

>>> hwnd = kernel32.GetConsoleWindow()
>>> kernel32.GetCurrentProcessId(), kernel32.GetCurrentThreadId()
(6552, 6452)
>>> win32u.NtUserQueryWindow(hwnd, 0), win32u.NtUserQueryWindow(hwnd, 2)
(6552, 6452)

In a kernel debugging session, I see that NtUserQueryWindow() in win32kfull.sys checks for a flag value (0x800) to determine whether to get the window owner's pid and tid values from the window data instead of using the real owner. The "PseudoConsoleWindow" class could be special cased internally to include the same flag. The console host would also have to be updated to support this. Currently, with a classic console window, SetOwner() is called by RemoveConsole(), which is initially set in InitWindowsSubsystem() via SetConsoleWindowOwner().

FYI, apparently information class 1 is the pid of the real owner, i.e. "conhost.exe". For example:

>>> win32u.NtUserQueryWindow(hwnd, 1)
1096
>>> name = (ctypes.c_wchar * 256)()
>>> hproc = kernel32.OpenProcess(0x0200_0000, False, 1096)
>>> psapi.GetModuleBaseNameW(hproc, None, name, len(name))
11
>>> name.value
'conhost.exe'

Maybe we need to be setting the pseudoconsole HWND to that class name (even if it's a lie)

The pseudoconsole window shouldn't have the same window procedure. But it should at least have a window procedure that closes the console session when WM_CLOSE is posted to the window. The processes in the console session will be sent CTRL_CLOSE_EVENT to let them shut down gracefully. That ties into the above discussion about reporting the primary process in the session as the effective owner of the pseudoconsole window. This allows using the pid of the effective owner to gracefully shut down the entire console session, e.g. via taskkill /pid <pid>.

eryksun avatar Nov 30 '21 01:11 eryksun

But it should at least have a window procedure that closes the console session when WM_CLOSE is posted to the window

That seems reasonable, even if people really really shouldn't be using the console HWND, that seems valid.

Notes to self: the flag is set by the ConsoleControl private api, and you can find the relevant OS code with bConsoleWindow = TRUE. Not helpful for external folks, but that'll get me close enough.

I wonder if this might have anything to do with the orphaning on update, too. #9914 is the one I'm thinking of.

zadjii-msft avatar Nov 30 '21 14:11 zadjii-msft

This allows using the pid of the effective owner to gracefully shut down the entire console session, e.g. via taskkill /pid <pid>.

Unfortunately this won't help with .NET Process.CloseMainWindow(), nor "End Task" in Task Manager. They only send the close message to the visible main window of a process. taskkill.exe, on the other hand, posts the close message to all top-level windows and message-only windows that the process owns. It searches for windows on all desktops of all window stations in the session. Most "graceful terminate" (like Unix SIGTERM) implementations that I've seen work similarly, at least in part. Few are as comprehensive as taskkill, but they're not as limited as CloseMainWindow(). For example, Qt's QProcesss::terminate() has a simple implementation that posts the close message to all top-level windows that the child process owns on the calling thread's desktop. It also tries to post the close message to the main (initial) thread in the process.

eryksun avatar Dec 01 '21 04:12 eryksun

@lhecker did this one get fixed too?

zadjii avatar Dec 17 '22 02:12 zadjii

Hmm I've been reading through this issue and I feel like my change didn't address this issue. CloseMainWindow() is similar to running taskkill without /F as mentioned above right? And yeah:

C:\Users\lhecker> taskkill /IM cmd.exe
ERROR: The process "cmd.exe" with PID 9256 could not be terminated.
Reason: This process can only be terminated forcefully (with /F option).

As @eryksun writes:

The pseudoconsole window shouldn't have the same window procedure. But it should at least have a window procedure that closes the console session when WM_CLOSE is posted to the window.

That seems like a simple enough change to me? All we'd have to do is to call VtIo::SendCloseEvent() if we receive a WM_CLOSE.

Edit:

Doesn't work, because cmd.exe receives the message and not the OpenConsole instance that manages it and owns the pseudo window. duh. As mentioned above:

The "PseudoConsoleWindow" class could be special cased internally to include the same flag.

lhecker avatar Dec 17 '22 03:12 lhecker

Hi folks here, @eryksun @zadjii-msft @zadjii @lhecker I got confused, possibly due to my limited English language skills and professional knowledge.

Is eryksun recommending openconsole.exe to behave like conhost.exe?

Let me use some examples to illustrate my confusions.

eg1: I launched cmd.exe(pid1), so conhost.exe(pid2) got launched too, then in this console, I launched python.exe(pid3). In python, kernel32.GetConsoleWindow() return hwnd, then user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) set pid to pid1(not pid2, nor pid3), so here why not set pid to pid2?

eg2: I launched cmd.exe(pid11), so openconsole.exe(pid12) got launched too, then in this console, I launched python.exe(pid13). In python, kernel32.GetConsoleWindow() return hwnd, then user32.GetWindowThreadProcessId(hwnd, ctypes.byref(pid)) set pid to pid12(not pid11, nor pid13), so eryksun recommend to let pid=pid11 as the case of conhost.exe?

infoagee avatar Sep 26 '23 04:09 infoagee