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 sane and press Enter — or Ctrl+J if Enter does nothing, because ICRNL will be off. If the screen is garbled rather than the input, printf '\033c' or reset. 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 differsWhat it tells you
tty pathEach window has its own PTY pair
sidEach is its own session
tpgidEach has its own foreground process group
stty sizeSize 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.

  1. Which component echoed your keystrokes, and which experiment proved it?
  2. In canonical mode, why did cat print nothing until you pressed Enter?
  3. When you pressed Ctrl+C during sleep 100 | cat, exactly which processes received SIGINT, and what put them in that position?
  4. Why did cat & stop immediately?
  5. Why did printf 'a\nb\n' stair-step after stty -opost?
  6. How many bytes is the Up arrow, and why does that create an ambiguity with the Escape key?
  7. Where did the reply to \033[6n arrive, and what does that imply for your terminal's design?
  8. Name four things that break when a shell is connected by pipes instead of a PTY.
  9. After the forced TIOCSWINSZ, why did top redraw at a size the window was not?
  10. Why did killing the tmux client leave sleep running, and killing the server not?

What You Should Now Believe

BeforeAfter
"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 ^C becomes a signal, and why tmux keeps 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.