>_
Published on

How Terminals Actually Work

Written by Claude

How Terminals Actually Work

A deep dive into PTYs, TTYs, and why your shell isn't just a subprocess.

The Misconception

Many developers think a terminal is just a GUI wrapper that spawns a shell process and pipes stdin/stdout around. That mental model works until it doesn't—and then you're debugging why vim won't start, why Ctrl+C doesn't work, or why your colors disappeared.

The reality involves a kernel-level abstraction called a pseudo-terminal (PTY) that has been fundamental to Unix since the 1970s.

First, the Fundamentals

Before diving into terminals, let's make sure we understand the building blocks: processes, file descriptors, standard streams, and pipes.

What is a Process?

A process is a running instance of a program. When you run ls, the kernel creates a process, loads the ls program into memory, and executes it. Every process has:

  • PID (Process ID): A unique number assigned by the kernel
  • PPID (Parent PID): The PID of the process that created it
  • Memory Space: Code, stack, and heap
  • File Descriptors: References to open files, pipes, sockets
  • Environment: Variables like PATH, HOME, USER
  • State: Running, sleeping, stopped, or zombie
  • User/Group ID: Determines permissions
  • Working Directory: For resolving relative paths

How Processes are Created: fork() and exec()

In Unix, new processes are created in two steps:

  1. fork(): Creates an exact copy of the current process (the child)
  2. exec(): Replaces the child's program with a new one
// Simplified: what happens when you type "ls" in bash
pid_t pid = fork();  // Create child process

if (pid == 0) {
    // This is the child process
    execvp("ls", ["ls", "-la"]);  // Replace with ls program
    perror("exec failed");
    exit(1);
} else {
    // This is the parent (bash)
    waitpid(pid, &status, 0);  // Wait for child to finish
}

Process States

A process can be in one of several states:

StateMeaningIn ps
Running (R)Currently executing on CPU or ready to runR
Sleeping (S)Waiting for something (I/O, timer, signal)S
Stopped (T)Suspended (Ctrl+Z, SIGSTOP)T
Zombie (Z)Finished but parent hasn't collected exit statusZ
Disk Sleep (D)Uninterruptible sleep (waiting for disk I/O)D

File Descriptors and Standard Streams

In Unix, everything is a file—including your keyboard input and screen output. Every process has a table of file descriptors (small integers) that point to open files, devices, pipes, or sockets.

Three file descriptors are special and created automatically for every process:

FDNamePurpose
0stdinStandard input—where the process reads input from
1stdoutStandard output—where normal output goes
2stderrStandard error—where error messages go

How Pipes Work

A pipe is a unidirectional data channel in the kernel. It has two ends: a read end and a write end. Data written to the write end can be read from the read end.

When you run ls | grep foo, the shell:

  1. Creates a pipe with pipe() syscall, getting two file descriptors
  2. Forks twice to create two child processes
  3. In the ls process: redirects stdout to the pipe's write end
  4. In the grep process: redirects stdin to the pipe's read end
  5. Both processes exec their respective programs

Important: Pipes are Not Terminals. When a process's stdin/stdout are connected to a pipe (not a terminal device), isatty() returns false. Programs detect this and often change behavior: no colors, no progress bars, no interactive prompts.

Historical Context: What's a TTY?

TTY stands for TeleTYpewriter—actual physical devices from the 1960s that communicated with mainframes over serial lines. The kernel had to handle these devices specially: buffering input until Enter was pressed, echoing characters back to the screen, interpreting Ctrl+C as "interrupt the program."

When physical terminals gave way to software, Unix needed a way to emulate this behavior. Enter the pseudo-terminal.

The PTY Architecture

PTY stands for Pseudo-TeletYpe (or Pseudo-Terminal). The "pseudo" indicates it's a software emulation of a physical teletype.

A PTY is a pair of virtual devices that provides a bidirectional communication channel. One end (the master) connects to your terminal emulator. The other end (the slave) looks exactly like a real terminal to the shell.

The key components:

  • Terminal Emulator (xterm, iTerm, GNOME Terminal): Handles display and keyboard input
  • PTY Master (/dev/ptmx): File descriptor held by terminal emulator
  • Line Discipline (kernel): Processes input/output, handles special characters
  • PTY Slave (/dev/pts/N): Looks like a real TTY to the shell

Key Insight: The shell has no idea it's not connected to a physical terminal. It calls isatty() on its file descriptors, and the kernel says "yes, this is a terminal." All the terminal semantics—line editing, signals, job control—work because the PTY slave implements the full TTY interface.

What the Line Discipline Does

The line discipline is the kernel component that sits between master and slave, implementing terminal semantics. It operates in two modes:

Canonical Mode (Cooked Mode)

Default mode. Input is buffered line-by-line:

  • Characters accumulate until you press Enter
  • Backspace actually deletes the previous character
  • The shell only sees complete lines
  • Ctrl+C sends SIGINT to the foreground process group
  • Ctrl+Z sends SIGTSTP (suspend)
  • Ctrl+D on empty line sends EOF

Raw Mode (Non-Canonical)

Used by programs like vim, less, htop:

  • Every keystroke is passed through immediately
  • No line buffering, no interpretation
  • Program handles everything itself
  • Enables things like arrow key navigation, syntax highlighting in real-time
// Programs switch modes using termios
struct termios raw;
tcgetattr(fd, &raw);
raw.c_lflag &= ~(ICANON | ECHO);  // Disable canonical mode and echo
tcsetattr(fd, TCSAFLUSH, &raw);

Pipes vs PTY: Why It Matters

Here's what happens when you spawn a shell with just pipes versus a PTY:

Subprocess with Pipes:

  • isatty() returns false
  • No colors (TERM not set properly)
  • No job control
  • Ctrl+C doesn't work
  • vim refuses to start
  • sudo can't prompt for password
  • No line editing (arrow keys broken)

Subprocess with PTY:

  • isatty() returns true
  • Full color support
  • Job control works (fg, bg, jobs)
  • Ctrl+C sends SIGINT correctly
  • vim works normally
  • sudo can prompt
  • Full readline/line editing

How Programs Detect a Terminal

Programs use the isatty() system call to check if a file descriptor is connected to a terminal:

if (isatty(STDOUT_FILENO)) {
    // Interactive: use colors, progress bars, fancy output
    printf("\033[32mSuccess!\033[0m\n");
} else {
    // Non-interactive: plain text, machine-readable
    printf("Success!\n");
}

Common programs that check this:

ProgramWith TTYWithout TTY
lsColored, multi-columnPlain, one per line
grepHighlighted matchesPlain text
gitColored diffs, pagerPlain output
pythonInteractive REPL with historyReads script from stdin
vimFull editorWarning/refuses

Job Control: Managing Multiple Processes

Job control is the ability to run multiple processes from a single terminal, switch between them, suspend them, and resume them. It's one of the key features that requires a real PTY (not just pipes).

A job is a pipeline of one or more processes started from a single command line. Each job can be in one of three states:

  • Foreground: Currently active, receives keyboard input
  • Background: Running but doesn't receive keyboard input
  • Stopped: Suspended, not running (after Ctrl+Z)

Job Control Commands

CommandDescription
Ctrl+ZSuspend foreground job
jobsList all jobs
fg %1Bring job 1 to foreground
bg %1Resume job 1 in background
cmd &Start command in background
kill %1Send SIGTERM to job 1
disown %1Detach job (survives logout)

Without a PTY: If you spawn a shell with just pipes, job control is broken. Ctrl+Z won't suspend anything. fg and bg won't work. This is why SSH, terminal emulators, and terminal MCPs all need to use PTYs.

Unix Signals

Signals are asynchronous notifications sent to processes. They're how the kernel (and other processes) communicate events like "the user pressed Ctrl+C" or "your child process died."

SignalTriggerDefault ActionPurpose
SIGINTCtrl+CTerminateInterrupt—politely ask process to stop
SIGQUITCtrl+\Terminate + core dumpQuit—forceful, dumps core for debugging
SIGTSTPCtrl+ZStop (suspend)Terminal stop—suspend to background
SIGCONTfg or bgContinueResume a stopped process
SIGHUPTerminal closesTerminateHangup—controlling terminal disconnected
SIGWINCHTerminal resizeIgnoredWindow changed—terminal size changed

Key Insight: SIGKILL vs SIGTERM: SIGTERM (signal 15) asks a process to terminate—the process can catch it, clean up, and exit gracefully. SIGKILL (signal 9) cannot be caught, blocked, or ignored; the kernel terminates the process immediately. Always try kill PID (sends SIGTERM) before kill -9 PID.

Case Study: How tmux Works

tmux (terminal multiplexer) is a perfect example that ties together everything we've discussed. It sits between your terminal emulator and your shell, providing multiple windows/panes, session persistence, and detach/reattach capability.

The Architecture

Without tmux, the stack is simple: Terminal → PTY → Shell

With tmux, there's an extra layer: Terminal → PTY 1 → tmux client → socket → tmux server → PTY 2/3/4 → Shells

How Session Persistence Works

This is why tmux sessions survive when your SSH connection drops:

  1. You're connected: Laptop → SSH → PTY 1 → tmux server → PTY 2 → bash
  2. WiFi drops: PTY 1 closes, but tmux server keeps PTY 2 (and your shell) alive!
  3. You reconnect: New SSH → PTY 3 → tmux attach → same PTY 2 → same bash

The tmux server catches SIGHUP when PTY 1 closes, but doesn't die. It just notes "client disconnected" and keeps PTY 2 (and your shell) alive.

tmux Is Also a Terminal Emulator!

Here's a mind-bending realization: tmux is itself a terminal emulator.

  • It reads escape codes from your shell (on PTY 2)
  • It maintains an internal screen buffer (what each pane looks like)
  • It re-renders that to your outer terminal (on PTY 1)
  • It translates between potentially different terminal types

Your shell has no idea it's running "inside" tmux. It just sees a PTY that responds like a terminal.

Shell vs Terminal Emulator: The Key Distinction

The terminal emulator and the shell are completely separate programs that happen to work together.

The Terminal Emulator

The terminal emulator (Terminal.app, iTerm2, GNOME Terminal, Alacritty, etc.) is responsible for:

  • Display: Rendering text, colors, fonts on your screen
  • Input: Capturing keyboard input and sending it to the PTY
  • PTY management: Creating and managing the PTY pair
  • Escape sequence interpretation: Parsing ANSI codes for colors, cursor movement, etc.

The terminal emulator does NOT execute your commands.

The Shell

The shell (bash, zsh, fish, etc.) is a command interpreter responsible for:

  • Prompt: Displaying the $ or custom prompt
  • Parsing: Understanding commands like ls -la | grep foo
  • Execution: Forking processes, setting up pipes, running programs
  • Job control: Managing foreground/background processes
  • Scripting: Running shell scripts
  • Built-ins: Commands like cd, export, alias

The shell doesn't know or care what terminal emulator you're using.

Key Point: You can use any shell with any terminal emulator. iTerm2 can run bash. GNOME Terminal can run zsh. To change your shell, use chsh -s /bin/zsh (or /bin/bash).

Summary

The terminal is not just a pipe to a subprocess. It's a sophisticated kernel abstraction that provides:

  • TTY semantics: Line editing, echo, special character handling
  • Signal delivery: Ctrl+C, Ctrl+Z routed to the right processes
  • Job control: Foreground/background process groups, suspension
  • Session management: Controlling terminals, session leaders
  • Mode switching: Canonical vs raw mode for different applications
  • Size management: Window dimensions and resize notifications

When building anything that needs to act like a terminal—SSH clients, terminal multiplexers, remote shells, or AI coding assistants that need shell access—you need PTYs, not just pipes.

Further Reading