Section 1: The PTY Laboratory
This section is the foundation, and it is the one people skip. Do not skip it.
You will begin with the smallest possible Rust program that launches a shell through a pseudo-terminal
and grow it into a correct one. Along the way you will learn — by implementing, breaking, and
inspecting — every Unix mechanism a terminal depends on: file descriptors, fork/exec, sessions,
process groups, controlling terminals, the line discipline, termios, ioctl, TIOCSCTTY,
TIOCGWINSZ/TIOCSWINSZ, SIGWINCH, SIGCHLD, blocking and non-blocking I/O, and polling.
No terminal emulator crate is used in this section. Not portable-pty, not pty-process, not
termion, not crossterm. You will call libc (and later nix/rustix) directly, because the
purpose here is to know what those crates do, not to have them do it.
What You Build
Milestone 1 → raw-inspector ~120 lines, no PTY, your own terminal in raw mode
Milestone 2 → terminal-pty v1 PTY pair + shell + naïve blocking relay
Milestone 3 → terminal-pty v2 one poll loop: stdin, master, signals, child exit
+ terminal-debugger hex dump + session recorder
At the end you have a program that is, functionally, a transparent terminal: it sits between your real terminal and a shell, relaying bytes without understanding them. That is exactly the right place to stop, because Section 2 is where you start understanding them.
The Layer You Are Building
┌─────────────────────────────────────────────────────────────────┐
│ YOUR REAL TERMINAL (Ghostty / iTerm / gnome-terminal) │
│ — you will put it in RAW mode so it stops interfering │
└─────────────────────────────────────────────────────────────────┘
│ stdin (fd 0) ▲ stdout (fd 1)
▼ │
┌─────────────────────────────────────────────────────────────────┐
│ YOUR PROGRAM ── the whole of Section 1 ── │
│ │
│ read(0) ─────────────────────────▶ write(master) │
│ write(1) ◀───────────────────────── read(master) │
│ │
│ + SIGWINCH → ioctl(0,TIOCGWINSZ) → ioctl(master,TIOCSWINSZ) │
│ + SIGCHLD → waitpid(WNOHANG) → exit │
└─────────────────────────────────────────────────────────────────┘
│ master fd
═══════│════════════════════════ KERNEL ═══════════════════════════
▼
┌─────────────────────────────────────────────────────────────────┐
│ PTY PAIR + LINE DISCIPLINE │
└─────────────────────────────────────────────────────────────────┘
│ /dev/pts/N
═══════│════════════════════════ USER SPACE ═══════════════════════
▼
┌─────────────────────────────────────────────────────────────────┐
│ bash — session leader, controlling terminal = the slave │
└─────────────────────────────────────────────────────────────────┘
Two terminals are involved and confusing them is the most common Section 1 bug. Your outer
terminal is the one you must put in raw mode so it does not echo, buffer, or intercept ^C.
The inner PTY is the one your child shell talks to. They have separate termios settings and
separate window sizes, and you are responsible for keeping the sizes in sync.
The Concepts, and Where Each Is Treated
For every concept, this section shows six things: what problem it solves, where it exists in the OS, which process owns or interacts with it, which bytes/signals/syscalls are involved, a small experiment that demonstrates it, and a failure mode that occurs when it is implemented incorrectly. That six-part treatment is the structure of every concept chapter.
| Concept | Chapter |
|---|---|
| TTY versus PTY | TTY and PTY |
| PTY master and slave | TTY and PTY |
| File descriptors | File Descriptors, fork & exec |
fork, exec, and spawn | File Descriptors, fork & exec |
| Sessions, session leaders | Sessions, Process Groups & the Controlling Terminal |
| Process groups | Sessions, Process Groups & the Controlling Terminal |
| Controlling terminals | Sessions, Process Groups & the Controlling Terminal |
| Foreground process groups | Sessions, Process Groups & the Controlling Terminal |
TIOCSCTTY | Sessions, Process Groups & the Controlling Terminal |
| Canonical and raw modes | termios & the Line Discipline |
| Echo | termios & the Line Discipline |
termios | termios & the Line Discipline |
ioctl | Signals, Window Size & the Child |
TIOCGWINSZ / TIOCSWINSZ | Signals, Window Size & the Child |
SIGWINCH | Signals, Window Size & the Child |
SIGCHLD | Signals, Window Size & the Child |
| Blocking vs. non-blocking I/O | I/O Multiplexing |
poll, select, epoll, kqueue, async | I/O Multiplexing |
The Labs
| Lab | Milestone | What you build |
|---|---|---|
| Lab 1 | M1 | A raw-mode keyboard byte inspector |
| Lab 2 | M2 | A PTY shell runner, from raw syscalls |
| Lab 3 | M3 | A correct event loop with resize, signals, and child reaping |
| Lab 4 | — | Seven experiments that make the concepts observable |
| Lab 5 | — | A PTY session recorder and replayer |
Lab 4 is not optional. It contains the cat/echo experiment, canonical vs. raw, the Ctrl+C trace,
the SIGWINCH observation, the job-control walkthrough with sleep 100, the pipes-vs-PTY
comparison, and the process-inspection tour with ps, /proc, strace, lsof, and stty. It is
where the concepts stop being text.
Crates: What You Are Allowed to Use, and When
The rule for this whole section: first the raw Unix API, then the Rust abstraction.
| Crate | What OS functionality it wraps | Allowed from |
|---|---|---|
libc | Raw declarations of the C library and syscall wrappers. No safety, no abstraction — exactly what you want first. | Lab 1 |
nix | Type-safe wrappers over POSIX: openpty, forkpty, setsid, tcsetattr, ioctl macros, poll, waitpid, signal handling. Turns i32 error codes into Result and raw constants into enums/bitflags. | Lab 3, after you have written the libc version |
rustix | Similar surface to nix but with I/O safety types (OwnedFd/BorrowedFd) and, on Linux, direct syscalls without libc. Prefer it if you want fd ownership enforced by the compiler. | Lab 3, as an alternative to nix |
signal-hook | Safe signal handling: registers handlers that do only async-signal-safe work, and gives you an iterator or a pipe/signalfd you can poll. Wraps sigaction + the self-pipe trick. | Lab 3 |
mio | A thin, cross-platform event-loop abstraction over epoll (Linux), kqueue (BSD/macOS), and IOCP (Windows). No runtime, no futures. | Lab 3 challenge |
tokio | An async runtime; tokio::io over the same mechanisms plus a task scheduler and futures. Much more machinery than this section needs. | Section 4, optional |
portable-pty | WezTerm's PTY abstraction. Wraps exactly what you build in Lab 2, plus a Windows ConPTY backend behind the same API. | After Lab 2 is complete and working |
Warning: If
portable-ptyappears in yourCargo.tomlbefore Lab 2 is finished, you have traded the entire point of this section for two hours. The exercise is not "get a shell running" — it is "know exactly which seven syscalls get a shell running, and what each one fails to do."
Deliverables
Before moving to Section 2:
-
raw-inspectorruns, decodes every key, and restores the terminal on every exit path including panic (Lab 1). -
terminal-ptyspawns a shell through a hand-written PTY setup with no PTY crate (Lab 2). - A written, ordered list of every syscall in your spawn path with its purpose and its failure mode.
-
A single event loop handling stdin, master,
SIGWINCH, andSIGCHLD, with async-signal-safe handlers (Lab 3). -
vimandtoprun correctly inside your runner, and resize correctly. - All seven experiments in Lab 4 completed, with a written prediction and result for each.
- A recorded session file that replays deterministically (Lab 5).
-
answers-m0.mdupdated: re-answer the twelve questions and diff against your first attempt.
Common Mistakes in This Section
| Mistake | Symptom | Fix |
|---|---|---|
| Not putting the outer terminal in raw mode | Double echo; your keystrokes appear twice; Ctrl+C kills your program instead of the inner job | tcsetattr(0, TCSAFLUSH, &raw) on the outer terminal, restore on exit |
| Not restoring the terminal on panic | Your shell is unusable after a crash; you must type stty sane blind | A guard type with Drop, plus std::panic::set_hook |
setsid() after TIOCSCTTY | TIOCSCTTY fails with EPERM; no job control | setsid() first, always |
Calling setsid() in a process that is already a group leader | setsid() returns EPERM | Structure the spawn so the child is fresh from fork |
| Keeping the slave fd open in the parent | The parent never sees EOF/EIO when the child exits — the loop hangs forever | close(slave) in the parent immediately after fork |
| Keeping the master fd open in the child | Confusing hangs and lost SIGHUP semantics | close(master) in the child before exec |
| Doing real work in a signal handler | Random deadlocks, corrupted output, malloc reentrancy | Handler writes one byte to a pipe; the event loop does the work |
Ignoring partial write() | Dropped keystrokes and truncated output under load | Loop until all bytes are written, handle EINTR and EAGAIN |
Treating EAGAIN as an error | Spurious "read failed" exits | On non-blocking fds, EAGAIN/EWOULDBLOCK means "nothing right now" |
Treating EIO on master read as an error | An ugly error on every normal exit (Linux only) | On Linux, EIO after the child exits is the EOF condition |
Forgetting TERM in the child's environment | vim says "terminal too dumb"; no colors | setenv("TERM", "xterm-256color") before exec |
| Setting the initial window size after spawn | The child's first TIOCGWINSZ returns 0×0; top renders nothing | Set the size on the master before or immediately at spawn |
How to Verify Success
# 1. The inspector restores your terminal.
stty -a > /tmp/before.txt
cargo run -p raw-inspector # press keys, then quit
stty -a > /tmp/after.txt
diff /tmp/before.txt /tmp/after.txt && echo "TERMIOS RESTORED"
# 2. The runner gives you a real, different tty.
tty # outer, e.g. /dev/pts/2
cargo run -p terminal-pty # then inside:
# tty # inner, e.g. /dev/pts/9 ← must differ
# ps -o pid,pgid,sid,tpgid,tty,comm
# exit
# 3. Interactive programs work.
cargo run -p terminal-pty
# vim → opens, alternate screen works, :q quits
# top → renders, updates, q quits
# resize the outer window while top is running → top re-lays-out
# 4. Job control works.
cargo run -p terminal-pty
# sleep 100
# ^Z → "[1]+ Stopped"
# jobs ; bg ; fg ; ^C
Section Profile: What a Section 1 Graduate Can Do
| Capability | Evidence |
|---|---|
| Explain every syscall in a PTY spawn | The ordered list, from memory |
| Debug a "Ctrl+C does nothing" bug in under five minutes | ps -o tpgid, then check ISIG, then check TIOCSCTTY |
Explain why nohup and tmux exist | SIGHUP on master close, to the session leader |
| Write a correct signal handler | Self-pipe or signalfd, no allocation, no locks |
| Predict what breaks when a program is piped instead of PTY'd | The ten-row table in the mental model |
You are not yet able to interpret a single escape sequence. Your program relays \x1b[31m
without knowing it means red. That is Section 2.
Next: TTY and PTY.