Terminals, TTYs & Pseudo-Terminals

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.

Every interactive command-line program runs inside a stack that most programmers never look at: a terminal emulator that draws characters and turns key presses into bytes, a kernel device that sits between that emulator and the program, and a set of process-group rules that decide who may read the keyboard and who gets Ctrl-C. Almost every confusing terminal behaviour has its cause in that stack: the prompt that needs Enter before it sees a key, the password that is echoed, the shell that is left without echo after a crash, the program that is stopped by SIGTTIN when started with &, the full-screen interface that does not redraw after the window is resized. This page explains the stack layer by layer, shows the POSIX interfaces that control it (termios, pseudo-terminals, sessions, job control, window size) and the Windows equivalents (the console host, Windows Terminal and ConPTY), and finishes with the rule that every terminal program must follow: always restore the terminal state.

The page was written against these sources, current in October 2026:

  • POSIX.1-2024 (IEEE Std 1003.1-2024, Open Group Base Specifications Issue 8). Issue 8 added tcgetwinsize and tcsetwinsize, ptsname_r, the O_TTY_INIT open flag and the SIGWINCH signal, and it renamed the pseudo-terminal halves from master and slave to manager and subsidiary.

  • glibc 2.44 and the Linux man-pages (termios(3), pty(7), credentials(7), signal(7)).

  • Windows 10 version 1809 or later for ConPTY (CreatePseudoConsole); the console documentation on Microsoft Learn, including the change to ClosePseudoConsole in Windows 11 24H2.

The running scenario is the Bookshelf tool. shelf is the command-line client (a one-shot shelf list, an interactive shelf tui catalogue browser) and shelfd is its background daemon. The design rules for such tools (when to prompt, how to detect a terminal, colour and NO_COLOR, exit codes) are covered in the TTY, Colour & Interactivity page and are not repeated here; this page is about what happens underneath. The sibling pages Escape Sequences & Terminal Capabilities, POSIX Processes, Signals & I/O and Windows Console & Processes go deeper on the topics this page only introduces.

Every C example on this page was compiled with Apple clang 21 and run on macOS, inside a pseudo-terminal where a terminal was required, and the outputs shown are the real outputs. The behaviour described for Linux comes from the Linux man-pages and the POSIX specification, not from running the code on Linux. The Windows examples were not compiled while this page was written; they follow the Microsoft Learn documentation they link to.

Terminal emulator, shell and program

Three different things are usually called "the terminal". Keeping them apart explains most of what follows.

Layer What it is What it does

Terminal emulator

A graphical (or remote) program: Terminal.app, iTerm2, GNOME Terminal, Windows Terminal, the VS Code panel, tmux, an ssh client.

Draws a grid of character cells, turns key presses into bytes (for example j, or ESC followed by [A for the Up arrow), interprets the escape sequences the program writes (colours, cursor movement), and reports its size.

Kernel TTY and line discipline

A device pair in the kernel: a pseudo-terminal master and slave on Linux, macOS and the BSDs.

Moves bytes between the emulator and the program. In between it optionally edits the line, echoes typed characters, translates carriage returns and newlines, and turns Ctrl-C, Ctrl-Z and Ctrl-Backslash into signals.

Shell

An ordinary program: bash, zsh, fish, PowerShell.

Reads commands from the terminal, starts other programs, and manages jobs (process groups) so that the right program receives the keyboard.

Program

shelf, less, vim: any process whose standard input and output are the terminal.

Reads bytes, writes bytes. It does not know which emulator it is talking to.

The word TTY is historical: it comes from the teletypewriter, the physical device a UNIX system was once driven from. A modern system has few real serial terminals but the kernel still exposes every terminal, real or virtual, through the same interface, and isatty(fd) is still the question a program asks to learn whether a file descriptor is one of them. A pseudo-terminal (PTY) is the kernel’s software-only terminal: the other end of the "wire" is another process, not a keyboard and a screen. Every terminal emulator, every ssh session, every tmux pane and every CI log that captures colour output is built on one.

The practical consequence is that a program never talks to a screen. It reads and writes a byte stream, and the bytes that mean "move the cursor" or "make this text red" are interpreted by the emulator at the far end. That is why a full-screen program must ask the emulator for its size and why the same shelf tui binary works unchanged in a local window, over ssh and inside tmux.

The kernel TTY and its line discipline

Between the program and the emulator sits the line discipline, a layer of the kernel’s TTY driver that processes every byte in both directions according to a termios structure attached to the terminal. The figure shows where it sits, with the Windows arrangement on the right for comparison.

The terminal stack: an emulator talks to a PTY master, the line discipline sits between master and slave, and a session with a foreground process group is attached to the slave. Windows uses a terminal, two pipes, conhost and a console client.

Following one key press through the stack makes the layers concrete. The diagram shows both the default (canonical) mode, where the line discipline edits the line and holds it until Enter, and the character-at-a-time mode a full-screen program uses.

sequenceDiagram actor User participant Emu as Terminal emulator participant Master as PTY master participant LD as Line discipline participant Prog as shelf tui (foreground group) User->>Emu: presses j Emu->>Master: write one byte, 0x6A Master->>LD: input processing (ICRNL, IGNCR, ISIG check) alt ICANON set (canonical mode) LD-->>Master: echo the byte if ECHO is set Note over LD,Prog: byte stays in the line buffer until Enter, read() stays blocked else ICANON clear (cbreak or raw) LD->>Prog: read() returns the byte at once (VMIN = 1) end Prog->>LD: write VT text for the new screen LD->>Master: output processing (OPOST, ONLCR) Master->>Emu: read the bytes Emu->>User: draws the glyphs User->>Emu: presses Ctrl-C Emu->>Master: write byte 0x03 Master->>LD: byte matches VINTR and ISIG is set LD-->>Prog: SIGINT to the foreground process group

Three details of the diagram matter in practice. First, echo is done by the line discipline, not by the program: the terminal, not shelf, prints the characters of a typed line, which is why echo is switched off with termios when a program wants to read a password. Second, output is processed too: with the default OPOST and ONLCR settings every newline written by the program becomes carriage return plus newline on its way to the emulator (the real output of the compiled examples below shows \r\n for that reason). Third, Ctrl-C is not a character the program reads: when ISIG is set the line discipline consumes it and sends SIGINT to the foreground process group.

Canonical and non-canonical mode

The termios structure from <termios.h> holds the whole configuration of the line discipline. POSIX.1-2024 specifies it in chapter 11 (General Terminal Interface) of the Base Definitions; termios(3) on Linux documents the Linux behaviour.

Member Controls

c_iflag

Input processing: ICRNL (carriage return to newline), INLCR, IGNCR, IXON and IXOFF (Ctrl-S and Ctrl-Q flow control), BRKINT, ISTRIP, PARMRK.

c_oflag

Output processing: OPOST (master switch), ONLCR (newline to carriage return plus newline) and the delay flags.

c_cflag

Hardware line settings: CSIZE, PARENB, CLOCAL, baud rate. Mostly irrelevant for a PTY.

c_lflag

Local modes: ICANON, ECHO and its variants (ECHOE, ECHOK, ECHONL, ECHOCTL), ISIG, IEXTEN, TOSTOP, NOFLSH.

c_cc

The special characters (VINTR, VQUIT, VSUSP, VEOF, VERASE, VKILL and so on) and the two read parameters VMIN and VTIME.

The most important flag is ICANON. In canonical mode (also called cooked mode, the default) the line discipline collects input into lines. A read returns only after the user presses Enter (or Ctrl-D, the end-of-file character), and until then the user can edit the line with Backspace, Ctrl-W (erase a word) and Ctrl-U (erase the line). That editing happens in the kernel, with no help from the program, and is why a simple program can read a line with fgets and behave sensibly. In non-canonical mode no editing happens and read returns as soon as the amount of data described by VMIN and VTIME is available.

Name ICANON ISIG Used for

Cooked (default)

set

set

Line-oriented programs: shells at the prompt, fgets, read in a script.

Cbreak

clear

set

One key at a time but Ctrl-C and Ctrl-Z still work: "press y to confirm", pagers, menus.

Raw

clear

clear

Full control: Ctrl-C arrives as the byte 0x03. Full-screen TUIs, editors, terminal-in-terminal programs. Also input and output processing off.

"Cbreak" and "raw" are not termios terms; they are the traditional names of two useful combinations, and a library’s raw mode (crossterm’s enable_raw_mode, Go’s x/term.MakeRaw, ncurses' raw() and cbreak()) is one of these two. The VMIN and VTIME members decide when a non-canonical read returns.

VMIN VTIME Behaviour of read

0

0

Returns immediately with whatever is available, possibly nothing (polling).

greater than 0

0

Blocks until at least VMIN bytes are available. VMIN equal to 1 is the usual "one key" setting.

0

greater than 0

Blocks for up to VTIME tenths of a second and returns what arrived, possibly nothing. Useful to tell a lone ESC key from the start of an escape sequence.

greater than 0

greater than 0

An inter-byte timer: after the first byte arrives, read returns when VMIN bytes have arrived or VTIME tenths of a second pass without a new byte.

Settings are read with tcgetattr(fd, &t) and applied with tcsetattr(fd, when, &t). The when argument selects the moment of the change: TCSANOW immediately, TCSADRAIN after all pending output has been transmitted (use this when changing output parameters), and TCSAFLUSH after the output has drained and discarding unread input. tcsetattr returns success if any of the requested changes was made, so a careful program calls tcgetattr again and compares when it depends on a specific setting. The non-standard cfmakeraw(&t) (BSD origin, available on Linux and macOS, not in POSIX.1-2024) clears the input, output, echo, canonical and signal flags and selects 8-bit characters in one call, but it does not set VMIN and VTIME, so set them explicitly.

Special characters

The line discipline recognises a few control characters, each one configurable through c_cc. The defaults below are the conventional ones.

Key c_cc index Needs Effect

Ctrl-C

VINTR

ISIG

SIGINT to the foreground process group.

Ctrl-Backslash

VQUIT

ISIG

SIGQUIT to the foreground process group (terminates and dumps core by default).

Ctrl-Z

VSUSP

ISIG

SIGTSTP to the foreground process group: the job is stopped.

Ctrl-D

VEOF

ICANON

Makes a pending read return what has been typed; on an empty line read returns 0, which programs treat as end of file.

Backspace (0x7F, or Ctrl-H)

VERASE

ICANON

Erases the previous character.

Ctrl-U

VKILL

ICANON

Erases the whole line.

Ctrl-W

VWERASE

ICANON and IEXTEN

Erases the previous word (an extension in most implementations).

Ctrl-V

VLNEXT

ICANON and IEXTEN

Takes the next character literally, so a user can type a raw escape.

Ctrl-S and Ctrl-Q

VSTOP and VSTART

IXON

Software flow control: Ctrl-S freezes the output until Ctrl-Q, a classic cause of "my terminal hangs".

A program that wants to treat Ctrl-C as an ordinary key must clear ISIG (and usually IXON, IEXTEN and ICRNL too, so that Ctrl-S, Ctrl-V and Ctrl-M arrive unchanged). A program that keeps ISIG set must be ready for SIGINT, SIGTSTP and SIGQUIT while the terminal is in the modified state, which is the subject of the next example.

Reading one key, and always restoring the terminal

The rule that governs every termios program is simple: the terminal belongs to the user’s shell, not to your program, so whatever state you change you must put back on every way out. A program that dies in raw mode leaves the user’s shell without echo and without line editing, and the user has to type reset blind. The example reads a single key for a shelf return confirmation in cbreak mode and restores the terminal on normal exit, on exit() and on the fatal signals it can catch.

// shelf-pick.c: ask "Return Dune? [y/n]" and read one key without Enter.
#include <errno.h>
#include <signal.h>
#include <stdio.h>
#include <stdlib.h>
#include <termios.h>
#include <unistd.h>

static struct termios saved;
static int have_saved = 0;

static void restore(void) {
    if (have_saved) {
        tcsetattr(STDIN_FILENO, TCSAFLUSH, &saved);
        have_saved = 0;
    }
}

static void on_fatal(int sig) {
    restore();                   /* tcsetattr is async-signal-safe */
    signal(sig, SIG_DFL);        /* then die with the original signal */
    raise(sig);
}

static int enter_cbreak(void) {
    if (!isatty(STDIN_FILENO)) {
        errno = ENOTTY;
        return -1;
    }
    if (tcgetattr(STDIN_FILENO, &saved) == -1) return -1;
    have_saved = 1;
    atexit(restore);

    struct sigaction sa = { .sa_handler = on_fatal };
    sigemptyset(&sa.sa_mask);
    sigaction(SIGINT, &sa, NULL);
    sigaction(SIGTERM, &sa, NULL);

    struct termios t = saved;
    t.c_lflag &= ~(ICANON | ECHO);   /* cbreak: keep ISIG so Ctrl-C still works */
    t.c_cc[VMIN] = 1;                /* block until one byte ... */
    t.c_cc[VTIME] = 0;               /* ... with no timeout */
    return tcsetattr(STDIN_FILENO, TCSAFLUSH, &t);
}

int main(void) {
    if (enter_cbreak() == -1) {
        perror("shelf: stdin is not a terminal");
        return 1;
    }
    fputs("Return \"Dune\"? [y/n] ", stdout);
    fflush(stdout);
    unsigned char c;
    if (read(STDIN_FILENO, &c, 1) != 1) return 1;
    printf("%c\n", c);
    return c == 'y' ? 0 : 2;
}

The program compiles with clang -Wall -Wextra without warnings. Run with a pipe instead of a terminal it refuses cleanly, and run inside a pseudo-terminal it behaves as designed:

$ echo y | ./shelf-pick
shelf: stdin is not a terminal: Inappropriate ioctl for device
$ echo $?
1

Driven through a pseudo-terminal with the byte y written to the master, the program printed Return "Dune"? [y/n] y followed by a carriage return and newline and exited with status 0. Driven with the byte 0x03 (Ctrl-C), it restored the terminal in on_fatal and then terminated by SIGINT, so the parent saw the same exit status as for an unhandled interrupt: the signal handler re-raises with the default disposition instead of calling exit.

The design points of that example are the checklist for every terminal-modifying program:

  • Check isatty first and refuse, or fall back to a non-interactive path, when the descriptor is not a terminal.

  • Save the original settings before the first change, and restore those settings, not a guess at the defaults.

  • Restore on every exit path: normal return, exit() (via atexit), and the signals you can catch (SIGINT, SIGTERM, SIGHUP, SIGQUIT). SIGKILL cannot be caught, so a program killed that way cannot restore anything.

  • Keep the handlers minimal and async-signal-safe: tcsetattr, signal and raise may be called from a handler, printf and malloc may not.

  • Pick TCSAFLUSH when entering and leaving the mode deliberately: it discards typed-ahead input, which for a password prompt is what you want and for a pipeline of keystrokes may not be.

  • Handle the stop signals too (see job control below), or Ctrl-Z in raw mode hands the shell a terminal without echo.

Libraries do this for you, and the page on TUI Architecture & Concepts shows how Ratatui, Bubble Tea and ncurses install their restore hooks; the pitfalls are the same whatever the language.

Pseudo-terminals

A pseudo-terminal is a pair of connected character devices. The pty(7) page describes the contract: the slave end behaves exactly like a classical terminal, and anything written to the master end is delivered to the program on the slave end as though a person had typed it, while anything the program writes to the slave can be read from the master. Writing the interrupt character to the master generates SIGINT for the foreground process group of the slave, because the line discipline sits in between. Whoever holds the master plays the role of the terminal emulator. POSIX.1-2024 calls the two ends the manager and the subsidiary; the older master and slave remain in the Linux man-pages, in most existing code and in this page.

Function Header and standard Purpose

posix_openpt(flags)

<stdlib.h> and <fcntl.h>, POSIX

Opens an unused master. Pass O_RDWR and normally O_NOCTTY so the caller does not acquire it as its controlling terminal. On Linux this opens /dev/ptmx.

grantpt(fd)

<stdlib.h>, POSIX

Sets the ownership and permissions of the slave device so that the user can open it.

unlockpt(fd)

<stdlib.h>, POSIX

Unlocks the slave so that it can be opened. It must be called before opening the slave.

ptsname(fd) and ptsname_r(fd, buf, n)

<stdlib.h>, POSIX (ptsname_r is new in Issue 8)

Returns the path of the slave: /dev/pts/N on Linux, /dev/ttysNNN on macOS. ptsname may use a static buffer and need not be thread-safe, so a multi-threaded program uses ptsname_r where it is declared.

openpty and forkpty

<pty.h> (Linux, link with -lutil on older glibc), <util.h> (macOS, OpenBSD), <libutil.h> (FreeBSD). Not POSIX.

Convenience wrappers that open the pair and, for forkpty, fork a child whose standard descriptors are the slave and which is the leader of a new session with the slave as its controlling terminal.

tcgetattr, tcsetattr, tcgetwinsize, tcsetwinsize

<termios.h>, POSIX

Work on either end; configuring the slave’s termios before the child starts is how a host decides echo, canonical mode and window size.

The ptsname_r function is declared by the macOS SDK used for this page only when no strict feature-test macro (such as _XOPEN_SOURCE) is defined, so portable code either avoids threads around ptsname or checks at build time.

Linux also offers the older BSD-style pseudo-terminals (/dev/ptyXY and /dev/ttyXY), deprecated since Linux 2.6.4; new code uses the UNIX 98 interface above. The number of UNIX 98 pairs is limited and adjustable through /proc/sys/kernel/pty/max, with /proc/sys/kernel/pty/nr showing the number in use, which matters for a test suite that creates thousands of terminals.

Hosting a program in a PTY

The example is a miniature terminal host. It opens a pair, starts a command whose standard descriptors are the slave in a new session, copies what the command writes to its own standard output and then resizes the window, which makes the kernel send SIGWINCH to the child. It is the shape of every terminal emulator, of script(1), of ssh on the server side and of every test harness that needs a program to believe it has a terminal.

// shelf-pty.c: run a command on a new pseudo-terminal, print what it writes,
// then resize the window. Usage: shelf-pty command [args...]
#define _XOPEN_SOURCE 700
#include <errno.h>
#include <fcntl.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <termios.h>
#include <unistd.h>

int main(int argc, char **argv) {
    if (argc < 2) {
        fprintf(stderr, "usage: %s command [args...]\n", argv[0]);
        return 64;
    }

    int master = posix_openpt(O_RDWR | O_NOCTTY);          /* 1. master side */
    if (master == -1 || grantpt(master) == -1 || unlockpt(master) == -1) {
        perror("posix_openpt");
        return 1;
    }
    const char *slave_name = ptsname(master);              /* 2. slave path */
    if (slave_name == NULL) {
        perror("ptsname");
        return 1;
    }

    struct winsize ws = { .ws_row = 24, .ws_col = 80 };    /* initial size */

    pid_t pid = fork();
    if (pid == -1) {
        perror("fork");
        return 1;
    }
    if (pid == 0) {
        close(master);
        setsid();                                          /* new session, no controlling terminal */
        int slave = open(slave_name, O_RDWR);              /* a session leader's first open may
                                                              already make it the controlling tty */
#ifdef TIOCSCTTY
        ioctl(slave, TIOCSCTTY, 0);                        /* BSD and macOS make it explicit; harmless
                                                              if the open already did it */
#endif
        ioctl(slave, TIOCSWINSZ, &ws);                     /* macOS refuses this on the master
                                                              until the slave has been opened */
        dup2(slave, STDIN_FILENO);
        dup2(slave, STDOUT_FILENO);
        dup2(slave, STDERR_FILENO);
        if (slave > STDERR_FILENO) close(slave);
        execvp(argv[1], &argv[1]);
        perror("execvp");
        _exit(127);
    }

    char buf[256];
    ssize_t n;
    int resized = 0;
    while ((n = read(master, buf, sizeof buf)) > 0) {      /* EIO when the child exits (Linux) */
        fwrite(buf, 1, (size_t)n, stdout);
        if (!resized) {                                    /* after the first output */
            resized = 1;
            ws.ws_row = 40;
            ws.ws_col = 120;
            ioctl(master, TIOCSWINSZ, &ws);                /* kernel sends SIGWINCH */
        }
    }
    int status = 0;
    waitpid(pid, &status, 0);
    return WIFEXITED(status) ? WEXITSTATUS(status) : 128 + WTERMSIG(status);
}

Combined with the shelf-size program from the window-size section below, which prints its size at start and after each SIGWINCH, the host produced exactly the expected result on macOS:

$ ./shelf-pty ./shelf-size
shelf tui: 24 rows x 80 columns
shelf tui: 40 rows x 120 columns
$ ./shelf-pty sh -c 'tty; stty -a | head -1'
/dev/ttys013
speed 9600 baud; 40 rows; 120 columns;

The short form with the non-POSIX helper replaces the whole block between posix_openpt and execvp:

// shelf-forkpty.c: forkpty does openpty + fork + login_tty in one call.
#include <stdio.h>
#include <sys/ioctl.h>
#include <sys/wait.h>
#include <unistd.h>
#if defined(__APPLE__) || defined(__OpenBSD__)
#  include <util.h>
#elif defined(__FreeBSD__)
#  include <libutil.h>
#else
#  include <pty.h>         /* Linux: link with -lutil on older glibc */
#endif

int main(void) {
    int master;
    struct winsize ws = { .ws_row = 24, .ws_col = 80 };
    pid_t pid = forkpty(&master, NULL, NULL, &ws);   /* NULL name, NULL termios: inherit defaults */
    if (pid == -1) {
        perror("forkpty");
        return 1;
    }
    if (pid == 0) {                                  /* child: stdin/out/err are the slave */
        execlp("tty", "tty", (char *)NULL);
        _exit(127);
    }
    char buf[128];
    ssize_t n = read(master, buf, sizeof buf - 1);
    if (n > 0) {
        buf[n] = '\0';
        printf("child says: %s", buf);
    }
    int status;
    waitpid(pid, &status, 0);
    return 0;
}

On macOS it printed child says: /dev/ttys013. The <pty.h>, <libutil.h> and -lutil details for Linux and FreeBSD come from the documentation of those systems; that branch of the example was not compiled here.

Source: pty(7)

PTY pitfalls and security notes

  • Echo is on by default. A new slave starts with the usual cooked settings, so input written to the master is echoed back to the master and newlines come out as carriage return plus newline. A host that forwards input from another terminal usually puts the slave in raw mode, or lets the program inside decide.

  • End of output. When the last descriptor of the slave closes, a read on the master fails with EIO on Linux; other systems may return 0. Treat both as "the child has gone", then call waitpid.

  • Do not leak the master. Open it with O_CLOEXEC (or call fcntl) where the platform allows, or close it in the child as above; an inherited master keeps the session alive and can let the child impersonate the terminal.

  • Deadlock. A host that writes a lot to the master without reading what the child writes can block both processes, because each side’s buffer fills. Use poll or a separate thread for each direction.

  • poll on /dev/tty fails on macOS. A program that opened /dev/tty and called poll on the descriptor, on the macOS used for this page, got POLLNVAL immediately, whereas poll on the inherited standard input (the slave) worked and reported the typed byte. A program that wants to wait for input on the controlling terminal should therefore poll the descriptor it inherited when it is a terminal, and test input handling on every platform it ships on.

  • Setting the size on the master can fail on macOS. A TIOCSWINSZ on the master failed with ENOTTY until the slave had been opened, which is why the example sets the initial size through the slave in the child. The same call on the master worked later, after the slave was open, and is how the host resizes the window. Other systems may behave differently, and the slave-side call is the safe choice.

  • Faking input. The TIOCSTI ioctl pushes bytes into a terminal’s input queue as if they had been typed; the Linux man-pages list it under "Faking input". A program that runs untrusted commands should not share its terminal with them, which is why sandboxes allocate a fresh PTY (and a new session) for the child instead of passing the real one.

  • Permissions. grantpt exists so that the slave is owned by the user who opened the master; never chmod a slave to world-readable, because anyone who can read it can read the session.

Sessions, process groups and the controlling terminal

A terminal is attached to a session, and the kernel decides who may use it through the session’s process groups. The Linux credentials(7) page gives the definitions in two paragraphs:

  • A process group is a collection of processes with the same process group ID. A shell puts the processes of one command or pipeline in one group, so shelf list | less is one group of two processes, the job. setpgid sets a process’s group.

  • A session is a collection of process groups, created by setsid. The creator is the session leader and the session ID is its process ID.

  • All processes of a session share a controlling terminal, established when the session leader first opens a terminal without O_NOCTTY (or, on BSD and macOS, with the TIOCSCTTY ioctl). A terminal can be the controlling terminal of at most one session.

  • At most one process group of the session is the foreground group. tcsetpgrp selects it, tcgetpgrp reads it. The others are background groups.

Function Purpose

setsid()

Creates a new session and a new process group with the caller as leader. Fails if the caller is already a group leader. Also detaches from any controlling terminal, which is the first step of daemonising shelfd.

setpgid(pid, pgid)

Moves a process to a group (or creates one with pgid equal to pid). Shells call it in both the parent and the child to avoid a race.

getpgrp(), getsid(pid), tcgetsid(fd)

Read the group ID, the session ID and the session of a terminal.

tcgetpgrp(fd), tcsetpgrp(fd, pgid)

Read and set the foreground process group of the terminal on fd.

open("/dev/tty", ...)

Opens the calling process’s controlling terminal, even when standard input and output are redirected. This is how ssh, sudo and git read a password while their standard input is a pipe.

When the user types shelf tui at the prompt, the shell forks, puts the child in a new process group, makes that group the foreground group of the terminal and waits. When the program ends or stops, the shell takes the foreground back. shelf tui & puts the group in the background instead. Nothing in this is visible to the program itself, but its consequences are: which signals it receives, and whether it may touch the terminal at all.

Job control

Job control is the set of rules that lets a user move jobs between foreground and background, stop them and resume them. The terminal driver and the shell cooperate through these signals, documented in signal(7) and, for the terminal rules, in termios(3).

Signal Generated by Effect

SIGINT

Ctrl-C (VINTR, with ISIG), to the foreground group

Terminates by default. A program may handle it to clean up.

SIGQUIT

Ctrl-Backslash (VQUIT), to the foreground group

Terminates and dumps core by default.

SIGTSTP

Ctrl-Z (VSUSP), to the foreground group

Stops the job by default. It can be caught or ignored, unlike SIGSTOP.

SIGSTOP

kill, a debugger

Stops the process; cannot be caught or ignored.

SIGCONT

The shell’s fg or bg, or kill -CONT

Resumes a stopped process. A handler is run even though the default action also resumes.

SIGTTIN

A background process reading the controlling terminal

Stops the whole background group.

SIGTTOU

A background process writing with TOSTOP set, or changing terminal settings (tcsetattr, tcsetpgrp) from the background

Stops the group. The terminal-setting case applies even without TOSTOP, unless the signal is blocked or ignored.

SIGHUP

The controlling terminal closing (window closed, ssh dropped)

Sent to the controlling process (normally the shell, the session leader), which typically passes it on to its jobs; terminates by default. Long-running tools that should survive logout ignore it or are started with nohup.

SIGWINCH

The terminal’s window size changing

Ignored by default; see below.

By default a background process may write to the terminal (output of shelf list & appears over the prompt) unless the TOSTOP flag is set in c_lflag, but it may never read it: the read stops the group with SIGTTIN. An orphaned process group, one whose parent has exited so that no process outside the group in the same session can restart it, is treated differently: stop signals could never be resumed by anyone, so a read from the terminal fails with EIO (as does a write when TOSTOP is set) instead of stopping the group. This is why a daemon that was not properly detached can fail mysteriously when it tries to prompt.

Is my program in the foreground?

A program that prompts must be able to tell whether it may use the terminal. The check is a comparison of two group IDs, and the example uses it to read an API token without echo from the controlling terminal even when standard input is a pipe.

// shelf-login.c: read an API token from /dev/tty without echoing it.
#include <fcntl.h>
#include <stdio.h>
#include <string.h>
#include <termios.h>
#include <unistd.h>

/* True when this process may read the terminal without being stopped by SIGTTIN. */
static int in_foreground(int fd) {
    pid_t fg = tcgetpgrp(fd);
    return fg != -1 && fg == getpgrp();
}

int main(void) {
    int tty = open("/dev/tty", O_RDWR | O_NOCTTY);   /* the controlling terminal, even if stdin is a pipe */
    if (tty == -1) {
        perror("shelf login: no controlling terminal; use --token-file");
        return 1;
    }
    if (!in_foreground(tty)) {
        fputs("shelf login: running in the background, cannot prompt\n", stderr);
        return 1;
    }

    struct termios saved, quiet;
    if (tcgetattr(tty, &saved) == -1) {
        perror("tcgetattr");
        return 1;
    }
    quiet = saved;
    quiet.c_lflag &= ~(tcflag_t)ECHO;                /* keep ICANON: the line editor still works */
    quiet.c_lflag |= ECHONL;                         /* but echo the newline the user types */

    dprintf(tty, "shelfd token: ");
    if (tcsetattr(tty, TCSAFLUSH, &quiet) == -1) {
        perror("tcsetattr");
        return 1;
    }
    char token[256];
    ssize_t n = read(tty, token, sizeof token - 1);
    tcsetattr(tty, TCSAFLUSH, &saved);               /* restore before doing anything else */
    if (n <= 0) return 1;
    token[n] = '\0';
    token[strcspn(token, "\n")] = '\0';
    printf("read %zu characters\n", strlen(token));
    return 0;
}

In a pseudo-terminal, typing s3cret and Enter produced the prompt shelfd token: followed by a bare newline and read 6 characters: the secret was not echoed. The program compiles without warnings. A caveat of the check: the foreground group can change between tcgetpgrp and the read (the user can press Ctrl-Z), so the check is a courtesy to produce a good error message, not a guarantee; a robust program also copes with being stopped.

Cooperating with Ctrl-Z

A program in cbreak or raw mode with ISIG set must handle SIGTSTP. Without a handler it is stopped by the default action while the terminal is still raw, so the shell prompt comes back without echo. The standard pattern is: in the handler restore the terminal, reset the disposition to the default and re-raise; in the SIGCONT handler set the terminal up again and request a redraw, because other programs have used the screen in the meantime.

// shelf-suspend.c: a raw-mode program that cooperates with Ctrl-Z.
#include <signal.h>
#include <stdio.h>
#include <termios.h>
#include <unistd.h>

static struct termios cooked, raw;
static volatile sig_atomic_t redraw_needed = 1;

static void on_tstp(int sig) {
    (void)sig;
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &cooked);   /* give the shell a sane terminal */
    signal(SIGTSTP, SIG_DFL);                      /* next SIGTSTP stops us for real */
    raise(SIGTSTP);                                /* delivered when the handler returns ... */
}                                                  /* ... because SIGTSTP is blocked inside it */

static void on_cont(int sig) {
    (void)sig;
    signal(SIGTSTP, on_tstp);                      /* re-arm */
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);      /* back to raw mode */
    redraw_needed = 1;                             /* the screen may have been overwritten */
}

int main(void) {
    if (tcgetattr(STDIN_FILENO, &cooked) == -1) return 1;
    raw = cooked;
    raw.c_lflag &= (tcflag_t)~(ICANON | ECHO);     /* keep ISIG: Ctrl-Z must still generate SIGTSTP */
    raw.c_cc[VMIN] = 1;
    raw.c_cc[VTIME] = 0;

    struct sigaction sa = { .sa_handler = on_tstp };
    sigemptyset(&sa.sa_mask);
    sigaction(SIGTSTP, &sa, NULL);
    sa.sa_handler = on_cont;
    sigaction(SIGCONT, &sa, NULL);

    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
    unsigned char c;
    for (;;) {
        if (redraw_needed) {
            redraw_needed = 0;
            dprintf(STDOUT_FILENO, "[redraw] press q to quit\n");
        }
        ssize_t n = read(STDIN_FILENO, &c, 1);
        if (n == -1) continue;                     /* EINTR after a signal */
        if (n == 0 || c == 'q') break;
    }
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &cooked);
    return 0;
}

Run under an interactive bash inside a pseudo-terminal, the sequence was: start the program, send Ctrl-Z, run stty -a at the shell prompt, then fg. The shell printed Stopped ./shelf-suspend, stty reported icanon and echo (the terminal had been restored while the program was stopped), and after fg the program printed its redraw line again and exited normally on q. The program must keep the original settings in static storage because the handlers have no other way to reach them; the SIGTSTP mask inside the handler is what makes the re-raise take effect only after the handler returns.

Window size and SIGWINCH

A terminal has a size in character cells and sometimes in pixels, stored in a struct winsize with the fields ws_row, ws_col, ws_xpixel and ws_ypixel (the pixel fields are often zero). Historically the size is read with the TIOCGWINSZ ioctl and set with TIOCSWINSZ, which every UNIX-like system provides but POSIX did not specify. POSIX.1-2024 standardises the same operation as tcgetwinsize(fd, &ws) and tcsetwinsize(fd, &ws) in <termios.h>, returning 0 on success and -1 with EBADF or ENOTTY on failure. It also added the SIGWINCH signal: tcsetwinsize sends it to the foreground process group when the size actually changes, and an emulator that resizes its window does the same through the master. The default action is to ignore the signal, so a program receives it only if it installs a handler.

On the macOS SDK used for this page tcgetwinsize is not declared (a call fails to compile), while TIOCGWINSZ works. The portable choice today is therefore the ioctl, with tcgetwinsize as the future-proof spelling on systems that have it.

// shelf-size.c: report the terminal size at start and after every SIGWINCH.
#include <signal.h>
#include <stdio.h>
#include <sys/ioctl.h>
#include <unistd.h>

static volatile sig_atomic_t resized = 1;           /* draw once at start */
static void on_winch(int sig) { (void)sig; resized = 1; }

/* TIOCGWINSZ works on Linux, macOS and the BSDs. POSIX.1-2024 standardises
   the same operation as tcgetwinsize(fd, &ws) in <termios.h>. */
static int get_size(int fd, struct winsize *ws) {
    return ioctl(fd, TIOCGWINSZ, ws);
}

int main(void) {
    struct sigaction sa = { .sa_handler = on_winch };   /* no SA_RESTART: pause() must return */
    sigemptyset(&sa.sa_mask);
    sigaction(SIGWINCH, &sa, NULL);

    for (int draws = 0; draws < 2;) {
        if (!resized) {
            pause();
            continue;
        }
        resized = 0;
        struct winsize ws;
        if (get_size(STDOUT_FILENO, &ws) == 0 && ws.ws_row > 0 && ws.ws_col > 0)
            printf("shelf tui: %hu rows x %hu columns\n", ws.ws_row, ws.ws_col);
        else
            puts("shelf tui: size unknown, assuming 24 x 80");
        fflush(stdout);
        draws++;
    }
    return 0;
}

Run through the shelf-pty host above it printed the two lines shown there. Run with its output redirected to a file it printed size unknown, assuming 24 x 80, because ioctl on a non-terminal fails with ENOTTY.

The size pitfalls are worth listing because every TUI library has hit them:

  • Do the work outside the handler. The example only sets a flag. The POSIX application usage notes call the idiom of filling a global winsize structure from inside the handler "common but incorrect", because the structure is not accessed atomically. They recommend either reading into a local structure and copying the members into volatile sig_atomic_t variables (blocking SIGWINCH while the main code reads them), or, simpler, setting a flag in the handler and calling tcgetwinsize later from the main loop, as the example does. Multi-threaded programs should wait for the signal with sigwait instead of installing a handler. A handler must never redraw a screen; a self-pipe that wakes the event loop is the usual way to turn the signal into an event.

  • Background processes are not notified. SIGWINCH goes only to the foreground process group. POSIX advises reading the size again when SIGCONT arrives after a stop, and polling the size periodically in a background process that writes to the terminal without TOSTOP.

  • Signals coalesce. Several resizes can produce one SIGWINCH; always read the current size, never count signals.

  • The size can be zero or unknown. A terminal that never reported its size (a serial line, a fresh pseudo-terminal that nobody sized, some CI log capture) returns 0 columns and rows. POSIX says the size of a terminal opened without O_TTY_INIT that nobody set is unspecified. Fall back to the LINES and COLUMNS environment variables if set, and then to 24 by 80.

  • Read the size of the terminal you draw on. With standard output redirected, ask /dev/tty or standard error instead of standard output.

  • Interrupted system calls. A handler installed without SA_RESTART makes a blocking read return EINTR, which an event loop must treat as "check the flag", not as an error.

  • Multiplexers and ssh. tmux and ssh forward the new size to the inner pseudo-terminal, which raises SIGWINCH in the program inside, so the code path is the same everywhere. Terminals can also be asked for their size with escape sequences, which is covered on the Escape Sequences & Terminal Capabilities page.

Windows: console host, Windows Terminal and ConPTY

Windows does not have a kernel TTY layer or a line discipline. The classic design lets a program talk to a console through the Console API (functions with "Console" in the name in kernel32.dll), and the console is served by conhost.exe, the console host, which also draws the window. The Microsoft documentation of the console ecosystem describes four roles: the client (the command-line program), the device (the console driver), the server (which interprets Console API calls) and the terminal (the final layer, drawing output and capturing input). On other platforms the server and terminal roles are one unit because there is no translation layer; on Windows they were historically bundled inside conhost.exe, and the pseudoconsole (ConPTY) split them.

Component Role

conhost.exe (open-source form: OpenConsole)

The Windows Console Host. Traditionally server and terminal together, drawing its own window. In pseudoconsole mode it is server-only: it serves the Console API for the attached program and translates it to and from virtual-terminal sequences for a hosting terminal.

Windows Terminal

A modern terminal application and the first-party example of a terminal that hosts console programs through the pseudoconsole, "much like all non-Windows platforms". It takes the terminal role only.

ConPTY (CreatePseudoConsole)

The Windows pseudo-terminal: the hosting application supplies two pipes, and the console host exchanges virtual-terminal text with it. Available from Windows 10 version 1809.

Classic Console API

GetConsoleMode, SetConsoleMode, ReadConsoleInput, WriteConsoleOutput and friends. Microsoft recommends virtual-terminal sequences for all new development but keeps a subset of the classic API.

The Microsoft guidance, in the classic versus virtual terminal article, is to replace the classic Console API with virtual-terminal sequences, because the sequences work across platforms, travel over any pipe or socket for remote access, and do not let a program reach into the window. A program that follows it writes the same escape sequences as on Linux and macOS (see the escape-sequences page) and needs only to switch the mode on.

Console modes: the Windows line discipline

The role of the termios flags is played by the console modes, set with SetConsoleMode on the input or the screen buffer handle and read with GetConsoleMode. The table maps the concepts of this page to Windows.

POSIX Windows Notes

isatty(fd)

GetConsoleMode succeeds on the handle

A failing call means the handle is a file or pipe; the full detection recipe is on the TTY page linked above.

tcgetattr and tcsetattr

GetConsoleMode and SetConsoleMode

Separate calls for the input handle and for the output handle.

ICANON

ENABLE_LINE_INPUT

ReadFile and ReadConsole return only after Enter when set; when clear they return once one or more characters are available.

ECHO

ENABLE_ECHO_INPUT

Valid only while ENABLE_LINE_INPUT is also set.

ISIG for Ctrl-C

ENABLE_PROCESSED_INPUT

Set by default: Ctrl-C is handled by the system and not placed in the input buffer. Clear it to read Ctrl-C as a key.

Raw VT input

ENABLE_VIRTUAL_TERMINAL_INPUT

Turns key presses into virtual-terminal sequences readable through ReadFile, matching what a Unix program sees.

Output escape sequences

ENABLE_VIRTUAL_TERMINAL_PROCESSING on the output handle

Needs ENABLE_PROCESSED_OUTPUT too. Off by default for a classic console, so a program must enable it.

Window size (TIOCGWINSZ)

GetConsoleScreenBufferInfo, the srWindow rectangle

The window is a viewport over a larger buffer, so the visible size is srWindow.Right - Left + 1 by Bottom - Top + 1.

SIGWINCH

WINDOW_BUFFER_SIZE_EVENT

Reported through ReadConsoleInput when ENABLE_WINDOW_INPUT is set on the input handle; ReadFile and ReadConsole never see it. Not set by default.

SIGINT and SIGQUIT

CTRL_C_EVENT and CTRL_BREAK_EVENT to a SetConsoleCtrlHandler routine

Console control events are not POSIX signals; runtimes map them. See the Windows console page.

SIGTSTP, SIGTTIN, SIGTTOU, SIGCONT

None

Windows has no job control: there are no foreground and background process groups of a console, so any attached program may read it and Ctrl-Z does not suspend anything.

On the output side Microsoft documents that a console created today has ENABLE_PROCESSED_OUTPUT and ENABLE_WRAP_AT_EOL_OUTPUT set and ENABLE_VIRTUAL_TERMINAL_PROCESSING not set; the optional DISABLE_NEWLINE_AUTO_RETURN flag better emulates a terminal emulator when text reaches the last column. On the input side, every input mode except ENABLE_WINDOW_INPUT and ENABLE_VIRTUAL_TERMINAL_INPUT is on in a new console. Putting a Windows program in raw mode with VT input is therefore the same idea as cfmakeraw:

// shelf-rawmode.c (Windows): switch the console to VT in and out, restore on exit.
#include <windows.h>
#include <stdio.h>

static DWORD saved_in, saved_out;
static HANDLE h_in, h_out;

static void restore_console(void) {
    SetConsoleMode(h_in, saved_in);
    SetConsoleMode(h_out, saved_out);
}

static BOOL WINAPI on_ctrl(DWORD type) {
    if (type == CTRL_C_EVENT || type == CTRL_CLOSE_EVENT) restore_console();
    return FALSE;                                   /* let the default handler end the process */
}

int wmain(void) {
    h_in  = GetStdHandle(STD_INPUT_HANDLE);
    h_out = GetStdHandle(STD_OUTPUT_HANDLE);
    if (!GetConsoleMode(h_in, &saved_in) || !GetConsoleMode(h_out, &saved_out)) {
        fputs("shelf: not a console\n", stderr);   /* redirected: treat like a non-TTY on POSIX */
        return 1;
    }
    SetConsoleCtrlHandler(on_ctrl, TRUE);
    atexit(restore_console);

    DWORD in  = saved_in;
    in &= ~(DWORD)(ENABLE_LINE_INPUT | ENABLE_ECHO_INPUT);   /* like clearing ICANON and ECHO */
    in |= ENABLE_VIRTUAL_TERMINAL_INPUT | ENABLE_WINDOW_INPUT;
    DWORD out = saved_out | ENABLE_PROCESSED_OUTPUT | ENABLE_VIRTUAL_TERMINAL_PROCESSING;
    if (!SetConsoleMode(h_in, in) || !SetConsoleMode(h_out, out)) {
        fputs("shelf: this console does not support virtual terminal mode\n", stderr);
        return 1;
    }

    CONSOLE_SCREEN_BUFFER_INFO info;
    GetConsoleScreenBufferInfo(h_out, &info);
    int cols = info.srWindow.Right  - info.srWindow.Left + 1;
    int rows = info.srWindow.Bottom - info.srWindow.Top  + 1;
    printf("shelf tui: %d rows x %d columns\n", rows, cols);
    return 0;
}

This Windows example was not compiled while this page was written. It uses only the documented calls and constants of the SetConsoleMode and GetConsoleScreenBufferInfo pages. The modes belong to the console, which the parent shell shares, so the handler and the atexit hook mirror the POSIX example and restore what the program changed; the page on Windows Console & Processes treats the mapping of console control events in detail.

Hosting a program with ConPTY

A Windows program that wants to be the terminal, like the shelf-pty host above, creates a pseudoconsole. The documentation describes the steps: create two pipes (one for input, one for output), call CreatePseudoConsole with the size and the two pipe ends the console host will use, prepare a STARTUPINFOEX whose attribute list carries the pseudoconsole through the PROC_THREAD_ATTRIBUTE_PSEUDOCONSOLE attribute, and start the child with CreateProcess and EXTENDED_STARTUPINFO_PRESENT.

// shelf-conpty.c (Windows 10 1809+): run "shelf.exe list" on a pseudoconsole and print its output.
#include <windows.h>
#include <stdio.h>

static HPCON pty;
static HANDLE child;

/* Waits for the child, then closes the pseudoconsole from a thread that does not read the output. */
static DWORD WINAPI close_when_done(LPVOID unused) {
    (void)unused;
    WaitForSingleObject(child, INFINITE);
    ClosePseudoConsole(pty);
    return 0;
}

int wmain(void) {
    HANDLE in_read, in_write, out_read, out_write;
    CreatePipe(&in_read, &in_write, NULL, 0);        /* input channel: we write, ConPTY reads */
    CreatePipe(&out_read, &out_write, NULL, 0);      /* output channel: ConPTY writes, we read */

    COORD size = { 120, 30 };                        /* columns, rows */
    HRESULT hr = CreatePseudoConsole(size, in_read, out_write, 0, &pty);
    if (FAILED(hr)) {
        fprintf(stderr, "CreatePseudoConsole failed: 0x%08lx\n", (unsigned long)hr);
        return 1;
    }

    STARTUPINFOEXW si = { 0 };
    si.StartupInfo.cb = sizeof si;
    SIZE_T bytes = 0;
    InitializeProcThreadAttributeList(NULL, 1, 0, &bytes);          /* first call: how much memory */
    si.lpAttributeList = 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 cmd[] = L"shelf.exe list";
    PROCESS_INFORMATION pi;
    if (!CreateProcessW(NULL, cmd, NULL, NULL, FALSE, EXTENDED_STARTUPINFO_PRESENT,
                        NULL, NULL, &si.StartupInfo, &pi)) {
        fprintf(stderr, "CreateProcess failed: %lu\n", GetLastError());
        return 1;
    }
    child = pi.hProcess;
    CloseHandle(in_read);                            /* free our copies so a broken pipe is detected */
    CloseHandle(out_write);
    CreateThread(NULL, 0, close_when_done, NULL, 0, NULL);

    char buf[4096];
    DWORD n;
    while (ReadFile(out_read, buf, sizeof buf, &n, NULL) && n > 0)
        fwrite(buf, 1, n, stdout);                   /* virtual-terminal text, escape sequences included */
    CloseHandle(pi.hThread);
    return 0;
}

This example is condensed from the documented steps and was not compiled while this page was written. It omits error handling for most calls and does not service the input pipe (in_write is never used, so the child gets no keyboard input), which the documentation recommends doing on its own thread in a real host. The output is read on the main thread and the pseudoconsole is closed from a second thread once the child has exited, so the read loop ends when the console host closes its end of the pipe. Resizing is ResizePseudoConsole(pty, newSize), the Windows counterpart of TIOCSWINSZ plus SIGWINCH: it adjusts the buffers so that programs using the console functions get the right dimensions.

The documentation lists the following rules, which are the Windows version of the PTY pitfalls above:

  • Service each pipe on its own thread. Using one thread for both channels can deadlock when one buffer fills while the thread waits on the other.

  • Close the pipe ends the pseudoconsole owns (in_read and out_write above) after CreateProcess, so that a broken pipe is noticed when the session ends.

  • ClosePseudoConsole sends CTRL_CLOSE_EVENT to each attached client. Before Windows 11 24H2 (build 26100) it waits indefinitely for the clients to disconnect, so close the output pipe first or keep reading it until the call returns and do not call it from the thread that reads the output; from 24H2 it returns immediately and the host learns of the end by reading until the pipe closes.

  • The size passed at creation applies to the in-memory buffer that answers GetConsoleScreenBufferInfoEx and WriteConsoleOutput for the child.

  • The PSEUDOCONSOLE_INHERIT_CURSOR flag asks for the parent console’s cursor position; a host that uses it must answer the cursor request it receives on the output pipe asynchronously or it can hang.

Restoring terminal state: the rules

The practices of this page reduce to one set of rules for any program that touches terminal state.

Rule Detail

Detect first

Ask isatty (POSIX) or GetConsoleMode (Windows) for each stream before using terminal features; a redirected stream is not a terminal.

Save before changing

Keep the original termios (or console mode) in memory and restore exactly it.

Restore on every path

Normal exit, error exit, atexit, caught fatal signals, panic and uncaught-exception hooks in languages that have them, and the stop signals (restore on stop, re-enter on SIGCONT).

Restore the visible state too

Leave the alternate screen, show the cursor, disable mouse reporting and bracketed paste if you enabled them. Those are escape sequences, covered on the escape-sequences page, and the user’s shell will not undo them.

Never modify more than needed

Change only the flags you need. A program that overwrites the whole termios with a fresh structure loses the user’s settings such as IXON, IUTF8 or custom special characters.

Use /dev/tty deliberately

Prompt on the controlling terminal for secrets so that redirected standard input and output stay unaffected, and fail with a clear message when no controlling terminal exists.

Know who owns the terminal

A program started in the background, from cron, under a container without a PTY or by an agent has no terminal to modify. Detect it and fall back to flags, files or environment variables.

If a program has already left the terminal in a bad state, the user’s recovery tools are reset, stty sane (typed blind, followed by Ctrl-J rather than Enter because ICRNL may be off), or opening a new terminal.

Pitfalls summary

  • A program that sets raw mode and does not restore it on SIGTERM, SIGHUP or a panic leaves the user’s shell broken.

  • A handler that does too much (redraws, allocates, prints with printf) is not async-signal-safe; set a flag.

  • Treating Ctrl-C as a byte requires clearing ISIG (POSIX) or ENABLE_PROCESSED_INPUT (Windows), after which the program owns the responsibility to quit cleanly.

  • cfmakeraw leaves VMIN and VTIME unchanged; set them or read may return zero bytes or block forever.

  • Raw mode and pipes do not mix: tcgetattr on a non-terminal fails with ENOTTY.

  • Window size is zero in non-interactive contexts; never divide by it.

  • On macOS, tcgetwinsize is not available in the SDK tested, poll on a /dev/tty descriptor fails, and the window size cannot be set on the master before the slave is opened.

  • On Windows there are no background process groups, no SIGTSTP and no SIGWINCH; resize arrives as an input record, and Ctrl-C is a console control event.