The Warm-Up: From Terminal User to Terminal Builder
One to two hours, no code, no crates. You have used a terminal for years. This chapter makes you look at it — with the tools already on your machine — until the abstraction cracks and you can see the machinery underneath.
Do these in order, in a real terminal, and write down your prediction before each one. Keep the
results in warmup.md in your workspace; you will re-read it at the
capstone.
Warning: Several exercises deliberately break your terminal. The escape hatch is: type
stty saneand press Enter — orCtrl+Jif Enter does nothing, becauseICRNLwill be off. If the screen is garbled rather than the input,printf '\033c'orreset. Learn these three now; you will need all of them.
Exercise 1: What Am I Actually Connected To?
tty # the device path
ls -l "$(tty)" # a character device: note the leading 'c'
ps -o pid,ppid,pgid,sid,tpgid,stat,tty,comm -p $$
echo $TERM
stty size
Predict first: will pid, pgid, and sid be the same number? What is tpgid?
Then open a second terminal window and run the same commands. Every difference is a fact about the architecture:
| What differs | What it tells you |
|---|---|
tty path | Each window has its own PTY pair |
sid | Each is its own session |
tpgid | Each has its own foreground process group |
stty size | Size is per-terminal, stored in the kernel |
And now the fact that should surprise you:
# In window B, with window A's tty path:
echo "hello from the other window" > /dev/pts/5 # ← use A's path
The text appears in window A. You wrote to the slave end of A's PTY; the bytes went through output processing and out A's master, where A's terminal emulator read and rendered them. A's shell was never involved.
Exercise 2: Who Echoes?
This is the single most clarifying experiment in the curriculum.
# 1. Baseline.
cat
# type: hello ← you see it
# press Enter ← you see "hello" AGAIN (cat's output)
# Ctrl+D to exit
# 2. Now turn echo off — at the KERNEL level.
stty -echo
cat
# type: hello ← you see NOTHING
# press Enter ← now "hello" appears (cat's output only)
# Ctrl+D
stty echo
Predict first: in step 1, how many times does the word hello appear on screen, and why?
The answer: twice. Once because the kernel echoed each character as you typed it, and once
because cat read the line and wrote it back. Turning off ECHO removes the first copy and leaves
the second.
The conclusion to carry forward: neither your shell nor your terminal emulator echoes your keystrokes. The kernel's line discipline does. Your terminal emulator only draws whatever comes back out of the PTY master — including the echo.
Exercise 3: Why Doesn't read() Return?
cat
# type "abc" slowly. cat prints NOTHING.
# press Enter → "abc" appears.
# Ctrl+D
stty -icanon min 1 time 0
cat
# type "a" → it appears TWICE immediately.
# Ctrl+C
stty sane
Predict first: in the second block, why twice?
Because the kernel echoes it (copy one) and cat now receives it immediately — no line buffering —
and writes it back (copy two). In canonical mode cat was blocked in read() until you pressed
Enter.
What you just saw: the kernel holds a line buffer and only releases it on a line terminator.
That is ICANON, and it is why read() on a terminal behaves unlike read() on a file.
Exercise 4: Where Does Ctrl+C Go?
Open a second window running watch against your first window's tty:
# Window B (substitute your window A tty):
watch -n 0.3 'ps -o pid,pgid,sid,tpgid,stat,comm -t pts/5'
Then in window A, one step at a time, reading window B after each:
ps -o pid,pgid,sid,tpgid,stat,comm # 1. baseline: TPGID == the shell's PGID
sleep 100 # 2. TPGID changes to sleep's PGID
# Ctrl+C # sleep dies; the SHELL does not
sleep 100 | cat # 3. BOTH share ONE pgid; TPGID equals it
# Ctrl+C # both die — one job, one group
sleep 100 & # 4. background: its PGID != TPGID
# Ctrl+C # sleep SURVIVES
jobs ; kill %1
Predict first, before step 4: does the backgrounded sleep die? Which process receives SIGINT?
The rule you just observed: the byte 0x03 is converted by the kernel into SIGINT, and
delivered to the terminal's foreground process group — the one shown as TPGID. Not to whoever
is reading, not to the session leader. The shell decides which group is in the foreground by calling
tcsetpgrp().
Now the other half:
cat &
# → "[1]+ Stopped (tty input)" IMMEDIATELY
jobs
fg
# now it can read. Type something, then Ctrl+D.
Predict first: does cat & read your next keystroke, exit, or stop?
It stops. A background process that reads from the terminal gets SIGTTIN. Only the foreground
group may read.
Exercise 5: Job Control, Watched Live
Keep window B's watch running. In window A:
sleep 300
# Ctrl+Z → "[1]+ Stopped" STAT becomes T, TPGID returns to the shell
jobs
bg # STAT becomes S (no '+'), TPGID still the shell's
fg # STAT S+, TPGID is sleep's PGID again
# Ctrl+C
Watch the TPGID column at each step. That sequence of TPGID changes is job control. The shell
is calling tcsetpgrp() to hand the terminal back and forth, and the kernel is routing signals
accordingly.
And the one nobody knows about:
stty tostop # turn ON stop-on-background-write
(sleep 1; echo "from the background") &
jobs # "[1]+ Stopped (tty output)"
fg
stty -tostop # restore the default
Why is TOSTOP off by default? Because it would stop every background job that logs anything.
The cost is that background output scribbles over your prompt — a trade-off Unix made in 1980 and
never revisited.
Exercise 6: Raw Mode, and Getting Out of It
stty -a | head -5 # note: icanon, echo, isig, opost, onlcr
stty raw -echo
stty -a | head -5 # note: -icanon -echo -isig -opost
# Now type: ls and press Enter.
# → nothing happens the way you expect. Enter may not even work.
# ESCAPE HATCH: type stty sane then press Ctrl+J
Predict first: with raw -echo set, what does Ctrl+C do?
Nothing. ISIG is cleared, so 0x03 is delivered to the program as ordinary data. That is exactly
how vim can bind <C-c>.
And the staircase:
stty -opost
printf 'a\nb\nc\n'
# → a
# b
# c
stty sane
ONLCR normally turns each \n into \r\n. With OPOST cleared, \n only moves down. You
will hit this in Lab 1, and now you will know why in one second instead of twenty minutes.
Exercise 7: The Bytes Your Keyboard Actually Sends
cat -v
# Press, one at a time:
# a → a
# Enter → (a newline)
# Tab → ^I
# Backspace → ^? ← NOT ^H
# Escape → ^[
# Up arrow → ^[[A ← THREE bytes
# F1 → ^[OP or ^[[11~
# Ctrl+A → ^A
# Alt+b → ^[b (macOS: may produce ∫ instead)
# é → M-CM-) or similar
# Ctrl+D to exit
Better, with real hex — and it is already on your machine:
stty raw -echo; head -c 20 | xxd; stty sane
# ...press a few keys, then wait; it exits after 20 bytes.
Predict first: how many bytes is the Up arrow? Is Backspace 0x08 or 0x7F? Is Enter 0x0D or
0x0A?
Most people get at least two of those wrong. That gap is the reason Lab 1 exists.
Exercise 8: Escape Sequences Are Just Bytes
printf '\033[31mred\033[0m normal\n' # SGR: color
printf '\033[2J\033[H' # what `clear` does
printf '\033[10;20HHERE\n' # absolute positioning
printf '\033[41m\033[2J\033[0m' # erase uses the CURRENT background
printf '\033]0;My Custom Title\007' # look at your title bar
printf '\033]8;;https://example.com\033\\CLICK\033]8;;\033\\\n' # a hyperlink
printf '\033[?25l'; sleep 1; printf '\033[?25h' # hide/show the cursor
printf '\033[?1049h'; echo "alternate screen"; sleep 2; printf '\033[?1049l'
printf '\033#8' # DECALN: fill with E
printf '\033c' # RIS: hard reset
Predict first: after printf '\033[41m\033[2J', what color is the screen and why?
Then see what a real program emits:
ls --color=always | cat -v | head -5 # GNU
ls -G | cat -v | head -5 # macOS
script -q -c 'vim -u NONE -c q' /dev/null | cat -v | head -40
That last one is a terminal emulator's entire input, made visible. Everything you will build in Section 2 is the interpretation of exactly those bytes.
Exercise 9: The Terminal Talks Back
# Ask the terminal where the cursor is:
printf '\033[6n'; read -r -d R pos; echo; echo "cursor: ${pos#*[}"
# Ask what it claims to be:
printf '\033[c'; read -r -d c da; echo; echo "device attributes: $da"
# Ask its background color:
printf '\033]11;?\033\\'; read -r -d '\' bg; echo; echo "background: $bg"
Predict first: where does the reply arrive — on stdout, or on stdin?
On stdin. The terminal writes its answer into the PTY as if you had typed it. This is why your
Terminal type will need a take_replies() method, and why a program that queries a terminal that
never answers will hang.
Exercise 10: Pipes vs. PTY
test -t 0 && echo "stdin IS a tty" || echo "stdin is NOT a tty"
echo '' | { test -t 0 && echo "IS a tty" || echo "NOT a tty"; }
ls --color=auto | cat # colors GONE — ls checked isatty(1)
stty size # works
stty size < /dev/null # "Inappropriate ioctl for device" (ENOTTY)
echo 'echo $-' | bash # option flags: no 'i' → not interactive
echo ':q' | vim # "Warning: Input is not from a terminal"
# Now the same things through a real PTY:
script -q -c 'ls --color=auto' /dev/null | cat -v | head -3 # colors PRESENT
script -q -c 'test -t 0 && echo TTY || echo PIPE' /dev/null
Predict first: write down four specific differences before running any of it.
Most people get one or two. The full table is in the mental model, and this exercise is where it stops being a table and becomes a fact.
Exercise 11: Resize, and Who Learns About It
trap 'echo "SIGWINCH → $(stty size)"' WINCH
# now drag the window edge. Each resize prints one line.
trap - WINCH
Then force a size from outside, without touching the window:
# Window A: run `top` and leave it running.
# Window B (substitute A's tty):
python3 - <<'EOF'
import fcntl, struct, termios
with open('/dev/pts/5', 'wb') as f:
fcntl.ioctl(f, termios.TIOCSWINSZ, struct.pack('HHHH', 12, 40, 0, 0))
EOF
Predict first: does window A's window change size? Does top re-lay out? What happens the next
time you resize A with the mouse?
top immediately redraws at 12×40 even though the window is unchanged, because the program
believes the kernel, not the pixels. The next mouse resize overwrites it. That is the entire
SIGWINCH mechanism in one experiment.
Exercise 12: The Multiplexer, Observed
tmux new-session -d -s warmup 'sleep 3000'
SERVER=$(pgrep -n tmux); CHILD=$(pgrep -n sleep)
ps -o pid,ppid,pgid,sid,tty,comm -p "$SERVER","$CHILD"
# ^ the SERVER's TTY is "?" — it has NO controlling terminal.
# the CHILD's TTY is a pts — its own, from tmux.
lsof -p "$SERVER" 2>/dev/null | grep -E 'ptmx|pts'
# ^ the SERVER holds the PTY MASTER.
tmux attach -t warmup &
sleep 1
pkill -9 -f 'tmux attach' # kill the CLIENT, hard
ps -p "$CHILD" # STILL RUNNING
kill -9 "$SERVER" # now kill the SERVER
sleep 1
ps -p "$CHILD" # GONE
Predict first: why does killing the client leave the child alive, and killing the server not?
Because the server holds the PTY master. Killing the client closes a socket; killing the server
closes the master, the kernel drops the carrier, and SIGHUP reaches the session leader. That is
the whole of Section 4, demonstrated in eight commands.
Exercise 13: Look at a Real Terminal's Syscalls
# Linux:
strace -f -e trace=read,write,ioctl -p "$(pgrep -n bash)" 2>&1 | head -40
# Press ONE key in that shell and count the syscalls.
# macOS:
sudo dtruss -f -p "$(pgrep -n bash)" 2>&1 | grep -E 'read|write|ioctl' | head
Predict first: how many syscalls does one ordinary keystroke cost?
Watch for ioctl(0, TCGETS, ...) — that is tcgetattr, i.e. a program checking or changing the line
discipline. Run vim under strace and watch it reconfigure the terminal on entry and restore it on
exit. That is the same sequence you will write in Lab 1.
The Debrief
Answer these in writing. If any is uncomfortable, re-run the exercise rather than reading ahead.
- Which component echoed your keystrokes, and which experiment proved it?
- In canonical mode, why did
catprint nothing until you pressed Enter? - When you pressed Ctrl+C during
sleep 100 | cat, exactly which processes receivedSIGINT, and what put them in that position? - Why did
cat &stop immediately? - Why did
printf 'a\nb\n'stair-step afterstty -opost? - How many bytes is the Up arrow, and why does that create an ambiguity with the Escape key?
- Where did the reply to
\033[6narrive, and what does that imply for your terminal's design? - Name four things that break when a shell is connected by pipes instead of a PTY.
- After the forced
TIOCSWINSZ, why didtopredraw at a size the window was not? - Why did killing the tmux client leave
sleeprunning, and killing the server not?
What You Should Now Believe
| Before | After |
|---|---|
| "The terminal shows what I type" | The kernel echoes; the terminal draws whatever comes back |
| "Ctrl+C sends a signal" | Ctrl+C sends the byte 0x03; the kernel makes it a signal — only if ISIG |
| "The shell reads my keystrokes" | The line discipline decides whether and when the shell sees them |
| "Escape sequences are magic" | They are bytes, and you just typed several by hand |
| "A terminal is one thing" | It is six: TTY, PTY, line discipline, emulator, shell, multiplexer |
| "tmux is a terminal" | tmux is a process that holds PTY masters and does not die |
Ready?
You are ready for Section 1 when:
- All thirteen exercises done, with written predictions recorded first.
- The debrief answered without looking anything up.
-
You have wedged your terminal at least once and recovered it with
stty sane+Ctrl+J. -
You can state, in one sentence each: who echoes, where
^Cbecomes a signal, and whytmuxkeeps shells alive.
You still cannot build a terminal. But you can now see one, and that is the prerequisite.
Next: The Weekly Plan, or go straight to Milestone 0 if you prefer to work without a schedule.