A brief analysis of why the C# Console console gets stuck

One: Background

1. Tell stories

During the analysis journey, there will always be a few production accidents caused by the unexpected stuck of the console. Friends with experience know that the stuck console is usually caused by moving the Quick Edit Window, screenshot as follows:

Although I know the reason, I have never had time to explore the underlying principles, and there is no introduction to the underlying principles on the market. I spent some time yesterday to briefly explore it, as a record and sharing.

Two: Answers to several questions

1. Why does the interface freeze?

I believe many friends will have such a question? The console program obviously does not have a message loop mechanism, so why can it still respond to window events?

To be honest, this is a good question. In fact, the reason why Console can respond to window events is because it opens a matching conhost window sub-process and uses it to accept UI events. For the convenience of explanation, the previous paragraph is a test that regularly outputs to the console. code.

        static void Main(string[] args)
        {
            for (int i = 0; i < int.MaxValue; i + + )
            {
                Console.WriteLine($"i={i}");
                Thread.Sleep(1000);
            }
        }

Run the program and use process explorer to observe the process tree.

Next, use windbg to attach to the conshost process and observe whether there is GetMessageW.

0:005> ~* k
   0 Id: 3ec8.2c20 Suspend: 1 Teb: 000000d2`92014000 Unfrozen
 # Child-SP RetAddr Call Site
00 000000d2`922ff798 00007fff`a3e45746 ntdll!NtWaitForSingleObject + 0x14
01 000000d2`922ff7a0 00007fff`a60b5bf1 KERNELBASE!DeviceIoControl + 0x86
02 000000d2`922ff810 00007ff6`9087a790 KERNEL32!DeviceIoControlImplementation + 0x81
03 000000d2`922ff860 00007fff`a60b7614 conhost!ConsoleIoThread + 0xd0
04 000000d2`922ff9e0 00007fff`a66a26a1 KERNEL32!BaseThreadInitThunk + 0x14
05 000000d2`922ffa10 00000000`00000000 ntdll!RtlUserThreadStart + 0x21
...
   2 Id: 3ec8.1b70 Suspend: 1 Teb: 000000d2`9201c000 Unfrozen
 # Child-SP RetAddr Call Site
00 000000d2`9227f858 00007fff`a4891b9e win32u!NtUserGetMessage + 0x14
01 000000d2`9227f860 00007ff6`908735c5 user32!GetMessageW + 0x2e
02 000000d2`9227f8c0 00007fff`a60b7614 conhost!ConsoleInputThreadProcWin32 + 0x75
03 000000d2`9227f920 00007fff`a66a26a1 KERNEL32!BaseThreadInitThunk + 0x14
04 000000d2`9227f950 00000000`00000000 ntdll!RtlUserThreadStart + 0x21
...

2. How to communicate between processes

A further refinement of this problem is how the content written by the client through Console.WriteLine($"i={i}"); is read by the server conhost!ConsoleIoThread method received.

Friends who are familiar with Windows programming know that the underlying calling logic of Console.WriteLine is ntdll!NtWriteFile -> nt!IopSynchronousServiceTail. The former is the gateway function for user mode to enter kernel mode, and the latter is the user’s irp function. After throwing it into the thread’s request packet queue, it goes to sleep (KeWaitForSingleObject) until the driver wakes up after fetching and processing it.

Having said so much, how to verify it?

  • Client breakpoint
0: kd> !process 0 0 ConsoleApp2.exe
PROCESS ffffe001b5e51840
    SessionId: 1 Cid: 0e8c Peb: 7ff7ab226000 ParentCid: 09d4
    DirBase: 18079000 ObjectTable: ffffc00036965200 HandleCount: <Data Not Accessible>
    Image: ConsoleApp2.exe

0: kd> bp /p ffffe001b5e51840 nt!IopSynchronousServiceTail
0: kd> g
Breakpoint 0 hit
nt!IopSynchronousServiceTail:
fffff802`a94f3410 48895c2420 mov qword ptr [rsp + 20h],rbx
3: kd>k
 # Child-SP RetAddr Call Site
00 ffffd000`f6477988 fffff802`a94f2e80 nt!IopSynchronousServiceTail
01 ffffd000`f6477990 fffff802`a916db63 nt!NtWriteFile + 0x680
02 ffffd000`f6477a90 00007ffc`2fed38aa nt!KiSystemServiceCopyEnd + 0x13
03 0000009f`0743dbd8 00007ffc`2cd1d478 ntdll!NtWriteFile + 0xa
04 0000009f`0743dbe0 00000000`00000005 0x00007ffc`2cd1d478
05 0000009f`0743dbe8 0000009f`0743dcf0 0x5
06 0000009f`0743dbf0 0000009f`0978c9b8 0x0000009f`0743dcf0
07 0000009f`0743dbf8 00007ffc`2986e442 0x0000009f`0978c9b8
08 0000009f`0743dc00 0000009f`0743dc30 0x00007ffc`2986e442
09 0000009f`0743dc08 0000009f`0743de00 0x0000009f`0743dc30
0a 0000009f`0743dc10 00000000`00000005 0x0000009f`0743de00
0b 0000009f`0743dc18 00000000`00000000 0x5

3: kd>tc
nt!IopSynchronousServiceTail + 0x70:
fffff802`a94f3480 e8ebf1b5ff call nt!IopQueueThreadIrp (fffff802`a9052670)

  • Server breakpoint

The extraction logic on the conhost side is in the conhost!ConsoleIoThread method, which internally calls the kernelbase!DeviceIoControl function. This method is quite interesting. You can directly issue commands to the driver. The method signature is as follows:

BOOLDeviceIoControl(
  HANDLE hDevice,
  DWORD dwIoControlCode,
  LPVOID lpInBuffer,
  DWORD nInBufferSize,
  LPVOID lpOutBuffer,
  DWORD nOutBufferSize,
  LPDWORD lpBytesReturned,
  LPOVERLAPPED lpOverlapped
);

After the extraction is completed, it will be output to the console through conhost!DoWriteConsole. You can then set a breakpoint to verify it.

0:000> bp conhost!DoWriteConsole
0:000> g
Breakpoint 0 hit
conhost!DoWriteConsole:
00007ff6`90876ec0 48895c2410 mov qword ptr [rsp + 10h],rbx ss:00000095`d627f738=0000000000000000
0:000>r
rax=000000000000000c rbx=00000095d627f7b0 rcx=000002370df76cc0
rdx=00000095d627f768 rsi=00000095d627f7c0 rdi=00000095d627f7f0
rip=00007ff690876ec0 rsp=00000095d627f728 rbp=00000095d627f8f9
 r8=000002370bedf010 r9=00000095d627f7b0 r10=000002370df76cc0
r11=000002370e0c9d00 r12=00000095d627f970 r13=000002370bedf010
r14=000002370bedf010 r15=0000000000000000
iopl=0 nv up ei pl zr na po nc
cs=0033 ss=002b ds=002b es=002b fs=0053 gs=002b efl=00000246
conhost!DoWriteConsole:
00007ff6`90876ec0 48895c2410 mov qword ptr [rsp + 10h],rbx ss:00000095`d627f738=0000000000000000
0:000>du 000002370df76cc0
00000237`0df76cc0 "i=18.."

You can see that there is indeed an i=18. I would like to remind you that if you want to see the sequential logic of the method, you can use perfview.

3. Why is it stuck after quick editing?

The source code of conhost is not public, but it can be inferred visually.

  1. After the quick editing window is enabled by the user, GetMessage will be aware of this custom MSG message.

  2. The logic of this message will keep the server’s process of processing Client messages in waiting, causing the Client’s IopSynchronousServiceTail to be unable to be awakened, causing it to remain blocked, and the completion status of a similar Task is never set.

Next, you can verify the processing message code of Quick Edit Window, just click the mouse on the console. The reference script is as follows:

0:004> bp win32u!NtUserGetMessage "dp ebp-30 L2; g"
0:004>g
00000095`d61ffae0 00000000`00130e6e 00000000`00000404
00000095`d61ffae0 00000000`00130e6e 00000000`00000404
00000095`d61ffae0 00000000`00130e6e 00000000`00000201
00000095`d61ffae0 00000000`00130e6e 00000000`00000405
00000095`d61ffae0 00000000`00130e6e 00000000`00000202
00000095`d61ffae0 00000000`00130e6e 00000000`00000200

From the introduction of each message code in chaggpt, you can see that there will be a 405 custom message, which is related to Quick Edit Window.

Three: Summary

This article is my personal speculation and record of window stuck. Advanced debugging is not easy. If you are interested, please feel free to add details.