Windows ConPTY: Building a Reliable Pseudoconsole Host
Host command-line programs with ConPTY by wiring synchronous pipes, STARTUPINFOEX, UTF-8 and virtual-terminal streams, resizing, and deadlock-safe teardown.
The Windows pseudoconsole API, commonly called ConPTY, lets a terminal application host a command-line program without creating the usual console window. The host becomes responsible for reading the program’s output, interpreting text and Virtual Terminal (VT) sequences, collecting user input, serializing that input, and sending it back. This is a transport and terminal-emulation contract, not a ready-made terminal UI. A host that only launches a child process but does not continuously drain output and supply input can hang even when the child is healthy.
ConPTY is available on Windows 10 version 1809 and Windows Server 2019 or later. The API surface is declared through the Windows Console headers and includes CreatePseudoConsole, ResizePseudoConsole, and ClosePseudoConsole. Use the minimum supported version as a real product constraint: older systems need a separate implementation or a clear compatibility message, not an unresolved import that prevents process startup.
The pipe directions are easy to reverse
The host creates two synchronous byte streams. For the input stream, ConPTY receives the read end and the host writes keyboard text or VT input sequences to the write end. For the output stream, ConPTY receives the write end and the host reads rendered application output from the read end. The pseudoconsole channels carry UTF-8 text interleaved with VT control sequences, regardless of the hosted application’s internal console code page. The host must parse and render those sequences or forward them to another VT-capable terminal.
CreatePseudoConsole accepts synchronous handles. Microsoft recommends servicing each communication channel on a separate thread because blocking on input while output is full, or closing the pseudoconsole while output is not drained, can deadlock. A GUI host normally has one input producer, one output reader, and a UI/render queue; it should not run a blocking ReadFile on the UI thread.
Attach a child through STARTUPINFOEX
The child is still created with CreateProcessW, but the host attaches the pseudoconsole using a process-thread attribute list. The lifecycle is: create pipes; create the pseudoconsole with its input-read and output-write ends; initialize a STARTUPINFOEX; add PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE; create the child with EXTENDED_STARTUPINFO_PRESENT; then close the host’s copies of the two handles that were handed to ConPTY. Retaining duplicate endpoints can prevent the other side from observing a broken pipe and make shutdown appear stuck.
The following complete helper validates character-cell dimensions and resizes an existing pseudoconsole. Session creation and pipe servicing are intentionally separate because each needs its own error handling and ownership protocol.
#include <windows.h>
#include <stdio.h>
int resize_pseudoconsole(HPCON console, SHORT columns, SHORT rows)
{
if (console == NULL || columns <= 0 || rows <= 0) {
fwprintf(stderr, L"invalid pseudoconsole handle or dimensions\n");
return 1;
}
COORD size;
size.X = columns;
size.Y = rows;
HRESULT hr = ResizePseudoConsole(console, size);
if (FAILED(hr)) {
fwprintf(stderr, L"ResizePseudoConsole failed: 0x%08lx\n",
(unsigned long)hr);
return 1;
}
return 0;
}
The width and height are character cells, not pixels. Resize after the host’s terminal layout has a new cell grid, not on every raw pixel change. A hosted application reads its console dimensions from the pseudoconsole; resize propagation can change wrapping and redraw behavior. Queue/coalesce rapid resize notifications so an interactive window does not flood the host with redundant operations.
Parse output as a terminal stream
The output is not a sequence of lines. VT control sequences can move the cursor, clear regions, set colors, change modes, and update terminal state. A line-oriented reader that strips escape sequences may make basic command output look plausible while corrupting interactive programs, full-screen editors, progress displays, and alternate-screen applications. Use a standards-aware VT parser or forward the stream intact to a terminal emulator that supports the sequences the child emits.
Pipe reads can split a multibyte UTF-8 character or a VT sequence across buffers. Preserve parser state between reads and decode incrementally; never assume one ReadFile call returns a complete row or control sequence. Apply bounds to pending data and render queues. If a consumer is slower than the child, the host must deliberately backpressure, drop only explicitly disposable rendering data, or spool within a quota. An unbounded queue converts a temporary UI delay into memory exhaustion.
Input needs the same care in reverse. Keyboard events are not simply Unicode characters: control keys, mouse reports, paste, and terminal modes require appropriate VT encodings. Serialize writes to the pseudoconsole input channel so byte sequences from concurrent UI actions do not interleave. Use a dedicated writer queue and handle partial or failed writes instead of assuming a single call transfers the entire payload.
Shutdown is a protocol, not just CloseHandle
ClosePseudoConsole closes the session and terminates attached client character-mode processes. If the hosted command is a shell that launches descendants, they are attached to the session too. Decide whether that termination behavior is acceptable; if the child should outlive the terminal host, ConPTY teardown is not an appropriate “detach” operation. Track the child process handle and exit status separately from the pseudoconsole handle.
Shutdown ordering matters with synchronous channels. Stop accepting new user input, stop or wait for the child according to product policy, keep the output reader draining while the pseudoconsole closes, observe pipe closure, join reader/writer threads, and then close the remaining host handles. Microsoft warns that closing can produce a final output frame, and the buffer must be drained. If PSEUDOCONSOLE_INHERIT_CURSOR was selected, the host also needs to answer the cursor-position query on the output channel and send the response through the input channel; failing to answer may block operations or teardown. Use that flag only when the parent-console cursor inheritance behavior is explicitly required.
Treat partial startup as a first-class path. If creating the second pipe or attaching the process attribute fails, close the handles already created and delete any initialized attribute list before returning. After a successful CreateProcessW, close the primary thread handle when no longer needed, retain the process handle only for wait/exit observation, and call DeleteProcThreadAttributeList before freeing its storage. Make each handle’s owner and close point visible in the code review.
Validate under pressure and across child types
Test a child that writes continuously while the UI is slow, exits immediately, waits for input, emits split UTF-8 and VT sequences, launches another process, and is resized repeatedly. Test broken input/output pipes and host cancellation. Confirm that process exit, pseudoconsole closure, pipe EOF, and thread completion each produce distinct observable states. Capture logs without recording sensitive terminal input by default; shell sessions can include passwords, tokens, and customer data.
ConPTY makes terminal hosting portable at the stream level, but not trivial. Correctness depends on handle direction, synchronous I/O discipline, VT and UTF-8 state, resize propagation, and teardown order. Get those contracts right before optimizing rendering or forwarding the session across a network.
Related:
- How to Configure Windows Terminal and PowerShell Profiles
- Understanding WSL2: How Windows Runs a Real Linux Kernel
Sources: