Milestone 0: The Terminal Mental Model
No code in this chapter. This is the model you will spend the rest of the curriculum making concrete. Read it, then explain it out loud to someone (or to a rubber duck, or to a text file). If any explanation stalls, that stall is exactly where your model is wrong.
The goal: after this chapter you can say, for any terminal behavior you observe, which layer owns it and which side of the kernel boundary it lives on.
The Layers, Precisely
A terminal is not one thing. It is five things that historically were one thing, and the confusion in every terminal discussion comes from that history.
The historical machine (1970)
┌─────────────────┐ RS-232 serial line ┌──────────────────┐
│ DEC VT100 │◀──────────────────────▶│ PDP-11 running │
│ hardware │ bytes, 9600 baud │ Unix │
│ │ │ │
│ • keyboard │ │ • tty driver │
│ • CRT screen │ │ • getty → login │
│ • cursor │ │ • shell │
│ • ANSI decoder │ │ │
└─────────────────┘ └──────────────────┘
^ THE TERMINAL ^ THE COMPUTER
The terminal was a physical device. It had a keyboard, a screen, and a small amount of logic that interpreted byte sequences to move a cursor and set attributes. The computer had a tty driver in the kernel that owned the serial port, buffered input into lines, echoed characters back, and turned certain bytes into signals.
Everything about modern terminals is an emulation of that picture. Every "why is it like this?" question has the same answer: because there used to be a wire.
The modern machine (today)
┌────────────────────────────────────────────────────────────────────────┐
│ TERMINAL EMULATOR — a user-space program pretending to be a VT100 │
│ • reads real keyboard events from the OS windowing system │
│ • ENCODES them into the byte sequences a VT100 keyboard would send │
│ • DECODES the byte stream coming back, maintaining a screen grid │
│ • RENDERS that grid with fonts on a GPU or CPU framebuffer │
└────────────────────────────────────────────────────────────────────────┘
│ write(master) ▲ read(master)
▼ │
══════════════════════════ KERNEL ═══════════════════════════════════════
┌────────────────────────────────────────────────────────────────────────┐
│ PTY PAIR — the kernel's replacement for the serial wire │
│ • master end: the fd the emulator holds │
│ • LINE DISCIPLINE: the same tty driver code as 1970, unchanged in kind│
│ • slave end: a device file (/dev/pts/N) that IS a tty to whoever opens│
└────────────────────────────────────────────────────────────────────────┘
│ read(0) ▲ write(1)
▼ │
══════════════════════════ USER SPACE ═══════════════════════════════════
┌────────────────────────────────────────────────────────────────────────┐
│ SHELL and its children — ordinary processes with fds 0/1/2 on the tty │
└────────────────────────────────────────────────────────────────────────┘
The PTY pair is the wire. That is its whole job: to be a place where one side writes bytes and the other side reads them, with a tty driver in between, so that a program written in 1979 to talk to a VT100 over a serial line works unmodified.
The Six Words, Defined Against Each Other
| Term | Precise definition | Kind of thing | Concrete instance |
|---|---|---|---|
| TTY | A character device that the kernel manages with a line discipline. Originally backed by serial hardware. | Kernel object + device file | /dev/ttyS0, /dev/tty1 (Linux VT), /dev/pts/3 |
| Terminal driver / line discipline | Kernel code layered between a tty device and the reading process. Buffers input into lines, echoes, translates line endings, generates signals from special characters, implements flow control. Configured through the termios structure. | Kernel code | Linux N_TTY; macOS's tty layer |
| PTY | A pair of kernel objects: a master (an fd with no device file of its own after allocation) and a slave (a real device file). Bytes written to one appear readable at the other, with the line discipline in between. | Kernel object pair | master fd from /dev/ptmx + /dev/pts/3 |
| Terminal emulator | A user-space program that holds the PTY master, converts input events to bytes, parses output bytes into screen state, and renders. | User-space process | Ghostty, Alacritty, xterm, Terminal.app |
| Shell | A user-space program that reads command lines from fd 0, forks/execs programs, and implements job control by manipulating process groups and the terminal's foreground process group. | User-space process | bash, zsh, fish, /bin/sh |
| Multiplexer | A user-space program that holds many PTY masters, runs an emulator per pane, composites panes into one logical screen, and outlives its own UI. | User-space process (usually a daemon + clients) | tmux, GNU screen, zellij |
The relationships, as a picture
flowchart TB
subgraph US1["User space — the front"]
EMU["Terminal emulator<br/>owns PTY master fd"]
end
subgraph K["Kernel"]
M["PTY master end"]
LD["Line discipline<br/>(terminal driver)<br/>termios config"]
S["PTY slave /dev/pts/N<br/>= a TTY"]
M <--> LD
LD <--> S
end
subgraph US2["User space — the back"]
SH["Shell<br/>fds 0,1,2 → slave<br/>session leader"]
CH["Child processes<br/>ls, vim, top"]
SH --> CH
end
EMU -->|"write(master)"| M
M -->|"read(master)"| EMU
S <--> SH
LD -.->|"SIGINT SIGTSTP<br/>SIGWINCH SIGHUP"| SH
LD -.-> CH
The Boundary That Matters: Who Owns What
This table is the reason this chapter exists. Memorize the middle column.
| Behavior | Owned by | Not owned by |
|---|---|---|
| A key press becomes a byte sequence | Emulator (input encoder) | Kernel; shell |
The bytes for the Up arrow are ESC [ A | Convention (terminfo/VT100), implemented in the emulator | Kernel |
| Typed characters appear on screen as you type | Kernel line discipline (echo), or the shell's own line editor if it disabled echo | Emulator (it only draws what it is told) |
read() returns only after Enter | Kernel line discipline (canonical mode) | Shell |
| Backspace erases the previous character in the input buffer | Kernel (canonical mode, VERASE) or the shell's line editor (raw mode) | Emulator |
^C interrupts the running program | Kernel (ISIG + VINTR → SIGINT) | Emulator; shell |
Which processes get that SIGINT | Kernel, using the terminal's foreground process group | — |
| Which process group is the foreground group | Shell (it calls tcsetpgrp) | Kernel decides nothing here |
\n moves to column 0 as well as down a line | Kernel on output (OPOST+ONLCR turns \n into \r\n), and the emulator's interpretation of \r | — |
| The cursor position | Emulator (screen state) | Kernel; shell |
| Colors, bold, italics | Emulator, per SGR sequences the program emitted | Kernel |
| Scrollback | Emulator (or multiplexer) | Kernel; shell |
| The window is 80×24 | Emulator decides, kernel stores it (TIOCSWINSZ), programs query it (TIOCGWINSZ) | Shell |
| A program redraws after a resize | Program, after catching SIGWINCH from the kernel | Emulator (it only set the size) |
| The shell dies when you close the window | Kernel sends SIGHUP when the master closes | Emulator (it just closed an fd) |
| A session survives the window closing | Multiplexer (it holds the master, and it is not the process that died) | Kernel |
Tip: When you hit a confusing terminal behavior for the rest of your career, ask the two questions in this order: (1) Is this the line discipline or the emulator? (2) Which process group is in the foreground? Those two questions resolve the majority of cases.
The Byte and Signal Flow, End to End
Output path: a program prints "hi\n"
USER SPACE ls: write(1, "hi\n", 3)
│
═════│═══════ KERNEL ═══════════════════════════════════════════════════
▼
slave end receives "hi\n"
│
OUTPUT PROCESSING (termios c_oflag):
OPOST enabled? yes (default)
ONLCR enabled? yes (default) → "\n" becomes "\r\n"
result: "hi\r\n"
│
PTY output buffer ────────────────────────────────► master fd becomes readable
═════│═══════ USER SPACE ══════════════════════════════════════════════
▼
emulator: poll/epoll/kqueue wakes → read(master) → "hi\r\n"
│
UTF-8 decoder → 'h', 'i', CR, LF
VT parser → Print('h'), Print('i'), Execute(CR), Execute(LF)
screen state → cells[row][0]='h', cells[row][1]='i',
CR: cursor.col = 0
LF: cursor.row += 1 (scroll if at bottom)
│
dirty rows marked → renderer draws them → pixels
Note:
ONLCRis why a raw-mode program that prints"\n"produces a staircase:hello worldIn raw mode you cleared
OPOST, so\nis only a line feed — it moves down, not to column 0. You must emit\r\nyourself. This will happen to you in Lab 1, and now you know why.
Input path: you press the Up arrow
USER SPACE windowing system → KeyEvent { physical: ArrowUp, mods: none }
│
emulator input encoder:
is DECCKM (application cursor keys, mode ?1) set?
no → "\x1b[A" (CSI A — normal cursor keys)
yes → "\x1bOA" (SS3 A — application cursor keys)
│
write(master, "\x1b[A", 3)
│
═════│═══════ KERNEL ═══════════════════════════════════════════════════
▼
INPUT PROCESSING (termios c_iflag / c_lflag):
ISIG? is 0x1b one of VINTR/VQUIT/VSUSP? no.
ICANON? if yes: append to the line buffer, do NOT wake read()
if no (raw): make the bytes available immediately
ECHO? if yes: copy the bytes to the OUTPUT side ← this is why you
see what you type, and why raw-mode programs must echo themselves
│
slave end input buffer ─────────────────────────► a reader's read(0) returns
═════│═══════ USER SPACE ══════════════════════════════════════════════
▼
vim: read(0) → "\x1b[A" → moves the cursor up one line
Signal path: you press Ctrl+C
emulator writes 0x03 to the master
│
═════│═══════ KERNEL ═══════════════════════════════════════════════════
▼
line discipline: c_lflag & ISIG? and 0x03 == c_cc[VINTR]?
yes → generate SIGINT
→ deliver to EVERY process in the terminal's FOREGROUND PROCESS GROUP
→ flush the input queue (because IEXTEN/NOFLSH semantics)
→ the byte 0x03 is CONSUMED — no process ever read()s it
no (ISIG cleared, i.e. raw mode)
→ 0x03 is ordinary data; the reading program receives byte 0x03
That last branch is why your raw-mode inspector in Lab 1 shows 03 instead of dying, and why vim
can bind <C-c>.
Sessions, Process Groups, and the Controlling Terminal
This is the part everyone skips and then cannot debug. Three nested containers:
SESSION (id = sid; created by setsid(); has ONE session leader; may have
│ at most ONE controlling terminal)
│
├── PROCESS GROUP 4242 ← the FOREGROUND process group of the controlling terminal
│ ├── bash (pid 4242, session leader, pgid 4242)
│ └── (children of the current foreground job)
│
├── PROCESS GROUP 4310 ← a BACKGROUND job: sleep 100 &
│ └── sleep (pid 4310, pgid 4310)
│
└── PROCESS GROUP 4315 ← another background job: make -j8
├── make
└── cc × 8
Rules, exactly:
| Rule | Detail |
|---|---|
| A session has at most one controlling terminal | Acquired by a session leader with no controlling terminal opening a tty (or explicitly via ioctl(fd, TIOCSCTTY, 0)). |
| A terminal has exactly one foreground process group | Stored in the kernel per-terminal. Read with tcgetpgrp(), set with tcsetpgrp(). ps shows it in the TPGID column. |
| Terminal-generated signals go to the foreground process group | SIGINT (^C), SIGQUIT (^\), SIGTSTP (^Z), and SIGWINCH on resize. |
A background process that reads from the terminal gets SIGTTIN | Which stops it. That is why cat & immediately shows [1]+ Stopped. |
A background process that writes gets SIGTTOU — only if TOSTOP is set | TOSTOP is off by default, which is why background jobs can scribble on your screen. |
When the terminal's last master fd closes, the kernel sends SIGHUP to the session leader | The shell then HUPs its jobs. This is why closing a window kills everything, and why nohup, disown, and tmux exist. |
An orphaned process group is never sent SIGTSTP/SIGTTIN/SIGTTOU that would stop it forever | The kernel instead delivers SIGHUP+SIGCONT when a group becomes orphaned with stopped members. |
Warning:
setsid()fails withEPERMif the calling process is already a process group leader. This is the classic PTY bug: youfork(), and if the child happens to already be a group leader,setsid()fails,TIOCSCTTYthen fails, and you get a shell with no controlling terminal — which "works" until you press Ctrl+C and nothing happens, or runvimand it complains. The fix is structural, and it is in Lab 2.
Where the Screen Actually Lives
The single most clarifying fact in this curriculum:
The kernel has no concept of a screen, a cursor, a color, or a scrollback buffer.
The line discipline knows about bytes and lines. It knows VERASE (usually 0x7f) should
remove the last character from its input line buffer, and it knows how many characters it echoed
so it can erase them visually. It does not know where the cursor is, what row 7 contains, or that
you are in the alternate screen.
Everything visual is invented by the emulator:
| Concept | Invented by | Represented as |
|---|---|---|
| Cursor row/column | Emulator | Two integers in your Cursor struct |
| The grid of characters | Emulator | Vec<Cell> of rows × cols |
| Colors and attributes | Emulator, driven by SGR sequences | A Style per cell |
| Scrollback | Emulator | Lines evicted from the top of the grid |
| Alternate screen | Emulator | A second grid, swapped by mode ?1049 |
| Scroll regions | Emulator | Two integers (top, bottom) from DECSTBM |
| Window title | Emulator, driven by OSC 0/2 | A String |
| "80×24" | Emulator decides; kernel stores it as struct winsize for programs to query | ioctl payload |
The kernel stores exactly one screen-shaped thing: struct winsize { ws_row, ws_col, ws_xpixel, ws_ypixel }. It never reads it. It only hands it to programs that ask, and sends SIGWINCH when it
changes. It is a mailbox, not a model.
Why Pipes Are Not Enough
You could connect a shell to your program with Stdio::piped(). It half-works, and understanding
exactly which half fails is the fastest way to internalize what a PTY provides.
| Capability | Pipe | PTY |
|---|---|---|
| Move bytes both ways | ✅ | ✅ |
isatty(0) returns true | ❌ | ✅ |
| Program enables colors / interactive mode | ❌ (most tools auto-disable) | ✅ |
| Line editing, echo, canonical mode | ❌ (no line discipline at all) | ✅ |
^C → SIGINT | ❌ (byte 0x03 is just data) | ✅ |
Job control (fg, bg, ^Z) | ❌ — bash prints "no job control in this shell" | ✅ |
TIOCGWINSZ returns a size | ❌ ENOTTY | ✅ |
SIGWINCH on resize | ❌ | ✅ |
vim, top, less work | ❌ | ✅ |
SIGHUP on disconnect | ❌ (you get EPIPE/EOF instead) | ✅ |
| The shell prints a prompt at all | ❌ (bash suppresses it when stdin is not a tty) | ✅ |
Checkpoint question: What do you expect will happen if you spawn
bashwith pipes instead of a PTY, then write"ls\n"to its stdin and read stdout? Write your prediction down before you run the experiment in Lab 4. Most people get one of the four observable differences right.
Where the Multiplexer Fits
A multiplexer is not a terminal emulator with tabs. It is a process that holds PTY masters and outlives its UI. That single property forces its entire architecture.
┌───────────────────────────────────────────────────────────────────────┐
│ Your GUI terminal emulator (or any terminal) │
│ owns PTY-A master; renders pixels │
└───────────────────────────────────────────────────────────────────────┘
│ PTY-A: bytes in/out
▼
┌───────────────────────────────────────────────────────────────────────┐
│ MUX CLIENT (a short-lived process; `tmux attach`) │
│ • puts its own terminal into raw mode │
│ • forwards keystrokes to the server over a Unix socket │
│ • receives screen updates and writes them to PTY-A │
│ • owns NOTHING that must survive │
└───────────────────────────────────────────────────────────────────────┘
│ Unix domain socket (framed messages)
▼
┌───────────────────────────────────────────────────────────────────────┐
│ MUX SERVER (a daemon: setsid, no controlling terminal) │
│ Session "work" │
│ Window 0 │
│ Pane 0 → PTY-B master + terminal state + child bash │
│ Pane 1 → PTY-C master + terminal state + child vim │
│ Session "logs" │
│ Window 0 → Pane 0 → PTY-D master + state + child tail -f │
└───────────────────────────────────────────────────────────────────────┘
Why the server must be a separate process:
SIGHUPon master close. If the PTY masters were owned by the client, closing the client (or its terminal) closes the masters, and the kernel HUPs every session leader behind them. Your shells die. The whole point of tmux is that they do not.- The client's terminal is not the pane's terminal. Pane sizes must be independent of whatever window happens to be attached — including zero attached windows.
- Terminal state must be maintained while nobody is watching. A pane running
topkeeps producing output when detached. Somebody must parse it, or you would have nothing to redraw on reattach. That "somebody" is a full terminal emulator, per pane, inside the server. - Multiple clients. Two people attached to one session need one source of truth.
Note: This is the reason the mux server contains a terminal emulator. The mux is not an alternative to the emulator — it is a second one, headless, in the middle. Section 5 is where that observation turns into an architecture.
Common Misconceptions, Corrected
| Misconception | Reality |
|---|---|
| "The shell draws the prompt." | The shell writes bytes. The emulator draws. |
| "The terminal sends my keystrokes to the shell." | The terminal writes bytes to the PTY master. The line discipline decides whether and when the shell sees them. |
| "Ctrl+C sends a signal." | Ctrl+C sends the byte 0x03. The kernel turns it into a signal — but only if ISIG is set. |
| "The emulator echoes what I type." | By default the kernel echoes. The emulator just renders whatever comes back on the master. |
"\n moves to the start of the next line." | \n moves down. \r moves to column 0. You usually get both because ONLCR inserts the \r for you. |
| "A PTY is like a socketpair." | Close: it is a bidirectional channel — plus a line discipline, plus signal generation, plus a window size, plus session/job-control semantics. Those extras are the point. |
| "tmux is a terminal emulator." | tmux is a multiplexer that contains terminal emulators. It does not draw pixels; it emits escape sequences into whatever real terminal is attached. |
| "Escape sequences are a standard." | They are a family of overlapping standards (ECMA-48/ANSI X3.64, DEC VT private modes, xterm extensions, and modern proposals) plus decades of de-facto behavior. terminfo exists because of this. |
| "256-color and truecolor are the same mechanism." | SGR 38;5;n (palette) and SGR 38;2;r;g;b (direct) are different sub-protocols with different support levels. |
| "Resizing sends the new size to the program." | Resizing sets a kernel field and sends SIGWINCH. The program must ask with TIOCGWINSZ. Programs that do not, render at the old size forever. |
Validation / Self-check
Milestone 0 is complete when you can answer all of these without notes. These are the same twelve questions listed in the introduction, which you should now be able to answer.
- Draw the stack from key press to pixel, marking the user/kernel boundary. Label every arrow with a system call or a signal.
- Give the precise definition of each of: TTY, PTY, line discipline, terminal emulator, shell, multiplexer — in one sentence each, without using any of the other five words as the definition.
- Which component echoes typed characters by default? Under what configuration does that change, and who does it instead?
- Name three things the line discipline does that a pipe does not.
- Trace what happens between pressing
landbashreceiving the string"ls\n", naming every buffer the bytes pass through. - Which process group receives
SIGINTwhen you press Ctrl+C duringsleep 100, and which system call put that group in the foreground? - What is stored in
struct winsize, who writes it, who reads it, and what signal connects them? - Why does closing a terminal window kill the shell? Name the signal, its sender, and its receiver.
- Where does the scrollback buffer live, and why is it impossible for the kernel to have one?
- Why must a multiplexer be a separate long-lived process rather than a library inside the GUI?
- Why does the multiplexer server need a terminal emulator inside it?
- Who owns the terminal screen — the shell, the kernel, or the emulator?
Tip: Answer #12 in exactly one sentence, and keep that sentence. It is the thesis of the whole curriculum, and you will be asked to defend it in the capstone.
Next: Workspace Design — the crate boundaries you will grow into, and why each one exists.