- Published on
How Terminals Actually Work
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:
fork(): Creates an exact copy of the current process (the child)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:
| State | Meaning | In ps |
|---|---|---|
| Running (R) | Currently executing on CPU or ready to run | R |
| 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 status | Z |
| 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:
| FD | Name | Purpose |
|---|---|---|
0 | stdin | Standard input—where the process reads input from |
1 | stdout | Standard output—where normal output goes |
2 | stderr | Standard 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:
- Creates a pipe with
pipe()syscall, getting two file descriptors - Forks twice to create two child processes
- In the
lsprocess: redirects stdout to the pipe's write end - In the
grepprocess: redirects stdin to the pipe's read end - 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()returnsfalse. 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
vimrefuses to startsudocan'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
vimworks normallysudocan 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:
| Program | With TTY | Without TTY |
|---|---|---|
ls | Colored, multi-column | Plain, one per line |
grep | Highlighted matches | Plain text |
git | Colored diffs, pager | Plain output |
python | Interactive REPL with history | Reads script from stdin |
vim | Full editor | Warning/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
| Command | Description |
|---|---|
Ctrl+Z | Suspend foreground job |
jobs | List all jobs |
fg %1 | Bring job 1 to foreground |
bg %1 | Resume job 1 in background |
cmd & | Start command in background |
kill %1 | Send SIGTERM to job 1 |
disown %1 | Detach job (survives logout) |
Without a PTY: If you spawn a shell with just pipes, job control is broken.
Ctrl+Zwon't suspend anything.fgandbgwon'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."
Terminal-Related Signals
| Signal | Trigger | Default Action | Purpose |
|---|---|---|---|
SIGINT | Ctrl+C | Terminate | Interrupt—politely ask process to stop |
SIGQUIT | Ctrl+\ | Terminate + core dump | Quit—forceful, dumps core for debugging |
SIGTSTP | Ctrl+Z | Stop (suspend) | Terminal stop—suspend to background |
SIGCONT | fg or bg | Continue | Resume a stopped process |
SIGHUP | Terminal closes | Terminate | Hangup—controlling terminal disconnected |
SIGWINCH | Terminal resize | Ignored | Window 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 trykill PID(sends SIGTERM) beforekill -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:
- You're connected: Laptop → SSH → PTY 1 → tmux server → PTY 2 → bash
- WiFi drops: PTY 1 closes, but tmux server keeps PTY 2 (and your shell) alive!
- 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
- pty(7) man page
- tty(4) man page
- The TTY demystified by Linus Akesson
- portable-pty Rust crate documentation