Trace a Keystroke: The Complete Path
This is the chapter the whole curriculum has been building toward. One key press, traced through every layer, with the exact bytes, syscalls, and state transitions named.
If you can reproduce this trace on your own machine with your own debugger output, you understand how terminals work. That is the acceptance criterion for Milestone 8.
The Canonical Trace: Pressing a
═══════════════════════════════ USER SPACE (your GUI) ══════════════════════════
1. HARDWARE / OS
The keyboard sends a scancode; the OS keyboard driver and the window
system's input layer turn it into an input event for the focused window.
2. WINDOWING EVENT
winit delivers:
WindowEvent::KeyboardInput {
event: KeyEvent {
physical_key: PhysicalKey::Code(KeyCode::KeyA), ← position
logical_key: Key::Character("a"), ← layout-mapped
text: Some("a"), ← committed text
state: ElementState::Pressed,
repeat: false,
}, ..
}
3. INPUT ENCODING [terminal-input]
Named key? no
Ctrl or Alt held? no
→ fall through to the text path: bytes = "a".as_bytes() = [0x61]
4. PTY WRITE [terminal-pty]
write(master_fd, [0x61], 1) → returns 1
═══════════════════════════════════ KERNEL ═════════════════════════════════════
5. PTY INPUT QUEUE
The byte enters the pair's input queue, heading for the slave.
6. LINE DISCIPLINE — INPUT PROCESSING
c_iflag: ISTRIP? no. ICRNL? irrelevant (0x61 is not CR).
c_lflag: ISIG? is 0x61 == VINTR/VQUIT/VSUSP? no.
ICANON? YES (for a plain `cat`; a shell with readline sets
its own mode — see the note below)
→ append 0x61 to the LINE BUFFER
→ read(slave) does NOT return
ECHO? YES
→ copy 0x61 to the OUTPUT queue ← THE ECHO ROUND TRIP
7. PTY OUTPUT QUEUE
0x61 is now readable on the MASTER, and no process has seen it as input yet.
═══════════════════════════ USER SPACE (your GUI) ══════════════════════════════
8. PTY READ
The reader thread's blocking read(master_fd, buf, 65536) returns 1 byte: [0x61]
→ send over the channel, wake the event loop via EventLoopProxy
9. UTF-8 DECODING [terminal-protocol]
0x61 is 0x00..=0x7f → DecodeResult::Char('a')
10. PARSER [terminal-protocol]
State: Ground. 0x61 is printable → perform.print('a')
11. TERMINAL STATE [terminal-core]
cell_width('a') = 1
pending_wrap? no
screen.write_cell(row=0, col=12, Cell{'a', current_style})
cursor.col = 13
damage.mark(0)
12. DAMAGE → REDRAW REQUEST
damage.any() == true → window.request_redraw()
13. RENDER [terminal-render-model → terminal-gui]
RenderSnapshot::from(&terminal) → dirty = [0]
for row 0:
for each cell:
resolve colors (theme → inverse → selection)
fill the cell rect with bg
atlas lookup GlyphKey{'a', bold:false, italic:false, size:16}
HIT → (atlas_x, atlas_y, w, h, xmin, ymin)
baseline_y = row*cell_h + ascent
blend the coverage bitmap into the framebuffer
draw the cursor at (row 0, col 13)
14. PRESENT
surface.buffer_mut().present() → the compositor shows the frame
═══════════════════════════════════════════════════════════════════════════════
...MEANWHILE, the shell has still not seen the byte. It is sitting in the
kernel's canonical line buffer, waiting for a line terminator.
Note — step 6, the honest version. The canonical-mode description above is exactly what happens for
cat,read, and any program that leaves the terminal alone. An interactive shell (bashwith readline,zshwith ZLE) puts the terminal into a non-canonical mode withECHOoff and does its own echoing and line editing — so that it can offer history, completion, and multi-line editing. In that case, step 6 delivers0x61to the shell immediately, and the shell writesaback out itself in step 7. The observable result is nearly identical; the owner is not. Determine which case you are in withstty -aon the inner PTY while the shell is running.
Pressing Enter, and the Line Being Released
1. winit: Key::Named(NamedKey::Enter)
2. ENCODING: Enter → 0x0d (CR) ← NOT 0x0a. The keyboard sends CR, as a
real terminal's RETURN key did.
3. write(master, [0x0d], 1)
4. LINE DISCIPLINE:
c_iflag ICRNL is SET (default) → 0x0d is translated to 0x0a on INPUT
ICANON: 0x0a is the line terminator
→ RELEASE the line buffer: bash's read(0, ...) returns "ls\n"
ECHO: echo the newline
→ OUTPUT side gets 0x0d 0x0a (ONLCR expands the LF)
5. Your terminal reads "\r\n" from the master:
Execute(0x0d) → cursor.col = 0
Execute(0x0a) → line_feed(): cursor.row += 1, scroll if at the region bottom
6. bash now has the string "ls\n" and begins the fork/exec dance from
the mental model's Phase D.
The two translations to keep straight:
| Direction | Flag | Effect |
|---|---|---|
| Input (toward the program) | ICRNL | CR → NL, so pressing Enter produces \n for the program |
| Output (toward you) | ONLCR | NL → CR NL, so a program printing \n gets a proper newline |
Backspace
1. winit: Key::Named(NamedKey::Backspace)
2. ENCODING: 0x7f (DEL) ← not 0x08. DEC terminals sent DEL; VERASE defaults to it.
Ctrl+Backspace → 0x08. Make it configurable.
3. LINE DISCIPLINE (canonical + ECHOE):
0x7f == c_cc[VERASE]
→ remove the last character from the LINE BUFFER
→ ECHOE: emit "\b \b" to the output — back up, write a space, back up
4. Your terminal receives 0x08 0x20 0x08:
Execute(0x08) → cursor.col -= 1 ← BS MOVES; it does NOT erase
Print(' ') → overwrite with a space
Execute(0x08) → cursor.col -= 1
That three-byte dance is why backspace visually erases even though BS is only a cursor movement.
Implementing BS as "erase" breaks every program that uses it for positioning.
Tab
1. winit: Key::Named(NamedKey::Tab) (Shift+Tab → CSI Z)
2. ENCODING: 0x09 (HT)
3. LINE DISCIPLINE: appended to the line buffer; echoed
(unless the shell is doing completion, in which case IT intercepts the tab
in its own raw-ish mode and never lets the kernel see it as data)
4. YOUR TERMINAL: Execute(0x09) → advance to the next tab stop.
Default stops every 8 columns. HT must NOT wrap: at the last stop it goes
to the last column and stops.
Arrow Keys
1. winit: Key::Named(NamedKey::ArrowUp)
2. ENCODING — mode-dependent:
DECCKM (?1) RESET: \x1b [ A = 1b 5b 41
DECCKM SET: \x1b O A = 1b 4f 41
With a modifier: \x1b [ 1 ; <mod> A ← always the CSI form
3. write(master, ...)
4. LINE DISCIPLINE:
ISIG? none of these bytes are VINTR/VQUIT/VSUSP.
ICANON? in a canonical-mode program, these three bytes go into the line
buffer as ordinary data — which is why `cat` shows "^[[A".
In a raw-mode program (vim, readline), they arrive immediately.
5. The program interprets them. bash's readline maps CSI A to
"previous-history"; vim maps it to "cursor up".
This is the clearest demonstration of the mode dependency: the terminal does not decide what the arrow key means, and it does not even decide what bytes it sends — the program decided, earlier, by setting DECCKM.
Ctrl+C
1. winit: Key::Character("c") with ControlKey held
2. ENCODING: 'c' & 0x1f = 0x03 (ETX)
3. write(master, [0x03], 1)
═══════════════════════════════════ KERNEL ═════════════════════════════════════
4. LINE DISCIPLINE:
c_lflag & ISIG? SET (default)
0x03 == c_cc[VINTR]? YES
→ generate SIGINT
→ deliver to EVERY process in the terminal's FOREGROUND PROCESS GROUP
→ FLUSH the input queue (unless NOFLSH)
→ the byte 0x03 is CONSUMED. No process ever read()s it.
If ISIG were CLEAR (raw mode — vim, less, your own inspector):
→ 0x03 is ORDINARY DATA and is delivered to the program.
5. The foreground process group — say `sleep 100` — receives SIGINT and dies
with the default disposition.
6. bash's waitpid returns; bash calls tcsetpgrp() to take the terminal back
and prints a new prompt.
7. Your terminal never learns any of this happened. It just sees new output.
Warning: Your GUI must not intercept Ctrl+C. Send the byte and let the kernel decide. A terminal that treats Ctrl+C as "copy" (a Windows convention) breaks every Unix program; that is why Unix terminals use Ctrl+Shift+C for copy.
Ctrl+Z
1. ENCODING: 'z' & 0x1f = 0x1a (SUB)
2. LINE DISCIPLINE: 0x1a == c_cc[VSUSP], ISIG set → SIGTSTP to the foreground
process group → the job STOPS (STAT 'T').
3. bash's waitpid(WUNTRACED) returns with WIFSTOPPED, bash calls
tcsetpgrp(tty, bash_pgid) to take the terminal back, and prints
"[1]+ Stopped sleep 100".
4. Your terminal sees only the printed text.
The interesting part: the terminal did nothing but forward one byte. All of job control is the kernel plus the shell.
Alt+B (word-back in readline)
1. winit: Key::Character("b") with AltKey held
(macOS: Option+B produces the TEXT "∫" — you must use the LOGICAL key,
not the text, and honor an "Option as Meta" setting.)
2. ENCODING: ESC prefix → 1b 62
NOT the 8-bit form (0xe2), which would collide with UTF-8 lead bytes.
3. LINE DISCIPLINE: two ordinary bytes. Not signals.
4. readline receives ESC then 'b', matches its "backward-word" binding.
Note that readline ALSO uses a timeout to distinguish a bare Escape from
the start of a sequence — the same ambiguity your terminal has on input.
Function Keys
F1 → \x1b O P (SS3 form; the VT100 lineage)
F5 → \x1b [ 1 5 ~ (CSI ~ form; the VT220 lineage)
F12 → \x1b [ 2 4 ~
Shift+F5 → \x1b [ 1 5 ; 2 ~ ← the modifier goes after the number
The gaps are historical: there is no CSI 16~ or CSI 22~.
Emit what xterm emits, because TERM=xterm-256color promises xterm's terminfo.
The Full Round Trip, as a Diagram
┌────────────────────────────────────────────────────────────────────────┐
│ YOUR TERMINAL (user space) │
│ │
│ key press pixels │
│ │ ▲ │
│ ▼ │ │
│ ┌────────────┐ ┌──────────────┐ │
│ │ input │ │ renderer │ │
│ │ encoder │ │ + atlas │ │
│ └─────┬──────┘ └──────▲───────┘ │
│ │ bytes snapshot │ │
│ │ │ │
│ │ ┌──────┴───────┐ │
│ │ │ terminal-core│ │
│ │ │ screen state │ │
│ │ └──────▲───────┘ │
│ │ │ actions │
│ │ ┌──────┴───────┐ │
│ │ │ parser+UTF-8 │ │
│ │ └──────▲───────┘ │
│ │ │ bytes │
│ ▼ │ │
│ write(master) read(master) │
└────────┼───────────────────────────────────────────────────┼───────────┘
│ ═ KERNEL ═ │
┌────────▼───────────────────────────────────────────────────┴───────────┐
│ PTY input queue ──▶ LINE DISCIPLINE ──▶ slave │
│ • ECHO ──────────────────────┐ │
│ • ICANON line buffering │ │
│ • ISIG → SIGINT/SIGTSTP ──┐ │ │
│ PTY output queue ◀── OPOST/ONLCR ◀── slave │ │ │
│ ▲ │ │ │
└────────────────────────────┼────────────────────┼──┼───────────────────┘
│ write(1) │ │ echo
┌────────────────────────────┴────────────────────▼──┴───────────────────┐
│ bash (session leader) → foreground process group → ls / vim / top │
└────────────────────────────────────────────────────────────────────────┘
Experiment: Reproduce This Trace
CLAIM. Every step above is directly observable on your machine.
METHOD. Enable every debug channel and press one key.
cargo run -p terminal-gui -- \
--debug-bytes --debug-utf8 --debug-parser --debug-actions \
--debug-cursor --debug-damage --debug-log /tmp/trace.log
# In the window: press exactly one key, `a`. Then quit.
cat /tmp/trace.log
Expected:
[input] KeyEvent physical=KeyA logical=Character("a") text=Some("a") mods=NONE
[encode] text path → [0x61]
[pty-w] write(master, 1 bytes): 61 |a|
[pty-r] read(master, 1 bytes): 61 |a|
[utf8] 0x61 → Char('a')
[parser] Ground + 0x61 → print
[action] PRINT 'a' U+0061
[cursor] (0,12) → (0,13) cause=print
[damage] mark row 0
[render] dirty=[0] atlas=HIT frame=0.31ms
And on the kernel side, simultaneously:
# Linux, in another window:
sudo strace -f -p $(pgrep -n terminal-gui) -e trace=read,write,ioctl
# Press one key. You should see exactly:
# write(5, "a", 1) = 1 ← to the master
# read(5, "a", 65536) = 1 ← the ECHO coming back
PREDICTION. Before running: how many bytes will read return after you press one key? Where did
that byte come from — the shell, or the kernel?
RESULT. Note that the byte came back before the shell ever saw it. That is the echo round trip,
and seeing it in strace is the moment the whole system clicks.
Test
#![allow(unused)] fn main() { #[test] fn full_round_trip_for_a_single_character() { // The end-to-end test: a key press produces a cell on the screen, via a // real PTY and a real line discipline. let mut h = Harness::spawn("cat"); h.press_key(Key::Character("a"), NO_MODS); h.wait_for_output(Duration::from_millis(500)); // `cat` has not echoed anything (no newline yet). The 'a' on screen is the // KERNEL's echo — which is exactly the point. assert_eq!(h.terminal().screen().row(0).cell(0).grapheme(), "a"); assert_eq!(h.terminal().cursor().col, 1); } #[test] fn enter_sends_cr_and_the_terminal_receives_crlf() { let mut h = Harness::spawn("cat"); h.press_key(Key::Character("h"), NO_MODS); h.press_key(Key::Named(Named::Enter), NO_MODS); h.wait_for_output(Duration::from_millis(500)); assert_eq!(h.bytes_written_to_pty(), b"h\r"); // ICRNL made it "h\n" for cat; ONLCR made cat's echo "h\r\n" coming back. assert!(h.bytes_read_from_pty().windows(2).any(|w| w == b"\r\n")); assert_eq!(h.terminal().cursor().row, 1); } #[test] fn ctrl_c_reaches_the_foreground_process_group() { let mut h = Harness::spawn_shell(); h.type_line("sleep 100"); h.wait_for(|t| t.child_count() == 2, Duration::from_secs(2)); h.press_key(Key::Character("c"), CTRL); // The byte we send is 0x03; the KERNEL turns it into SIGINT. assert_eq!(h.last_bytes_written(), &[0x03]); h.wait_for(|t| t.child_count() == 1, Duration::from_secs(2)); } #[test] fn backspace_produces_the_bs_space_bs_dance() { let mut h = Harness::spawn("cat"); h.press_key(Key::Character("a"), NO_MODS); h.press_key(Key::Named(Named::Backspace), NO_MODS); h.wait_for_output(Duration::from_millis(500)); assert_eq!(h.last_bytes_written(), &[0x7f]); assert!(h.bytes_read_from_pty().ends_with(b"\x08 \x08")); assert_eq!(h.terminal().cursor().col, 0); } }
Validation / Self-check
You have finished Milestone 8 when you can, without notes:
- Recite all 14 steps of the
atrace, naming the component that owns each. - State which byte Enter sends and which two
termiosflags translate it in each direction. - Explain the
BS SP BSdance and whyBSalone does not erase. - Explain why the same arrow key produces different bytes in
bashandvim, naming the mode and who sets it. - Trace Ctrl+C, naming the byte, the flag, the signal, and the exact set of recipients.
- Explain why Ctrl+Shift+C is copy on Unix terminals rather than Ctrl+C.
- Explain what happens to the
abyte between step 6 and the shell actually receiving it. - Name the two places a timeout is used to disambiguate
ESC, and who owns each. - Point at the exact line in your own code where each of the 14 steps happens.
- Produce the debug log for one keystroke and explain every line of it.