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 (bash with readline, zsh with ZLE) puts the terminal into a non-canonical mode with ECHO off and does its own echoing and line editing — so that it can offer history, completion, and multi-line editing. In that case, step 6 delivers 0x61 to the shell immediately, and the shell writes a back out itself in step 7. The observable result is nearly identical; the owner is not. Determine which case you are in with stty -a on 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:

DirectionFlagEffect
Input (toward the program)ICRNLCR → NL, so pressing Enter produces \n for the program
Output (toward you)ONLCRNL → 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:

  1. Recite all 14 steps of the a trace, naming the component that owns each.
  2. State which byte Enter sends and which two termios flags translate it in each direction.
  3. Explain the BS SP BS dance and why BS alone does not erase.
  4. Explain why the same arrow key produces different bytes in bash and vim, naming the mode and who sets it.
  5. Trace Ctrl+C, naming the byte, the flag, the signal, and the exact set of recipients.
  6. Explain why Ctrl+Shift+C is copy on Unix terminals rather than Ctrl+C.
  7. Explain what happens to the a byte between step 6 and the shell actually receiving it.
  8. Name the two places a timeout is used to disambiguate ESC, and who owns each.
  9. Point at the exact line in your own code where each of the 14 steps happens.
  10. Produce the debug log for one keystroke and explain every line of it.

Next: Lab 12 — The Windowed Renderer.