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

CONSOLE

main or wmain

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.

WINDOWS

WinMain or wWinMain

Gets no console. A shell such as cmd.exe does not wait for it: the prompt returns immediately.

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;
}

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

SetConsoleTextAttribute (16 colours)

SGR, 16, 256 and 24-bit colour: \x1b[1;31m

Move the cursor

SetConsoleCursorPosition

CSI cursor movement

Clear or scroll

FillConsoleOutputCharacter, ScrollConsoleScreenBuffer

CSI erase and scroll

Alternate screen

no equivalent in the API

\x1b[?1049h and \x1b[?1049l

Mouse and bracketed paste

mouse input records in ReadConsoleInput

mode-based sequences, with ENABLE_VIRTUAL_TERMINAL_INPUT

VT handling is a mode of the console, switched on per handle with SetConsoleMode. These are the flags a CLI uses:

Flag Handle Effect

ENABLE_PROCESSED_OUTPUT

output

Control characters such as backspace, tab, bell, carriage return and line feed are interpreted. Required for VT processing.

ENABLE_VIRTUAL_TERMINAL_PROCESSING

output

The console interprets VT sequences written to the output handle instead of printing them.

DISABLE_NEWLINE_AUTO_RETURN

output

A line feed no longer implies a carriage return; applications that position the cursor themselves usually want this.

ENABLE_VIRTUAL_TERMINAL_INPUT

input

Key presses arrive as VT sequences, as they would on a Unix terminal, instead of as classic key event records.

ENABLE_LINE_INPUT, ENABLE_ECHO_INPUT

input

Line-buffered input and echo. Clearing both gives the equivalent of non-canonical mode with echo off.

ENABLE_PROCESSED_INPUT

input

Ctrl+C is handled by the system (delivered as a control event) instead of being returned as an input character.

ENABLE_WINDOW_INPUT

input

ReadConsoleInput also returns window size change records (the Windows way to learn about a resize).

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;
}

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

SetConsoleOutputCP(CP_UTF8) and SetConsoleCP(CP_UTF8)

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 GetConsoleOutputCP and GetConsoleCP and restore them.

The activeCodePage manifest element set to UTF-8

The process’s ANSI code page, so every A API and the CRT narrow functions use UTF-8

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 with WriteConsoleW, and convert to UTF-8 only when writing to a file or a pipe (as emit above does). This works on every Windows version and is independent of any code page.

  • UTF-8 everything. Set activeCodePage to UTF-8, call SetConsoleOutputCP(CP_UTF8) and SetConsoleCP(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 W APIs; the pitfalls are in the byte-level paths (Console.OutputEncoding in .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");

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.exe builtins, 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.

flowchart TD A["User types: shelf add The Hobbit"] --> S{"Which shell?"} S -->|"cmd.exe"| C["cmd expands percent variables and treats metacharacters as syntax, caret escapes"] S -->|"PowerShell"| P["PowerShell parses the line into values, then builds the string per PSNativeCommandArgumentPassing"] S -->|"Windows Terminal or Explorer"| T["Only forwards keystrokes or a shortcut target to a shell or CreateProcess"] C --> CP["CreateProcess with one command-line string"] P --> CP T --> CP CP --> K["Kernel stores the string in the new process"] K --> R["C runtime startup reads GetCommandLineW"] R --> M{"Entry point?"} M -->|"main"| MA["Splits with MSVC rules, converts to the ANSI code page"] M -->|"wmain"| MW["Splits with MSVC rules, keeps UTF-16"] M -->|"direct call"| W["CommandLineToArgvW splits it, slightly different rules"] MA --> G{"Linked with setargv.obj?"} MW --> G G -->|"yes"| X["Wildcards such as *.txt are expanded by the runtime"] G -->|"no, the default"| N["Wildcards stay literal, the program globs itself"] X --> ARGV["argc and argv"] N --> ARGV W --> ARGV

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. 2n backslashes followed by a quote give n backslashes and the quote toggles quoting; 2n+1 backslashes followed by a quote give n backslashes 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

cmd.exe

%NAME% is expanded (a literal percent in a batch file is %%). The metacharacters &, |, <, >, (, ) and ^ are syntax. The caret ^ is the shell’s escape character (^& for a literal ampersand), and a quote changes whether metacharacters are special, which is a separate mechanism from the program’s own quote rules. The backslash is not an escape for the shell. Batch files add %~dp0-style parameter modifiers and delayed expansion with !.

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 $PSNativeCommandArgumentPassing.

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

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_EVENT

Ctrl+C

Interrupt. Delivered only if the console input has ENABLE_PROCESSED_INPUT and the process has not disabled it.

CTRL_BREAK_EVENT

Ctrl+Break

A second, stronger interrupt that cannot be disabled by ENABLE_PROCESSED_INPUT.

CTRL_CLOSE_EVENT

closing the console window

The program is about to be terminated; there is only a short time to clean up.

CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT

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" */
}

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

signal(SIGINT, ...) handles Ctrl+C and SIGBREAK handles Ctrl+Break; the CRT installs its own control handler for this.

.NET

Console.CancelKeyPress receives Ctrl+C and Ctrl+Break (ConsoleSpecialKey.ControlC and ControlBreak); setting e.Cancel = true keeps the process alive. PosixSignalRegistration offers a cross-platform view of the same events.

Python

signal.signal(signal.SIGINT, ...) works for Ctrl+C; signal.SIGBREAK for Ctrl+Break; signal.CTRL_C_EVENT and signal.CTRL_BREAK_EVENT are what os.kill accepts on Windows.

Node.js

process.on('SIGINT') for Ctrl+C, SIGBREAK for Ctrl+Break, and SIGHUP is generated when the console window is closed. Most other signals are not supported on Windows.

Go

signal.Notify with os.Interrupt receives Ctrl+C and Ctrl+Break.

Rust

The standard library has no signal API. Crates such as Tokio expose Windows control events in a platform-specific module.

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:

  • lpApplicationName and lpCommandLine. Pass the full path of the executable as lpApplicationName and the complete (already quoted) command line as lpCommandLine. If lpApplicationName is NULL, 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, then C:\Program Files\Shelf\shelf.exe). That is the unquoted path vulnerability: an attacker who can plant C:\Program.exe hijacks the launch. Always quote the program path, and pass lpApplicationName. lpCommandLine for CreateProcessW must be a writable buffer.

  • bInheritHandles and handle inheritance. A child inherits only handles that were created inheritable and only if the parent passes TRUE; inheriting everything leaks handles to children, so the safe pattern is a STARTUPINFOEX with a PROC_THREAD_ATTRIBUTE_HANDLE_LIST that names exactly which handles to inherit. This is the equivalent of closing every descriptor except 0, 1 and 2 after fork, or of O_CLOEXEC by 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 with ResumeThread), CREATE_UNICODE_ENVIRONMENT (the environment block is UTF-16) and CREATE_BREAKAWAY_FROM_JOB.

  • Environment and working directory. Pass NULL to inherit; an explicit environment is a block of NAME=value strings 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) and GetExitCodeProcess. An exit code on Windows is a 32-bit DWORD, 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;
}

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. flags can include PSEUDOCONSOLE_INHERIT_CURSOR.

  • ResizePseudoConsole(hPC, size) is the Windows TIOCSWINSZ: 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 a STARTUPINFOEX attribute 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;
}

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 GetConsoleMode succeeds and isatty-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.json file (shell, font, colour scheme, starting directory) and a command-line interface (wt new-tab, wt split-pane, wt -d <dir>, with ; between subcommands) that a shelf tui launcher script can use.

  • Installation with winget install Microsoft.WindowsTerminal or 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.

Long Paths, PATHEXT and Line Endings

Three smaller platform facts regularly bite portable CLIs:

  • Long paths. The classic MAX_PATH limit 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, the longPathAware manifest element shown earlier, plus the LongPathsEnabled registry 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.exe and PowerShell resolve shelf to shelf.exe, shelf.cmd and so on using the list in the PATHEXT environment variable, in order, searching the current directory first in cmd.exe but not in PowerShell. A native program that spawns shelf through CreateProcess does not get that behaviour (only .exe is tried), so portable launchers resolve the full path themselves. A name collision between shelf.exe and shelf.cmd is decided by PATHEXT order, and a writable directory early on PATH is a hijacking vector.

  • Line endings. Windows text files and consoles conventionally use CRLF. The C runtime’s text-mode streams convert \n to CRLF on output and back on input; raw handles (WriteFile) do not, which is why the earlier example writes \r\n explicitly. A CLI that produces data for other tools (JSON, CSV) should write \n consistently 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

CreateProcessW

fork and exec, or posix_spawn

One call, no clone of the parent; takes one command-line string rather than an argument array.

Command-line arguments

GetCommandLineW, then CommandLineToArgvW or the CRT

argv from execve

The program splits the string itself, so callers must quote correctly.

Wait and exit status

WaitForSingleObject, GetExitCodeProcess

waitpid

32-bit exit codes; 259 is reserved for STILL_ACTIVE; no signal-death status.

Force-kill

TerminateProcess

kill(pid, SIGKILL)

No cleanup in the target, same as SIGKILL.

Interrupt (Ctrl+C)

control event CTRL_C_EVENT, SetConsoleCtrlHandler

SIGINT, sigaction

Handler runs on a new thread; delivered to every process on the console.

Ctrl+Break

CTRL_BREAK_EVENT

SIGQUIT (Ctrl-backslash)

Roughly corresponding; Break is not a core dump request.

Window closed or logoff

CTRL_CLOSE_EVENT, CTRL_LOGOFF_EVENT, CTRL_SHUTDOWN_EVENT

SIGHUP, SIGTERM

Short fixed time before the process is terminated.

Send an interrupt to a child

GenerateConsoleCtrlEvent

kill(pid, SIGINT)

Only to the whole console group (Ctrl+C) or to a group made with CREATE_NEW_PROCESS_GROUP (Ctrl+Break).

Kill a process tree

job object with JOB_OBJECT_LIMIT_KILL_ON_JOB_CLOSE

kill(-pgid, ...), sessions, cgroups

The job also enforces CPU and memory limits.

Is stdout a terminal?

GetConsoleMode succeeds

isatty

Also fails on a pipe or file; GetFileType distinguishes them.

Terminal modes

SetConsoleMode flags

termios with tcsetattr

Per handle; there is no single raw mode flag, clear ENABLE_LINE_INPUT, ENABLE_ECHO_INPUT and ENABLE_PROCESSED_INPUT instead.

Window size

GetConsoleScreenBufferInfo

ioctl(TIOCGWINSZ) or tcgetwinsize

Read the srWindow rectangle, not the buffer size.

Resize notification

WINDOW_BUFFER_SIZE_EVENT via ReadConsoleInput

SIGWINCH

An input record, not a signal, so it arrives in the input loop.

Pseudo-terminal

CreatePseudoConsole

posix_openpt, forkpty

The host receives a re-rendered VT stream, not raw bytes.

Pipes

CreatePipe, named pipes

pipe, FIFOs, Unix sockets

Handles, not descriptors; inheritance is explicit.

Handle inheritance

bInheritHandles, handle-list attribute

descriptors inherited unless FD_CLOEXEC

Inherit-everything is the Windows default if enabled; use the handle list.

Environment

GetEnvironmentVariableW, SetEnvironmentVariableW

getenv, setenv

Names are case-insensitive on Windows.

Executable search

SearchPath, PATH and PATHEXT in the shell

execvp, PATH

CreateProcess tries .exe only; the current directory is searched by cmd.exe.

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.exe in 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, wprintf and WriteConsoleW on 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.exe metacharacter rules are three separate bugs behind the same symptom, a wrong argv.

  • 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.