Windows Console & Processes
|
This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production; see the section bibliography for the reference material consulted while preparing these pages. |
A command-line program on Windows is not "a POSIX program with different paths". The operating system has no fork,
no termios, no signals in the POSIX sense, and no kernel that splits a command line into an array of arguments. Each
of those jobs is done by a different mechanism, and a CLI that is going to run on Windows, macOS and Linux has to know
where the mechanisms differ. This page covers the Windows side: what a console is, how a program gets attached to one,
how bytes and text are encoded on it, how the command line reaches the program, how Ctrl+C is delivered, how processes
and process trees are created and controlled, how a terminal emulator or test harness hosts a program with a
pseudoconsole, and what Windows Terminal adds. The POSIX counterparts are explained in
Terminals, TTYs & Pseudo-Terminals, and the escape sequences themselves in
Escape Sequences & Terminal Capabilities. Design rules
that do not depend on the platform (exit codes, streams, colour policy) are in
Streams & Exit Codes and
TTY, Colour & Interactivity and are not repeated.
The page was written against the Windows documentation current in October 2026, for Windows 11 and Windows 10
(version 1809 or later for ConPTY, version 1903 or later for the UTF-8 activeCodePage manifest setting),
PowerShell 7.6 and .NET 10 LTS. The running example is the Bookshelf shelf tool of this section.
Which snippets were run. The page was written on a macOS machine, and Windows code cannot be run there. None of the
Win32 C examples (windows.h), the manifest, the PowerShell and the C# snippets were compiled or executed while this
page was written; they follow the signatures and flags of the Microsoft documentation linked after each example and
should be built and tested on Windows before being relied on. The one example that is portable C, the argument
splitter in the section on the command line, was compiled with clang and run, and the output shown is its real output.
Console and GUI Subsystems
A Windows executable carries a subsystem value in its PE header, and that value, not the code, decides whether the
loader treats the program as a console program. The linker option /SUBSYSTEM sets it, and the entry point implied
by it differs:
| Subsystem | Entry point (C and C++) | Behaviour |
|---|---|---|
|
|
Inherits the parent’s console when started from one, or gets a new console window when started from Explorer. The shell waits for it to finish. |
|
|
Gets no console. A shell such as |
If /SUBSYSTEM is not given, the MSVC linker infers it from which entry point the program defines. Other toolchains
use their own switches (for example -mconsole and -mwindows for MinGW, #![windows_subsystem = "windows"] for Rust,
<OutputType>Exe</OutputType> against WinExe in a .NET project). A CLI is a CONSOLE program. A program that needs
no window at all but must not flash a console either (a background helper, a Windows service host) is the usual reason
to choose WINDOWS.
A GUI-subsystem program has no standard handles to write to, but it can still print into the console it was started
from, which is how one executable can serve both as a desktop app and as a command-line tool. The three calls involved
are AllocConsole (create a new console and attach to it), AttachConsole(ATTACH_PARENT_PROCESS) (attach to the
parent’s console) and FreeConsole (detach). The C runtime does not notice the change by itself, so the standard
streams have to be reopened on the new console’s devices.
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
/* For a GUI-subsystem build of shelf.exe: print to the console that launched us, if there is one. */
static BOOL attach_parent_console(void) {
if (!AttachConsole(ATTACH_PARENT_PROCESS)) {
return FALSE; /* started from Explorer or a service: no parent console */
}
FILE *unused;
freopen_s(&unused, "CONOUT$", "w", stdout);
freopen_s(&unused, "CONOUT$", "w", stderr);
freopen_s(&unused, "CONIN$", "r", stdin);
return TRUE;
}
int WINAPI wWinMain(HINSTANCE inst, HINSTANCE prev, PWSTR cmdline, int show) {
if (attach_parent_console()) {
puts("shelf: 3 books on the shelf");
return 0;
}
/* No console: show the desktop window instead. */
return 0;
}
Source: AttachConsole and /SUBSYSTEM (Specify Subsystem).
The trade-offs are real. The shell has already returned to its prompt when the GUI-subsystem program starts printing,
so output appears after the next prompt, and redirection (shelf > out.txt) does not work because the program’s
standard handles were never the redirected ones. The pattern most large tools use instead is to ship two executables
(a CONSOLE build for the command line and a WINDOWS build for the desktop), which is what several editors and IDEs do
with a small launcher next to the main binary.
The Classic Console API and Virtual Terminal Sequences
Windows has two ways to control a console. The classic Console API is a set of functions that act on the console buffer directly: move the cursor, set attributes, fill a rectangle, scroll a region. Virtual-terminal (VT) sequences are the ECMA-48 and xterm style escape sequences used on every Unix terminal; a program writes them to the output handle and the console interprets them. Microsoft’s documentation recommends VT sequences for all new development and keeps only a small subset of the classic API as still necessary. The pages "Classic Console API versus virtual terminal sequences" and "Console ecosystem roadmap" in the Microsoft documentation describe that direction.
| Job | Classic API | VT sequence |
|---|---|---|
Colour and text attributes |
|
SGR, 16, 256 and 24-bit colour: |
Move the cursor |
|
CSI cursor movement |
Clear or scroll |
|
CSI erase and scroll |
Alternate screen |
no equivalent in the API |
|
Mouse and bracketed paste |
mouse input records in |
mode-based sequences, with |
VT handling is a mode of the console, switched on per handle with SetConsoleMode. These are the flags a CLI uses:
| Flag | Handle | Effect |
|---|---|---|
|
output |
Control characters such as backspace, tab, bell, carriage return and line feed are interpreted. Required for VT processing. |
|
output |
The console interprets VT sequences written to the output handle instead of printing them. |
|
output |
A line feed no longer implies a carriage return; applications that position the cursor themselves usually want this. |
|
input |
Key presses arrive as VT sequences, as they would on a Unix terminal, instead of as classic key event records. |
|
input |
Line-buffered input and echo. Clearing both gives the equivalent of non-canonical mode with echo off. |
|
input |
Ctrl+C is handled by the system (delivered as a control event) instead of being returned as an input character. |
|
input |
|
Output VT processing is not on by default for a process, so a CLI that wants colours or cursor control has to switch
it on, and has to cope with the call failing: SetConsoleMode fails when the handle is not a console (output
redirected to a file or a pipe), which is also the Windows answer to "is stdout a terminal?" (the equivalent of
isatty). Older consoles that predate VT support also fail the flag, so a fallback to plain text is the right
behaviour. The colour policy itself (NO_COLOR, --color) is the same as on every platform and is covered in
TTY, Colour & Interactivity.
The classic functions that Microsoft keeps recommending are SetConsoleMode, SetConsoleCP and
SetConsoleOutputCP, SetConsoleCtrlHandler, and ReadConsoleInput for window resize events, together with
GetConsoleScreenBufferInfo to read the window size. Everything else in the classic API is better done with VT
sequences.
The following program is the Windows entry of the shelf list command. It prepares the console, prints in colour only
when the output really is a console, and writes Unicode correctly in both situations (console and redirected).
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdlib.h>
#include <wchar.h>
/* Returns TRUE when h is a console and VT processing is now on. */
static BOOL enable_vt_output(HANDLE h) {
DWORD mode = 0;
if (!GetConsoleMode(h, &mode)) {
return FALSE; /* redirected to a file or a pipe */
}
return SetConsoleMode(h, mode | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
}
/* Writes UTF-16 text to the console, or as UTF-8 when stdout is redirected. */
static void emit(HANDLE h, BOOL is_console, const wchar_t *text) {
DWORD written;
int length = (int)wcslen(text);
if (is_console) {
WriteConsoleW(h, text, (DWORD)length, &written, NULL);
return;
}
char utf8[1024];
int bytes = WideCharToMultiByte(CP_UTF8, 0, text, length, utf8, sizeof utf8, NULL, NULL);
WriteFile(h, utf8, (DWORD)bytes, &written, NULL);
}
int wmain(int argc, wchar_t **argv) {
HANDLE out = GetStdHandle(STD_OUTPUT_HANDLE);
BOOL vt = enable_vt_output(out);
BOOL no_color = GetEnvironmentVariableW(L"NO_COLOR", NULL, 0) > 0;
if (argc < 2 || wcscmp(argv[1], L"list") != 0) {
emit(GetStdHandle(STD_ERROR_HANDLE), FALSE, L"usage: shelf list\r\n");
return 2;
}
const wchar_t *title = L"\x00C9tica a Nic\x00F3maco"; /* Etica a Nicomaco with accents */
if (vt && !no_color) {
emit(out, TRUE, L"\x1b[1;32m*\x1b[0m ");
}
emit(out, vt, title);
emit(out, vt, L"\r\n");
return 0;
}
Source: SetConsoleMode, Console Virtual Terminal Sequences and Classic Console API versus Virtual Terminal Sequences.
In .NET, Console.IsOutputRedirected is the isatty answer. The
frameworks that wrap these checks for each language are compared in
TTY, Colour & Interactivity.
Code Pages and UTF-8
Windows text APIs come in two forms. The W functions take UTF-16 and are the native form; the A functions take
bytes in the process’s ANSI code page, a legacy 8-bit encoding chosen by the system locale (for example 1252 for
Western European Windows). A console additionally has its own input and output code pages (what chcp shows),
which are what the console uses to interpret the bytes a program writes with WriteFile or the CRT narrow functions.
By default, that is an OEM code page such as 437 or 850, not UTF-8, which is why a UTF-8 string written with printf
shows as garbage on a default console.
There are three independent places where UTF-8 can be switched on, and a program that must work everywhere needs to know what each one does.
| Mechanism | What it changes | Notes |
|---|---|---|
|
The code pages of the current console, for input and output bytes |
Console-wide, not process-wide: it affects other programs sharing the console and is not restored when the program exits. Read the old values with |
The |
The process’s ANSI code page, so every |
Needs Windows 10 version 1903 or later. Per executable, set at build time. |
The system setting "Beta: Use Unicode UTF-8 for worldwide language support" |
The ANSI and OEM code pages of the whole machine |
User-controlled; a program cannot depend on it being set. |
The manifest element is declared in the application manifest, embedded in the executable:
<?xml version="1.0" encoding="UTF-8" standalone="yes"?>
<assembly manifestVersion="1.0" xmlns="urn:schemas-microsoft-com:asm.v1">
<application>
<windowsSettings>
<activeCodePage xmlns="http://schemas.microsoft.com/SMI/2019/WindowsSettings">UTF-8</activeCodePage>
<longPathAware xmlns="http://schemas.microsoft.com/SMI/2016/WindowsSettings">true</longPathAware>
</windowsSettings>
</application>
</assembly>
The robust approaches, in order of preference, are:
-
Wide-character everything. Define
wmain, treat all text as UTF-16 internally, print to a console withWriteConsoleW, and convert to UTF-8 only when writing to a file or a pipe (asemitabove does). This works on every Windows version and is independent of any code page. -
UTF-8 everything. Set
activeCodePageto UTF-8, callSetConsoleOutputCP(CP_UTF8)andSetConsoleCP(CP_UTF8)at start-up, and keep narrow strings in UTF-8. Convenient for code shared with Linux and macOS, but requires Windows 10 1903 and a restore of the console code pages on exit. -
Let the runtime handle it. .NET, Go, Rust and the JVM write Unicode to a console correctly through the
WAPIs; the pitfalls are in the byte-level paths (Console.OutputEncodingin .NET, piping into other programs). The runtime-specific pages of this section cover those.
The C runtime offers _setmode(_fileno(stdout), _O_U16TEXT) to make wprintf write UTF-16 to the console; mixing
narrow and wide output on the same stream after that is undefined, so choose one per stream.
using System.Text;
// shelf in .NET: write UTF-8 bytes when stdout is redirected, and keep the console on UTF-8.
Console.OutputEncoding = new UTF8Encoding(encoderShouldEmitUTF8Identifier: false);
Console.WriteLine("\u00C9tica a Nic\u00F3maco");
Source: Console.OutputEncoding.
The Command Line Is One String
On POSIX, execve takes an array of arguments, so the shell does the splitting and the child receives exactly the
array the shell built. Windows has no such array. CreateProcess takes a single command-line string, the kernel
stores it in the process, GetCommandLineW returns it, and the program itself splits it into argv. The C runtime
does this before main, and CommandLineToArgvW does the same for programs that call it directly. Two consequences
follow that surprise Unix developers:
-
Every program may split the command line differently. The convention is common but not enforced, and some programs (
cmd.exebuiltins, many installers,msiexec) use their own rules. -
A caller that builds a command line for another program must quote each argument so that the callee’s rules recover the original text. There is no
execv-style call that avoids the problem.
The MSVC parsing rules
The rules used by the Microsoft C and C++ runtime are documented with an exact algorithm. For arguments after the program name:
-
Arguments are separated by spaces or tabs.
-
A string in double quotes is one argument, even with spaces. The quotes themselves are not part of the argument.
-
A double quote preceded by a backslash (
\") is a literal double quote. -
Backslashes are literal unless they come immediately before a double quote.
2nbackslashes followed by a quote givenbackslashes and the quote toggles quoting;2n+1backslashes followed by a quote givenbackslashes and a literal quote. -
Inside a quoted string, two double quotes in a row are one literal quote (the rule used by the UCRT since Visual Studio 2008).
-
The first token, the program name, follows simpler rules: no backslash escaping, and a quote only toggles.
This portable C program implements the rules and prints the result for the documented examples, plus the classic trailing-backslash pitfall. It was compiled with clang on macOS and run; the output below is its real output.
/* msvc_argv.c: the documented MSVC argument-splitting rules, for arguments after the program name. */
#include <stdio.h>
#include <stdlib.h>
#include <string.h>
static int split_command_line(const char *cmd, char ***out) {
int cap = 8, n = 0;
char **argv = malloc(cap * sizeof *argv);
const char *p = cmd;
for (;;) {
while (*p == ' ' || *p == '\t') p++;
if (*p == '\0') break;
char *arg = malloc(strlen(p) + 1);
size_t len = 0;
int in_quotes = 0;
while (*p != '\0' && (in_quotes || (*p != ' ' && *p != '\t'))) {
size_t backslashes = 0;
while (*p == '\\') { backslashes++; p++; }
if (*p == '"') {
for (size_t i = 0; i < backslashes / 2; i++) arg[len++] = '\\';
if (backslashes % 2 == 1) {
arg[len++] = '"'; /* backslash-quote: a literal quote */
} else if (in_quotes && p[1] == '"') {
arg[len++] = '"'; /* two quotes inside quotes: a literal quote */
p++;
} else {
in_quotes = !in_quotes; /* a plain quote toggles quoting */
}
p++;
} else {
for (size_t i = 0; i < backslashes; i++) arg[len++] = '\\';
if (*p != '\0') arg[len++] = *p++;
}
}
arg[len] = '\0';
if (n == cap) { cap *= 2; argv = realloc(argv, cap * sizeof *argv); }
argv[n++] = arg;
}
*out = argv;
return n;
}
int main(void) {
const char *cases[] = {
"shelf add \"The Hobbit\" --author \"J. R. R. Tolkien\"",
"shelf \"abc\" d e",
"shelf a\\\\b d\"e f\"g h",
"shelf a\\\\\\\"b c d",
"shelf a\\\\\\\\\"b c\" d e",
"shelf a\"b\"\" c d",
"shelf find C:\\Books\\ \"C:\\My Books\\\" tail",
};
for (size_t c = 0; c < sizeof cases / sizeof *cases; c++) {
char **argv;
int argc = split_command_line(cases[c], &argv);
printf("%s\n", cases[c]);
for (int i = 0; i < argc; i++) printf(" argv[%d] = [%s]\n", i, argv[i]);
}
return 0;
}
shelf add "The Hobbit" --author "J. R. R. Tolkien"
argv[0] = [shelf]
argv[1] = [add]
argv[2] = [The Hobbit]
argv[3] = [--author]
argv[4] = [J. R. R. Tolkien]
shelf "abc" d e
argv[0] = [shelf]
argv[1] = [abc]
argv[2] = [d]
argv[3] = [e]
shelf a\\b d"e f"g h
argv[0] = [shelf]
argv[1] = [a\\b]
argv[2] = [de fg]
argv[3] = [h]
shelf a\\\"b c d
argv[0] = [shelf]
argv[1] = [a\"b]
argv[2] = [c]
argv[3] = [d]
shelf a\\\\"b c" d e
argv[0] = [shelf]
argv[1] = [a\\b c]
argv[2] = [d]
argv[3] = [e]
shelf a"b"" c d
argv[0] = [shelf]
argv[1] = [ab" c d]
shelf find C:\Books\ "C:\My Books\" tail
argv[0] = [shelf]
argv[1] = [find]
argv[2] = [C:\Books\ C:\My Books" tail]
The last case is the most common real-world bug. A path that ends in a backslash and is wrapped in quotes
(for example "C:\My Books\") has its closing quote escaped by the backslash, so the argument swallows the following text. The
fix when building a command line is to double the trailing backslashes of an argument before adding the closing
quote, and to prefer forward slashes or to strip the trailing backslash in paths passed to other programs. This
demo shows the splitting rules only. The runtime’s own handling is the reference, and argv[0] has the special rule
listed above.
Differences between the splitters
CommandLineToArgvW is documented as using rules similar to the MSVC ones but not identical. The documented
differences concern the program name (when the string begins with a quote or an empty string) and the treatment of
quotes; a program that needs exactly the runtime’s behaviour should use main/wmain and read argv, and a program
that parses GetCommandLineW itself should be tested against the cases above rather than assumed equivalent. The
result of CommandLineToArgvW is one block of memory freed with a single LocalFree call.
Wildcard expansion. On Unix the shell expands *.epub before the program starts. On Windows the shell does not,
and the MSVC runtime does not either unless the program is linked with setargv.obj (narrow) or wsetargv.obj
(wide). A CLI that accepts file patterns therefore has to decide: expand them itself (with FindFirstFile), link
the expanding start-up object, or document that patterns must be quoted away from the shell. Running a Unix-minded
shelf import *.epub under cmd.exe gives the program the literal text *.epub.
Narrow versus wide. A program with main receives argv converted to the ANSI code page of the process, so text
outside that code page is lost (shown as ?). A program with wmain gets the exact UTF-16 command line. With the
activeCodePage manifest set to UTF-8, the narrow argv is UTF-8 as well. For a Unicode-safe CLI that is not using a
runtime that already does it, use wmain or the UTF-8 manifest.
cmd.exe and PowerShell quoting
The shell adds its own layer in front of the program’s rules. The two shells that matter:
| Shell | Rules that apply before the program sees the line |
|---|---|
|
|
PowerShell |
PowerShell parses the line into its own values first, with its own quoting and escaping (the backtick is the escape character). It then builds the native command line. How it does that is controlled by |
For PowerShell 7.3 and later, $PSNativeCommandArgumentPassing has three values. Legacy is the historical behaviour:
arguments with spaces get wrapped in quotes, but embedded double quotes are not escaped, so the receiving program’s
parser consumes them. Standard escapes embedded quotes and empty arguments so that MSVC-style parsing recovers each
argument exactly. Windows, the default on Windows, behaves as Legacy for a small list of programs and file types
that do their own parsing (cmd.exe, cscript.exe, wscript.exe and .bat, .cmd, .js, .vbs, .wsf files) and as
Standard for everything else. On other platforms the default is Standard. The stop-parsing token --% passes the
rest of the line through untouched.
# PowerShell 7.6: how a title with spaces and quotes reaches shelf.exe
$PSNativeCommandArgumentPassing # Windows on Windows, Standard elsewhere
$PSNativeCommandArgumentPassing = 'Standard'
shelf add 'He said "hi"' --author 'Ana' # argv[2] is: He said "hi"
# From cmd.exe the caret escapes a shell metacharacter; the quotes are the program's business
# C:\> shelf add "Pride ^& Prejudice" --author "Jane Austen"
# Inside quotes cmd.exe does not treat the caret as an escape, so it can remain in the text
Source: about_Preference_Variables and about_Parsing.
Two security consequences follow. First, a program that builds a command line from untrusted input and runs it through
cmd.exe /c is vulnerable to command injection through the metacharacters above, and the quoting that is correct for
a native program is not sufficient for cmd.exe, so the safe choice is to avoid the shell and call CreateProcess
directly. Second, even without a shell, launching a .bat or .cmd file makes cmd.exe parse the arguments again,
which is how several languages' standard libraries ended up with an argument-injection vulnerability in 2024 (the "BatBadBut"
class); do not pass untrusted arguments to batch files. The official Windows documentation does not provide a quoting
function that is safe for cmd.exe, so no recipe for one is given here.
Ctrl+C and Ctrl+Break
Windows has no signals. A console delivers control events to every process attached to it, by running a handler function on a new thread in each process. The events are:
| Event | Source | Meaning |
|---|---|---|
|
Ctrl+C |
Interrupt. Delivered only if the console input has |
|
Ctrl+Break |
A second, stronger interrupt that cannot be disabled by |
|
closing the console window |
The program is about to be terminated; there is only a short time to clean up. |
|
user logoff or system shutdown |
Same, delivered to services too. |
A program registers handlers with SetConsoleCtrlHandler(handler, TRUE). Handlers are called in reverse order of
registration until one returns TRUE; returning FALSE passes the event on, and the default handler (which calls
ExitProcess) runs last. SetConsoleCtrlHandler(NULL, TRUE) makes the process ignore Ctrl+C, and that setting is
inherited by child processes. A handler runs on its own thread, so the usual rules for threads apply, but unlike a
POSIX signal handler it may call ordinary functions: set an atomic flag or an event and return. For the close, logoff
and shutdown events the system terminates the process once the handler returns or after a short time-out, so cleanup
must be quick.
GenerateConsoleCtrlEvent(event, processGroupId) sends CTRL_C_EVENT or CTRL_BREAK_EVENT to a process group
sharing the caller’s console. The two have different reach: CTRL_C_EVENT can only be sent to all processes
attached to the console (group id 0), while CTRL_BREAK_EVENT can target the process group of a child created with
CREATE_NEW_PROCESS_GROUP. That creation flag also makes the new group ignore Ctrl+C, so the only way to ask such a
child to stop politely is Ctrl+Break. This is the Windows analogue of kill(pid, SIGINT) and it is much narrower,
which is why many tools fall back to TerminateProcess (like SIGKILL, it does not let the target clean up).
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
static volatile LONG stop_requested = 0;
static BOOL WINAPI on_control_event(DWORD event) {
switch (event) {
case CTRL_C_EVENT:
case CTRL_BREAK_EVENT:
InterlockedExchange(&stop_requested, 1); /* main loop finishes the current book and exits */
return TRUE; /* handled: do not run the default handler */
case CTRL_CLOSE_EVENT:
case CTRL_LOGOFF_EVENT:
case CTRL_SHUTDOWN_EVENT:
InterlockedExchange(&stop_requested, 1);
Sleep(2000); /* the process ends when we return or time out */
return TRUE;
default:
return FALSE;
}
}
int wmain(void) {
if (!SetConsoleCtrlHandler(on_control_event, TRUE)) {
return 1;
}
for (int book = 1; book <= 1000 && !stop_requested; book++) {
wprintf(L"syncing book %d\n", book);
Sleep(500);
}
wprintf(L"shelf sync: stopped cleanly\n");
return stop_requested ? 130 : 0; /* 130 follows the Unix convention for "interrupted by SIGINT" */
}
Source: SetConsoleCtrlHandler and GenerateConsoleCtrlEvent.
How each runtime maps these events onto its own abstraction differs, and none of the Microsoft pages lists the mappings, so the runtime documentation is the reference:
| Runtime | Mapping |
|---|---|
C runtime |
|
.NET |
|
Python |
|
Node.js |
|
Go |
|
Rust |
The standard library has no signal API. Crates such as Tokio expose Windows control events in a platform-specific module. |
Sources: Console.CancelKeyPress, Python signal, Node.js signal events and Go os/signal.
The portable pattern, which keeps the shell-level behaviour identical on all three systems, is: handle the interrupt
by setting a flag, finish or roll back the current unit of work, exit with a non-zero status, and leave
TerminateProcess/SIGKILL for the supervisor. For the longer story of supervised processes, see
Long-Running Processes & Services.
Creating Processes
CreateProcessW is the one call that creates a process. Where POSIX splits the job into fork (clone) and exec
(replace the image), Windows creates the new process from an executable file directly; the closest POSIX counterpart
is posix_spawn. Process creation is considerably more expensive than fork on Linux, which matters for CLIs that
spawn many short-lived children, but there is no copy-on-write clone to exploit.
The arguments that matter for a CLI:
-
lpApplicationNameandlpCommandLine. Pass the full path of the executable aslpApplicationNameand the complete (already quoted) command line aslpCommandLine. IflpApplicationNameisNULL, Windows takes the first token of the command line as the program and, for a path with spaces that is not quoted, tries each prefix in turn (C:\Program.exe, thenC:\Program Files\Shelf\shelf.exe). That is the unquoted path vulnerability: an attacker who can plantC:\Program.exehijacks the launch. Always quote the program path, and passlpApplicationName.lpCommandLineforCreateProcessWmust be a writable buffer. -
bInheritHandlesand handle inheritance. A child inherits only handles that were created inheritable and only if the parent passesTRUE; inheriting everything leaks handles to children, so the safe pattern is aSTARTUPINFOEXwith aPROC_THREAD_ATTRIBUTE_HANDLE_LISTthat names exactly which handles to inherit. This is the equivalent of closing every descriptor except 0, 1 and 2 afterfork, or ofO_CLOEXECby default. -
Creation flags:
CREATE_NO_WINDOW(a console program without a console window),CREATE_NEW_CONSOLE,DETACHED_PROCESS(no console at all),CREATE_NEW_PROCESS_GROUP,CREATE_SUSPENDED(start paused, resume withResumeThread),CREATE_UNICODE_ENVIRONMENT(the environment block is UTF-16) andCREATE_BREAKAWAY_FROM_JOB. -
Environment and working directory. Pass
NULLto inherit; an explicit environment is a block ofNAME=valuestrings ending with an extra empty string, and must be sorted by name for some APIs to find variables reliably. -
Waiting and exit codes:
WaitForSingleObject(process, INFINITE)andGetExitCodeProcess. An exit code on Windows is a 32-bitDWORD, not an 8-bit value as on Linux, and the value 259 (STILL_ACTIVE) means "not finished yet", so a program that exits with 259 is indistinguishable from a running one. Keep CLI exit codes in the 0 to 255 range for portability.
Not every executable is a program CreateProcess can run directly. It runs .exe files; for a .bat or .cmd file
it starts cmd.exe behind the scenes (with the quoting hazards above), and a .ps1 script has to be launched through
powershell.exe or pwsh.exe. If no extension is given, CreateProcess appends .exe only; it does not use
PATHEXT, which is a convention of cmd.exe and PowerShell.
Job objects
POSIX groups processes in sessions and process groups so that a signal can reach a whole tree (kill(-pgid, SIGTERM))
and control groups limit resources on Linux. On Windows both jobs belong to a job object: a container of processes
that can be limited, queried, and terminated together. A process belongs to a job, and its children are placed in the
same job by default. Windows 8 and later allow nested jobs.
The most useful setting for a CLI or daemon that spawns helpers is JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE: when the last
handle to the job closes (including because the parent crashed), the system terminates every process in the job. That
removes the orphaned children that plague POSIX supervisors, without needing the children’s cooperation. shelfd uses
it to run shelf sync workers:
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
#include <stdio.h>
/* Runs "shelf.exe sync --all" as a child of a job that dies with shelfd. Returns the child's exit code. */
static DWORD run_sync_in_job(void) {
HANDLE job = CreateJobObjectW(NULL, NULL);
if (job == NULL) return 1;
JOBOBJECT_EXTENDED_LIMIT_INFORMATION limits = {0};
limits.BasicLimitInformation.LimitFlags = JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE;
if (!SetInformationJobObject(job, JobObjectExtendedLimitInformation, &limits, sizeof limits)) {
CloseHandle(job);
return 1;
}
wchar_t command[] = L"\"C:\\Program Files\\Shelf\\shelf.exe\" sync --all"; /* writable buffer */
STARTUPINFOW si = {0};
si.cb = sizeof si;
PROCESS_INFORMATION pi = {0};
/* Create suspended so the child cannot start grandchildren before it is in the job. */
if (!CreateProcessW(L"C:\\Program Files\\Shelf\\shelf.exe", command, NULL, NULL, FALSE,
CREATE_SUSPENDED | CREATE_NO_WINDOW, NULL, NULL, &si, &pi)) {
CloseHandle(job);
return 1;
}
if (!AssignProcessToJobObject(job, pi.hProcess)) {
TerminateProcess(pi.hProcess, 1);
CloseHandle(pi.hThread); CloseHandle(pi.hProcess); CloseHandle(job);
return 1;
}
ResumeThread(pi.hThread);
WaitForSingleObject(pi.hProcess, INFINITE);
DWORD code = 0;
GetExitCodeProcess(pi.hProcess, &code);
CloseHandle(pi.hThread);
CloseHandle(pi.hProcess);
CloseHandle(job); /* closing the job terminates anything shelf left running */
return code;
}
Source: Job Objects, CreateProcessW and Process Creation Flags.
Job objects also carry CPU, memory and process-count limits, which is the Windows route to what ulimit and cgroups do
on Linux. Container and sandbox runtimes build on the same objects.
Hosting Terminals with ConPTY
A program that wants to run another console program inside its own window (a terminal emulator, an SSH server, a test harness, an editor’s integrated terminal) cannot simply redirect the child’s handles: the child would see pipes, not a console, and would turn off colours and cursor control. On Unix the answer is a pseudo-terminal pair (Terminals, TTYs & Pseudo-Terminals). Windows 10 version 1809 added the equivalent, the pseudoconsole (ConPTY). The host creates two pipes, creates a pseudoconsole bound to them, and starts the child attached to that pseudoconsole. The child believes it has a real console; the host reads a stream of VT sequences from one pipe and writes the user’s keystrokes, also as VT sequences, to the other.
The API is three functions and a process attribute:
-
CreatePseudoConsole(size, hInput, hOutput, flags, &hPC)creates the pseudoconsole from an input pipe it reads and an output pipe it writes.flagscan includePSEUDOCONSOLE_INHERIT_CURSOR. -
ResizePseudoConsole(hPC, size)is the WindowsTIOCSWINSZ: call it when the host window changes size. -
ClosePseudoConsole(hPC)ends the session. Keep draining the output pipe until the call returns, as a precaution against the call waiting on pending output. -
PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE, set on aSTARTUPINFOEXattribute list, attaches the new process.
#define WIN32_LEAN_AND_MEAN
#include <windows.h>
static HANDLE g_out_read; /* VT stream coming out of the pseudoconsole */
static HANDLE g_in_write; /* VT stream going into the pseudoconsole */
/* Copies the child's VT output to our own console. */
static DWORD WINAPI pump_output(LPVOID unused) {
HANDLE stdout_handle = GetStdHandle(STD_OUTPUT_HANDLE);
char buffer[4096];
DWORD got, wrote;
while (ReadFile(g_out_read, buffer, sizeof buffer, &got, NULL) && got > 0) {
WriteFile(stdout_handle, buffer, got, &wrote, NULL);
}
return 0;
}
/* Copies our keystrokes (VT input) to the child. */
static DWORD WINAPI pump_input(LPVOID unused) {
HANDLE stdin_handle = GetStdHandle(STD_INPUT_HANDLE);
char buffer[256];
DWORD got, wrote;
while (ReadFile(stdin_handle, buffer, sizeof buffer, &got, NULL) && got > 0) {
WriteFile(g_in_write, buffer, got, &wrote, NULL);
}
return 0;
}
int wmain(void) {
/* Our own console: VT output on, raw VT input, so keys pass through unchanged. */
HANDLE console_out = GetStdHandle(STD_OUTPUT_HANDLE);
HANDLE console_in = GetStdHandle(STD_INPUT_HANDLE);
DWORD out_mode = 0, in_mode = 0;
GetConsoleMode(console_out, &out_mode);
GetConsoleMode(console_in, &in_mode);
SetConsoleMode(console_out, out_mode | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING);
SetConsoleMode(console_in, ENABLE_VIRTUAL_TERMINAL_INPUT);
HANDLE pty_in_read, pty_out_write;
if (!CreatePipe(&pty_in_read, &g_in_write, NULL, 0)) return 1;
if (!CreatePipe(&g_out_read, &pty_out_write, NULL, 0)) return 1;
COORD size = {120, 30};
HPCON pty;
if (FAILED(CreatePseudoConsole(size, pty_in_read, pty_out_write, 0, &pty))) return 1;
CloseHandle(pty_in_read); /* the pseudoconsole keeps its own references to these ends */
CloseHandle(pty_out_write);
/* Attach the new process to the pseudoconsole through an attribute list. */
SIZE_T bytes = 0;
InitializeProcThreadAttributeList(NULL, 1, 0, &bytes);
STARTUPINFOEXW si = {0};
si.StartupInfo.cb = sizeof si;
si.lpAttributeList = (PPROC_THREAD_ATTRIBUTE_LIST)HeapAlloc(GetProcessHeap(), 0, bytes);
InitializeProcThreadAttributeList(si.lpAttributeList, 1, 0, &bytes);
UpdateProcThreadAttribute(si.lpAttributeList, 0, PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE,
pty, sizeof pty, NULL, NULL);
wchar_t command[] = L"shelf.exe tui";
PROCESS_INFORMATION pi = {0};
if (!CreateProcessW(NULL, command, NULL, NULL, FALSE, EXTENDED_STARTUPINFO_PRESENT,
NULL, NULL, &si.StartupInfo, &pi)) {
return 1;
}
CreateThread(NULL, 0, pump_output, NULL, 0, NULL);
CreateThread(NULL, 0, pump_input, NULL, 0, NULL);
WaitForSingleObject(pi.hProcess, INFINITE);
ClosePseudoConsole(pty); /* the output pump ends when the pipe closes */
DeleteProcThreadAttributeList(si.lpAttributeList);
SetConsoleMode(console_out, out_mode);
SetConsoleMode(console_in, in_mode);
return 0;
}
Source: Creating a Pseudoconsole session and CreatePseudoConsole.
A few facts about ConPTY that matter when testing or embedding a TUI:
-
ConPTY is not documented as a transparent pipe. In practice the console host keeps its own model of the screen, so the VT stream the host reads should not be assumed to be byte-for-byte what the child wrote; sequences can be rewritten or coalesced, and some set-up sequences appear at the start. Parse the stream as VT, never as a recording.
-
The host owns the other end of two ordinary pipes, so it has to read continuously; a host that stops reading blocks the child.
-
Child programs see a real console, so
GetConsoleModesucceeds andisatty-style checks pass, which is the point. -
ConPTY needs Windows 10 1809 or later. Earlier systems have no equivalent.
Testing the shelf tui screen through ConPTY on Windows CI is covered in
Testing Terminal Apps & TUIs; libraries such as portable-pty and
expectrl wrap this API behind the same interface they use for POSIX pseudo-terminals.
Windows Terminal and the Console Host
Two programs can display a console on Windows 10 and 11. conhost.exe is the traditional console host that has
existed since Windows NT: it draws the window, owns the console buffer and implements the classic API. Windows
Terminal (wt.exe) is a separate, open-source terminal emulator that hosts any command-line program, including
cmd.exe, PowerShell and WSL distributions, in tabs and panes. It is built on ConPTY: each tab is a pseudoconsole, and
Windows Terminal reads its VT stream and renders it. On Windows 11 22H2 and later it is the default terminal application, and the
Windows setting "Default terminal application" lets a user choose which host opens when a console program is started
from Explorer or the Run box.
What Windows Terminal adds, in terms a CLI author can rely on:
-
A modern renderer with Unicode, colour emoji, and ligature-capable fonts. It supports the VT features covered in Escape Sequences & Terminal Capabilities, including 24-bit colour and mouse reporting. Which extensions a given version supports (for example hyperlinks or the Kitty keyboard protocol) has to be checked in its release notes.
-
Profiles in a
settings.jsonfile (shell, font, colour scheme, starting directory) and a command-line interface (wt new-tab,wt split-pane,wt -d <dir>, with;between subcommands) that ashelf tuilauncher script can use. -
Installation with
winget install Microsoft.WindowsTerminalor from the Microsoft Store.
No supported, documented way exists to detect "am I in Windows Terminal?" from a program. The environment variable
WT_SESSION is set by Windows Terminal and is widely used for that purpose, but it is a convention rather than a
contract, and the right approach is to detect capabilities (the GetConsoleMode check and TERM/COLORTERM-style
hints) instead of identity.
Source: Windows Terminal documentation.
Long Paths, PATHEXT and Line Endings
Three smaller platform facts regularly bite portable CLIs:
-
Long paths. The classic
MAX_PATHlimit is 260 characters. Prefixing an absolute path with\\?\lifts it for APIs that support the prefix and disables path normalisation (forward slashes are no longer converted,.and..are not collapsed). Alternatively, thelongPathAwaremanifest element shown earlier, plus theLongPathsEnabledregistry setting (or group policy), lets ordinary paths exceed 260 characters in applications that opt in. Neither is on by default, so a CLI that walks a deep library tree should opt in and handle the failure. -
PATHEXT.cmd.exeand PowerShell resolveshelftoshelf.exe,shelf.cmdand so on using the list in thePATHEXTenvironment variable, in order, searching the current directory first incmd.exebut not in PowerShell. A native program that spawnsshelfthroughCreateProcessdoes not get that behaviour (only.exeis tried), so portable launchers resolve the full path themselves. A name collision betweenshelf.exeandshelf.cmdis decided byPATHEXTorder, and a writable directory early onPATHis a hijacking vector. -
Line endings. Windows text files and consoles conventionally use CRLF. The C runtime’s text-mode streams convert
\nto CRLF on output and back on input; raw handles (WriteFile) do not, which is why the earlier example writes\r\nexplicitly. A CLI that produces data for other tools (JSON, CSV) should write\nconsistently and say so. The wider cross-platform comparison is in Cross-Platform Portability.
Win32 and POSIX Equivalents
The table maps the Windows mechanism a CLI uses to its closest POSIX counterpart. "Closest" is deliberate: many pairs differ in important ways, stated in the last column.
| Job | Win32 | POSIX | Main difference |
|---|---|---|---|
Create a process |
|
|
One call, no clone of the parent; takes one command-line string rather than an argument array. |
Command-line arguments |
|
|
The program splits the string itself, so callers must quote correctly. |
Wait and exit status |
|
|
32-bit exit codes; 259 is reserved for |
Force-kill |
|
|
No cleanup in the target, same as |
Interrupt (Ctrl+C) |
control event |
|
Handler runs on a new thread; delivered to every process on the console. |
Ctrl+Break |
|
|
Roughly corresponding; Break is not a core dump request. |
Window closed or logoff |
|
|
Short fixed time before the process is terminated. |
Send an interrupt to a child |
|
|
Only to the whole console group (Ctrl+C) or to a group made with |
Kill a process tree |
job object with |
|
The job also enforces CPU and memory limits. |
Is stdout a terminal? |
|
|
Also fails on a pipe or file; |
Terminal modes |
|
|
Per handle; there is no single raw mode flag, clear |
Window size |
|
|
Read the |
Resize notification |
|
|
An input record, not a signal, so it arrives in the input loop. |
Pseudo-terminal |
|
|
The host receives a re-rendered VT stream, not raw bytes. |
Pipes |
|
|
Handles, not descriptors; inheritance is explicit. |
Handle inheritance |
|
descriptors inherited unless |
Inherit-everything is the Windows default if enabled; use the handle list. |
Environment |
|
|
Names are case-insensitive on Windows. |
Executable search |
|
|
|
Background service |
Windows service via the Service Control Manager |
daemon, systemd unit, launchd job |
A service has its own entry point and no console; see the services page. |
Pitfalls
-
Redirected output and
SetConsoleMode. The call fails on files and pipes. Treat the failure as "not a terminal", never as an error to report. -
Left-over console state. VT modes and code pages set on a shared console stay set after the program exits, which leaves the user’s
cmd.exein an altered state. Save the old values and restore them in a normal exit path and in the control handler. -
Mixing narrow and wide output.
printf,wprintfandWriteConsoleWon the same handle each assume different encodings. Pick one path per stream. -
Spaces in paths. The unquoted-path launch, the trailing-backslash quote, and the
cmd.exemetacharacter rules are three separate bugs behind the same symptom, a wrongargv. -
Exit code 259 and large codes. Exit codes above 255 are legal on Windows but not portable, and 259 is ambiguous.
-
Assuming POSIX shells. Single quotes mean nothing to
cmd.exe;*is not expanded;/starts options in many native Windows tools (dir /w), so a cross-platform flag style (--flag) should be used by CLIs written for this section. -
Elevation. An elevated process runs in a different console session from a non-elevated parent and does not share its console or environment changes. See Security & App Isolation for UAC and execution levels.
What the Official Documentation Does Not Provide
Microsoft documents each API above, but there is no single guide to writing a cross-platform command-line tool for
Windows. In particular, the documentation does not list how each language runtime maps control events onto its own
interrupt model (the runtime documentation linked above is the only source), does not define a safe quoting
function for cmd.exe, does not provide a supported way to identify Windows Terminal (as opposed to detecting
capabilities), and does not state how a ConPTY host should interpret every rewritten sequence. The advice on this page
beyond those API definitions is practical guidance, not a specification, and the Windows-specific code was not run
while writing it.
For the .NET side of installing, packaging and developing on Windows, see Development Environment & Tooling and Globalization & Localization.