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.


The Labs

LabMilestoneWhat you build
Lab 1M1A raw-mode keyboard byte inspector
Lab 2M2A PTY shell runner, from raw syscalls
Lab 3M3A 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.

CrateWhat OS functionality it wrapsAllowed from
libcRaw declarations of the C library and syscall wrappers. No safety, no abstraction — exactly what you want first.Lab 1
nixType-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
rustixSimilar 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-hookSafe 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
mioA thin, cross-platform event-loop abstraction over epoll (Linux), kqueue (BSD/macOS), and IOCP (Windows). No runtime, no futures.Lab 3 challenge
tokioAn 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-ptyWezTerm'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-pty appears in your Cargo.toml before 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-inspector runs, decodes every key, and restores the terminal on every exit path including panic (Lab 1).
  • terminal-pty spawns 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, and SIGCHLD, with async-signal-safe handlers (Lab 3).
  • vim and top run 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.md updated: re-answer the twelve questions and diff against your first attempt.

Common Mistakes in This Section

MistakeSymptomFix
Not putting the outer terminal in raw modeDouble echo; your keystrokes appear twice; Ctrl+C kills your program instead of the inner jobtcsetattr(0, TCSAFLUSH, &raw) on the outer terminal, restore on exit
Not restoring the terminal on panicYour shell is unusable after a crash; you must type stty sane blindA guard type with Drop, plus std::panic::set_hook
setsid() after TIOCSCTTYTIOCSCTTY fails with EPERM; no job controlsetsid() first, always
Calling setsid() in a process that is already a group leadersetsid() returns EPERMStructure the spawn so the child is fresh from fork
Keeping the slave fd open in the parentThe parent never sees EOF/EIO when the child exits — the loop hangs foreverclose(slave) in the parent immediately after fork
Keeping the master fd open in the childConfusing hangs and lost SIGHUP semanticsclose(master) in the child before exec
Doing real work in a signal handlerRandom deadlocks, corrupted output, malloc reentrancyHandler writes one byte to a pipe; the event loop does the work
Ignoring partial write()Dropped keystrokes and truncated output under loadLoop until all bytes are written, handle EINTR and EAGAIN
Treating EAGAIN as an errorSpurious "read failed" exitsOn non-blocking fds, EAGAIN/EWOULDBLOCK means "nothing right now"
Treating EIO on master read as an errorAn 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 environmentvim says "terminal too dumb"; no colorssetenv("TERM", "xterm-256color") before exec
Setting the initial window size after spawnThe child's first TIOCGWINSZ returns 0×0; top renders nothingSet 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

CapabilityEvidence
Explain every syscall in a PTY spawnThe ordered list, from memory
Debug a "Ctrl+C does nothing" bug in under five minutesps -o tpgid, then check ISIG, then check TIOCSCTTY
Explain why nohup and tmux existSIGHUP on master close, to the session leader
Write a correct signal handlerSelf-pipe or signalfd, no allocation, no locks
Predict what breaks when a program is piped instead of PTY'dThe 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.