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

TermPrecise definitionKind of thingConcrete instance
TTYA 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 disciplineKernel 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 codeLinux N_TTY; macOS's tty layer
PTYA 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 pairmaster fd from /dev/ptmx + /dev/pts/3
Terminal emulatorA user-space program that holds the PTY master, converts input events to bytes, parses output bytes into screen state, and renders.User-space processGhostty, Alacritty, xterm, Terminal.app
ShellA 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 processbash, zsh, fish, /bin/sh
MultiplexerA 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.

BehaviorOwned byNot owned by
A key press becomes a byte sequenceEmulator (input encoder)Kernel; shell
The bytes for the Up arrow are ESC [ AConvention (terminfo/VT100), implemented in the emulatorKernel
Typed characters appear on screen as you typeKernel line discipline (echo), or the shell's own line editor if it disabled echoEmulator (it only draws what it is told)
read() returns only after EnterKernel line discipline (canonical mode)Shell
Backspace erases the previous character in the input bufferKernel (canonical mode, VERASE) or the shell's line editor (raw mode)Emulator
^C interrupts the running programKernel (ISIG + VINTR → SIGINT)Emulator; shell
Which processes get that SIGINTKernel, using the terminal's foreground process group—
Which process group is the foreground groupShell (it calls tcsetpgrp)Kernel decides nothing here
\n moves to column 0 as well as down a lineKernel on output (OPOST+ONLCR turns \n into \r\n), and the emulator's interpretation of \r—
The cursor positionEmulator (screen state)Kernel; shell
Colors, bold, italicsEmulator, per SGR sequences the program emittedKernel
ScrollbackEmulator (or multiplexer)Kernel; shell
The window is 80×24Emulator decides, kernel stores it (TIOCSWINSZ), programs query it (TIOCGWINSZ)Shell
A program redraws after a resizeProgram, after catching SIGWINCH from the kernelEmulator (it only set the size)
The shell dies when you close the windowKernel sends SIGHUP when the master closesEmulator (it just closed an fd)
A session survives the window closingMultiplexer (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: ONLCR is why a raw-mode program that prints "\n" produces a staircase:

hello
     world

In raw mode you cleared OPOST, so \n is only a line feed — it moves down, not to column 0. You must emit \r\n yourself. 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:

RuleDetail
A session has at most one controlling terminalAcquired 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 groupStored 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 groupSIGINT (^C), SIGQUIT (^\), SIGTSTP (^Z), and SIGWINCH on resize.
A background process that reads from the terminal gets SIGTTINWhich stops it. That is why cat & immediately shows [1]+ Stopped.
A background process that writes gets SIGTTOU — only if TOSTOP is setTOSTOP 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 leaderThe 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 foreverThe kernel instead delivers SIGHUP+SIGCONT when a group becomes orphaned with stopped members.

Warning: setsid() fails with EPERM if the calling process is already a process group leader. This is the classic PTY bug: you fork(), and if the child happens to already be a group leader, setsid() fails, TIOCSCTTY then fails, and you get a shell with no controlling terminal — which "works" until you press Ctrl+C and nothing happens, or run vim and 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:

ConceptInvented byRepresented as
Cursor row/columnEmulatorTwo integers in your Cursor struct
The grid of charactersEmulatorVec<Cell> of rows × cols
Colors and attributesEmulator, driven by SGR sequencesA Style per cell
ScrollbackEmulatorLines evicted from the top of the grid
Alternate screenEmulatorA second grid, swapped by mode ?1049
Scroll regionsEmulatorTwo integers (top, bottom) from DECSTBM
Window titleEmulator, driven by OSC 0/2A String
"80×24"Emulator decides; kernel stores it as struct winsize for programs to queryioctl 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.

CapabilityPipePTY
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 bash with 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:

  1. SIGHUP on 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.
  2. 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.
  3. Terminal state must be maintained while nobody is watching. A pane running top keeps 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.
  4. 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

MisconceptionReality
"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.

  1. Draw the stack from key press to pixel, marking the user/kernel boundary. Label every arrow with a system call or a signal.
  2. 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.
  3. Which component echoes typed characters by default? Under what configuration does that change, and who does it instead?
  4. Name three things the line discipline does that a pipe does not.
  5. Trace what happens between pressing l and bash receiving the string "ls\n", naming every buffer the bytes pass through.
  6. Which process group receives SIGINT when you press Ctrl+C during sleep 100, and which system call put that group in the foreground?
  7. What is stored in struct winsize, who writes it, who reads it, and what signal connects them?
  8. Why does closing a terminal window kill the shell? Name the signal, its sender, and its receiver.
  9. Where does the scrollback buffer live, and why is it impossible for the kernel to have one?
  10. Why must a multiplexer be a separate long-lived process rather than a library inside the GUI?
  11. Why does the multiplexer server need a terminal emulator inside it?
  12. 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.