Architecture & Build Plan · Rev 5

Building astroterm

A phased plan for writing the whole terminal stack from scratch in Rust: a shell that parses and runs commands, the PTY layer that fakes a 1978 serial terminal for it, an X11 client spoken directly over a socket, and a glyph rasterizer that puts characters on screen. No crate does any part of the interesting work. Nine phases, each with a test that says when it is done — and it assumes no prior systems programming experience, so §1 defines every term before it is used.

Language
Rust 2021
Dependencies
1 — ABI only
Display
X11 wire protocol
Glyphs
PSF2 bitmap
Target
Linux Mint 22.3
Conformance
VT100 / VT220
§ 1

Systems programming primer

Everything after this section assumes Unix systems vocabulary. This section supplies it. Nothing here is specific to terminals — it is the shared language of every program that talks to a Unix kernel.

How to read this document

New to systems programming? Read §1 and §2 carefully — together they explain how a terminal works before any code exists. Then skim §3–§5 for the shape of the project, and treat §6–§9 as reference material you return to when you reach that phase. §17 is a glossary you can jump back to at any time.

Already know fork and file descriptors? Start at §3. Nothing in §1 or §2 will be new to you.

A · The kernel boundary

Process

A running program, with memory of its own.

When you launch ls, the kernel creates a process for it and gives it a number, the PID. Two copies of the same program running at once are two processes with entirely separate memory — neither can see the other's variables.

Kernel & user space

The OS core owns the hardware; your program does not.

The kernel controls memory, disks, the keyboard and the screen. Your program runs in user space and cannot touch any of it directly — it must ask. That wall is why one crashing program does not take the machine down with it.

System call

The only way to ask the kernel for anything. Everything bottoms out here.

Reading a file, creating a process, sending a byte — all syscalls. Linux has roughly 350. A syscall is not an ordinary function call; it is a CPU instruction that switches the processor into kernel mode and back.

write(1, "hi", 2) means "kernel, write 2 bytes to descriptor 1".

libc

A library that wraps syscalls in normal-looking functions.

C's printf("hi") formats the string, then issues a write syscall. Rust's std does the same underneath. libc adds buffering and convenience — it is not itself the kernel. This is the "Tier 1" floor discussed in §4.

B · Files and descriptors

"Everything is a file"

Unix's central idea, and the reason this project is possible.

Files, keyboards, terminals, network connections, pipes and many kernel objects are all reached through the same four syscalls: open, read, write, close. A terminal can therefore be read and written exactly like a text file.

File descriptor (fd)

A small integer naming something a process has open.

Not a pointer, not a handle object — literally an int like 3. It indexes a table the kernel keeps per process. open() hands you a new one; read/write take one. Because they are just numbers, they can be swapped around — which is the whole basis of redirection.

fd 0, 1 and 2

stdin, stdout, stderr — by convention, not by law.

Every process starts with these three open. Nothing forces them to point anywhere in particular. That is exactly how ls > out.txt works: the shell quietly points fd 1 at the file before running ls, and ls never notices the difference.

Device file

A path that is really a driver, not stored data.

/dev/ptmx holds no bytes on disk. Opening it asks the kernel's pseudoterminal driver to manufacture a new terminal for you. /dev/pts/3 is one specific terminal. Reading and writing these paths is a conversation with a driver.

ioctl

"I/O control" — the escape hatch for anything that isn't read or write.

ioctl(fd, REQUEST, &data) sends a numbered command to whatever driver sits behind that fd. Setting the terminal size, switching to raw mode and unlocking a pty are all ioctls.

Each has a magic constant — TIOCSWINSZ is 0x5414 — and an associated struct. That is why §5 talks about transcribing structs from kernel headers.

Socket

A pipe between unrelated programs, possibly across a network.

A Unix domain socket is one that appears as a path on disk. /tmp/.X11-unix/X0 is how any program reaches the X server. You connect() to it and then read and write it like any other fd — which is all §8's X11 client really does.

C · Processes and plumbing

fork()

Clones the current process. It returns twice.

After fork() there are two nearly identical processes continuing from the same line. In the parent it returns the child's PID; in the child it returns 0. You branch on the return value to discover which one you are. This confuses everybody the first time.

exec()

Replaces the running program, keeping the process.

Same PID, same open file descriptors, completely new code and memory. On success it never returns — the program that called it no longer exists. If your next line runs, exec failed.

fork + exec together

How every program on Unix is launched. There is no other way.

Fork to get a copy, adjust the copy (redirect descriptors, join a process group), then exec the real program. The gap between the two is where a shell does all its setup — and it is why the hazard in §9 exists at all.

waitpid()

The parent collects a finished child's exit status.

Until it does, the dead child lingers as a zombie: a process-table entry holding nothing but an exit code. A shell that forgets to wait leaks them steadily.

dup2(old, new)

"Descriptor new now refers to whatever old refers to."

dup2(file_fd, 1) means "stdout is now this file". Three calls like this implement the whole of >, < and |. This is the single most important trick in the shell.

pipe()

Creates two joined fds: what goes in one comes out the other.

ls | wc is: create a pipe, fork twice, dup2 the write end onto ls's stdout and the read end onto wc's stdin. Neither program knows it is in a pipeline.

Process group

A set of processes the kernel signals as a unit.

Every pipeline gets its own group, so Ctrl+C interrupts every stage at once rather than just the last one. The terminal tracks which group is in the foreground — that bookkeeping is the job control work in Phase 3.

poll()

"Sleep until any of these descriptors has something for me."

Without it you would either burn a CPU core spinning, or block on one fd and go deaf to the others. The entire event loop in §9 is a single poll call in a loop.

D · Signals

Signal

An interruption delivered by the kernel, out of nowhere.

It stops the program mid-flight, runs a handler, then resumes. SIGINT is Ctrl+C, SIGWINCH means the window was resized, SIGCHLD means a child exited, SIGTSTP is Ctrl+Z, and SIGKILL cannot be caught at all.

Signal handler

A function the kernel calls on delivery.

The catch: it can fire between any two machine instructions — including halfway through malloc, or in the middle of your own data structure update.

Async-signal-safe

The short list of functions a handler may legally call.

write is on it. Anything that allocates is not. If a handler calls malloc while the interrupted code was already inside malloc, the process deadlocks with no error at all. This is why §9 uses the self-pipe trick: the handler writes one byte to a pipe and nothing else, and the real work happens back in the main loop.

E · Terminals

Terminal (tty)

Originally furniture: a keyboard and screen on a serial cable.

To the operating system it is a character device — you read keystrokes from it and write output to it. The abbreviation tty is short for teletype, which is what these things were before they had screens.

Pseudoterminal (PTY)

A software-faked terminal: a pair of connected fds.

The master end is held by the emulator; the slave end is handed to the shell as its stdin, stdout and stderr. Bytes written to one come out of the other. The shell cannot tell it is not attached to real hardware, which is precisely the point.

Who provides the PTY?

The kernel does. You ask for one — you do not write one.

Opening /dev/ptmx makes the kernel manufacture a master/slave pair and run the line discipline between them. Writing your own would mean writing a kernel module.

So astroterm-pty is a client: it requests a pair, configures it, resizes it and pumps bytes. The emulator and shell around it are entirely yours. §4 has the full breakdown.

Line discipline

Kernel code sitting between the master and the slave.

Not a process — a layer inside the driver. It echoes what you type, holds a line until you press Enter, implements backspace, and turns control characters into signals. Most of what feels like "the terminal being helpful" is actually this.

Canonical mode

The default: input is buffered until Enter.

The line discipline collects a whole line, letting you edit it, and only then hands it to the program. This is why cat supports backspace despite containing no editing code whatsoever.

Raw mode

Every keystroke delivered instantly. No echo, no editing, no signals.

vim, less and htop all switch to it, because they want to react to j the moment you press it. Your own spike in Phase 0 will need to put its stdin into raw mode too.

termios

The struct holding every terminal setting.

Flags like ECHO and ICANON, the special characters, the speeds. You read it with the TCGETS ioctl, flip bits, and write it back with TCSETS. "Raw mode" concretely means clearing ECHO and ICANON, among others.

F · Bytes, bits and text

Byte, u8, hex

A byte is 8 bits, values 0–255. In Rust that type is u8.

Hexadecimal (0x1B) is base 16, used everywhere here because one hex digit is exactly four bits. 0x1B is 27 in decimal, and it is the ESC character that begins every escape sequence in §7.

Bitmask / bitflags

Many yes/no answers packed into one integer, one per bit.

attrs & BOLD != 0 tests a bit; attrs |= BOLD sets one; attrs &= !BOLD clears it. Used for cell attributes and termios flags because it is compact and extremely fast.

Framebuffer / ARGB

A big array of pixels — drawing is just writing integers.

One u32 per pixel, laid out row after row. ARGB names the four bytes inside it: alpha, red, green, blue. §8's renderer fills such an array and hands the whole thing to the X server with one request.

Tokenizer, parser, AST

The three stages of making sense of text.

The tokenizer chops ls -l | wc into pieces. The parser arranges those pieces into a tree — a pipeline containing two commands. That tree is the AST (abstract syntax tree), and the executor walks it. Phase 2 builds all three.

Ring buffer

A fixed-size buffer that overwrites its oldest entry when full.

Scrollback is one: keep the most recent 10,000 lines and silently drop anything older, so memory use has a hard ceiling no matter how much output scrolls past.

State machine

A program that is always in exactly one named state.

Each input moves it to another state and maybe performs an action. §7's escape-sequence parser is one: it reads a byte, decides where to go, and never needs to look backwards or ahead. This makes it both simple and very fast.

§ 2

What happens when you type ls

One command, traced end to end through every component. If you read only one section of this document, read this one — it is the whole system working, before any of it is abstracted into crates.

Assume astroterm is finished and running bash. You press two keys and hit Enter. Here is every hop, and who does the work at each one.

#WhoWhat happens
1YouPress the l key. The keyboard sends a hardware scancode over USB.
2KernelThe input driver turns the scancode into an input event and delivers it to the program listening for it — here, the X server.
3X serverSees that astroterm's window has keyboard focus. Writes a 32-byte KeyPress event into astroterm's socket.
4astrotermpoll() returns: the X11 socket is readable. Reads the event, decodes event type 2, keycode 46.
5astrotermLooks up keycode 46 in the keyboard mapping → keysym l → the byte 0x6C.
6astrotermwrite(master_fd, [0x6C], 1) — one byte into the PTY master.
7KernelThe line discipline receives 0x6C. Canonical mode, so it appends the byte to an internal line buffer and echoes it straight back toward the master.
8astrotermpoll() returns again — the master is readable. Reads back the very byte it just wrote.
9astrotermFeeds it to the parser. State is Ground and the byte is printable, so: write Cell{ch:'l'} at the cursor, advance the cursor, mark the row dirty.
10astrotermBlits the dirty row into the framebuffer and sends PutImage. You now see l on screen — and bash has not executed a single instruction.
11YouPress s (steps 1–10 repeat), then press Enter, which sends 0x0D.
12KernelThe line discipline sees the carriage return, translates it to 0x0A, and only now releases the whole buffered line "ls\n" to the slave side.
13bashIts blocked read(0, ...) finally returns "ls\n". This is the first bash has heard about any of it.
14bashTokenizes the line, builds an AST, searches $PATH, finds /bin/ls.
15bashCalls fork(). There are now two bash processes, both continuing from the same line.
16bash childCalls setpgid() to start a new process group, then execve("/bin/ls"). The bash code is gone, replaced by ls — but fds 0, 1 and 2 still point at the PTY slave, inherited across both the fork and the exec.
17lsReads the directory and calls write(1, ...). Because stdout is a terminal, it adds colour: ESC[0m, ESC[01;34m for directories.
18KernelThose bytes travel from the slave, through the line discipline, to the master.
19astrotermpoll() wakes. Reads the chunk. The parser now sees 0x1BEscape[CsiEntry → digits → m → dispatch SGR, setting the current colour. Printable bytes then fill cells.
20astrotermDirty rows blit, PutImage, and the listing appears.
21Kernells exits. The kernel sends SIGCHLD to bash.
22bashwaitpid() reaps it and reads exit status 0, then prints a fresh prompt — which travels the same path as steps 17–20.

The two surprises in that trace

Steps 7–10: the kernel echoed your keystroke, not bash. The character appeared on screen because the line discipline sent it back, not because any program decided to print it. Clear the ECHO flag in termios and typing becomes invisible — which is exactly how password prompts are implemented.

Steps 12–13: bash saw nothing at all until you pressed Enter. Every backspace and every cursor movement before that happened inside kernel code. Programs that need each keystroke as it arrives — vim, htop — switch the terminal to raw mode precisely to opt out of this.

Both facts are the reason §3 insists that the emulator and the shell never speak to each other directly. There is always a kernel in between, doing more than you would expect.

§ 3

What you are actually building

"A terminal" is three separate programs that happen to be adjacent. Getting this boundary right up front is the single decision the rest of the architecture hangs off.

The emulator is a program that draws a grid of characters and knows nothing about commands. The shell is an ordinary program that reads text and runs other programs, and knows nothing about pixels. Neither one talks to the other directly — the kernel sits between them, impersonating a DEC VT100 on the end of a serial cable, because that is the interface Unix standardised in the 1970s and never replaced.

That impersonation is the pseudoterminal, and the piece of it that surprises people is the line discipline: kernel code that echoes your keystrokes, buffers input until you press Enter, handles backspace, and converts Ctrl+C into a SIGINT delivered to the foreground process group. None of that is the shell's doing. It is why cat supports line editing despite containing no editing code at all.

astroterm your emulator X11 window keyboard capture cell grid + glyphs holds master fd KERNEL — PTY PAIR master from /dev/ptmx line discipline echo · canonical buffering erase · ^C → SIGINT slave /dev/pts/N astrosh your shell lex → parse → expand fork · dup2 · execvp job control fd 0,1,2 = slave keystroke bytes write(master) text + CSI seqs read(master) line of input read(0) prompt, output write(1) ls · vim · cargo inherit the same slave fork + exec
Figure 1 — The only data path that matters. Your emulator never speaks to your shell. It writes bytes to a file descriptor and reads bytes back; the kernel's line discipline sits in the middle doing echo, buffering and signal generation. Everything else in this document is an elaboration of one of these two arrows.

Two consequences fall straight out of Figure 1. First, the shell and the emulator can be built and shipped independently — you can run astrosh inside GNOME Terminal while it is half-finished, and run bash inside astroterm while that is half-finished. That is why the roadmap builds the shell first: it gives you a working artefact before any pixel is drawn.

Second, the emulator's job is narrower than it looks. It is a byte pump with a parser and a renderer bolted on. There is no "terminal logic" beyond interpreting the escape sequences in that stream.

§ 4

The from-scratch line

From scratch does not mean from nothing. It means from specifications rather than from someone else's implementation — and that line has to be drawn explicitly, because there is a hard floor beneath it.

Every layer below is something you could in principle write yourself. The question is only where writing it stops being this project and starts being a different one. The floor is the kernel syscall boundary: beneath it you are writing an operating system.

TierWhat lives thereStatusReasoning
4 · Terminal VT parser, cell grid, scrollback, shell lexer and executor, compositor Yours The project itself. Derived from the Williams diagram, XTerm ctlseqs and POSIX.
3 · Formats X11 wire protocol, PSF2 glyph format, Unicode Character Database tables Yours All three are published, stable, byte-level specifications. Implementing them is reading a document, not reverse-engineering.
2 · Syscall wrappers openpty, termios, ioctl, poll, signals, fork/exec Yours astroterm-sys. This is what nix and signal-hook would have done. Roughly 600 lines, and it teaches the PTY at the ioctl level.
1 · ABI bindings The libc crate — extern "C" declarations and constants Kept Contains no implementation. It is a transcription of the C headers, not a library. See the note below.
0 · Kernel The syscall interface itself Floor Irreducible. Below this you are not building a terminal.

One honest caveat about libc

Rust's std links libc on Linux regardless of what you do. Vec allocates through malloc; std::fs calls open. Avoiding that genuinely means #![no_std] plus your own allocator — a different project, and one that buys authenticity rather than independence.

So the line worth defending is "no crate does any terminal, protocol, or rendering work for me", not "libc is absent from the binary". The libc crate stays because it contains no logic — only extern "C" signatures and integer constants. If you want the dependency count to read zero, transcribe the two dozen declarations you actually need into astroterm-sys yourself. It produces a byte-identical binary; it is a bookkeeping choice, not an engineering one.

Which parts are actually ours

Worth stating outright, because the crate names invite a wrong reading. The emulator and the shell are ours completely. The pseudoterminal is not, and cannot be — it is kernel code. astroterm-pty is a client of the kernel's PTY driver, not an implementation of one.

ComponentWritten byWhat that means here
Terminal emulatorYouastroterm — window, parser, grid, renderer. Every line.
ShellYouastrosh — lexer, expansion, executor, job control. Every line.
VT parser, cell grid, glyph rasterizer, X11 client, Unicode tablesYouThe five supporting crates. Derived from published specs.
PTY driver and line disciplineLinux kernelLives in drivers/tty/. Replacing it means writing a kernel module — a different project entirely. You request a pair and configure it.
X serverX.OrgIt owns the screen and the input devices. You write a client that speaks its protocol — which is what Phase 6 is.
Keyboard and input driversLinux kernelScancodes into events. Steps 1–2 of the trace in §2.
The programs you run in itTheir authorsls, vim, htop. Your terminal's job is to host them faithfully, not to replace them.

Why a PTY at all — why not just connect the shell with pipes?

You could. pipe() would happily carry bytes between your emulator and your shell, and it is far less work. It also breaks almost everything, and the reasons are exactly the reasons pseudoterminals exist:

  • isatty(1) returns false, so ls drops its colours and grep changes its output format — programs behave differently when they think they are being piped to a file.
  • vim, htop and less refuse to start at all. They need a terminal to put into raw mode.
  • There is no window size to report, so full-screen programs cannot lay themselves out and SIGWINCH has no meaning.
  • Ctrl+C does nothing. Signal delivery to a foreground process group is kernel terminal bookkeeping, and a pipe has none of it.

A PTY is precisely "a pipe that also does those four things". That is the entire reason the kernel offers one.

One nuance, since it is a fair question: you could write your own line discipline. Put the PTY in raw mode and implement echo, line buffering, backspace and Ctrl+C handling in your own code — some programs do. But you would still need the kernel's PTY underneath for isatty, termios and process-group signals. The line discipline is optional; the pseudoterminal is not.

§ 5

Module architecture

A Cargo workspace with nine members. Two organising rules: exactly one crate touches the kernel, and three crates touch nothing at all.

astroterm binary · event loop astrosh binary · the shell astroterm-render grid + glyphs → pixels astroterm-x11 wire protocol client astroterm-pty pair + child + resize astroterm-font PSF2 → bitmaps astroterm-core parser · grid astroterm-unicode UCD-generated tables astroterm-sys the only kernel caller libc — extern "C" declarations and constants no implementation · the from-scratch floor width(ch) same syscall layer extern "C" shaded = pure no syscalls, fully testable
Figure 2 — One crate touches the kernel; three touch nothing. Funnelling every syscall through astroterm-sys means the unsafe in this project lives in one auditable place. The three shaded crates are pure functions over bytes, which is what makes the testing in §11 possible without a window, a PTY, or a child process.
CrateKindResponsibilityReplaces
astroterm-syslibRaw syscall wrappers: /dev/ptmx + TIOCGPTN/TIOCSPTLCK, termios via TCGETS/TCSETS, poll, fork/execve, self-pipe signal handlingnix, signal-hook
astroterm-unicodelibUAX #11 width and UAX #29 grapheme tables, generated at build time from the UCD text filesunicode-width, unicode-segmentation
astroterm-corelibCell, Grid, cursor, scrollback ring, alt screen, the VT state machine, CSI/OSC dispatchvte, bitflags
astroterm-fontlibPSF2 parser → glyph bitmaps and a codepoint→glyph map. TrueType rasterizer later.SDL_ttf, FreeType
astroterm-x11libSocket, MIT-MAGIC-COOKIE-1 handshake, XID allocation, CreateWindow, PutImage, event decoding, keycode→keysymsdl2, Xlib, xcb
astroterm-ptylibSession management on top of astroterm-sys: ask the kernel for a pair, spawn the child with the slave as its controlling terminal, apply TIOCSWINSZ on resize, reap on exit. A client of the kernel driver — see §4.
astroterm-renderlibComposite Grid + glyphs into an ARGB buffer; damage tracking. Writes to a Surface trait, so a DRM backend can be added later.
astrotermbinConfig, the single-threaded poll loop of §9
astroshbinLexer, expander, AST, executor, builtins, job control
§ 6

Core data structures

Three types carry almost all the state. Get their shape right and the rest of the emulator is mechanical.

astroterm-core/src/grid.rs

// 16 bytes packed. A 200-col x 10,000-line scrollback is then ~32 MB —
// acceptable. If it isn't, intern the attributes and store a u32 index.
#[derive(Clone, Copy, PartialEq)]
pub struct Cell {
    pub ch:    char,      // base char; see WIDE_TRAIL below
    pub fg:    Color,     // Indexed(u8) | Rgb(u8,u8,u8) | Default
    pub bg:    Color,
    pub attrs: Attrs,     // hand-rolled bitflags — 20 lines, no crate
}

pub struct Grid {
    cols:      usize,
    rows:      usize,
    cells:     Vec<Cell>,               // rows * cols, row-major, index = row * cols + col
    scrollback: VecDeque<Vec<Cell>>,    // ring, capped at SCROLLBACK_LINES (10_000)
    cursor:    Cursor,                  // row, col, visible, and the DECSC saved copy
    region:    (usize, usize),          // DECSTBM top/bottom margins, default (0, rows-1)
    tabstops:  Vec<bool>,               // per column; HTS sets, TBC clears
    alt:       Option<Box<Grid>>,       // ESC [ ? 1049 h swaps the whole grid out
    dirty:     Vec<bool>,               // one flag per row — the renderer's damage list
}

Three decisions worth calling out

A flat Vec<Cell>, not Vec<Vec<Cell>>. One allocation, cache-friendly row scans, and scrolling within a margin region becomes a copy_within rather than a pointer shuffle.

Wide characters occupy two cells. A CJK ideograph or an emoji is written into the cell at the cursor, and the next cell is stamped with the WIDE_TRAIL attribute and skipped by the renderer. Getting this wrong is the single most common source of "the whole line is shifted by one" bugs. The width comes from astroterm-unicode; never guess from the codepoint range.

The alt screen is a whole second Grid, swapped wholesale. That is why quitting vim restores your prompt untouched — nothing was restored, the original grid was never modified.

§ 7

The escape-sequence parser

The part that sounds hardest and isn't. It is a byte-at-a-time state machine with eight states, specified precisely by Paul Williams' VT500 diagram, and it never needs to look ahead or back.

The byte stream from the child is mostly printable text, with control sequences woven in. A sequence starts with ESC (0x1B), and the byte that ends it determines what it means:

ESC [ 2 J          // ED   — erase display
ESC [ 12 ; 40 H    // CUP  — cursor to row 12, col 40
ESC [ 1 ; 31 m     // SGR  — bold, foreground red
ESC [ ? 1049 h     // DEC private mode set — switch to alt screen
ESC [ ? 25 l       // DEC private mode reset — hide cursor
ESC ] 0 ; title BEL // OSC  — set window title
Ground printable → put in cell Escape saw 0x1B CsiEntry clear param buf CsiParam accumulate ints OscString collect until terminator 0x1B [ 0-9 ; : ] final byte 0x40–0x7E → dispatch, reset to Ground BEL or ST
Figure 3 — Five of the eight states, carrying ~95% of real traffic. The three omitted (CsiIntermediate, DcsPassthrough, Ignore) handle rarer sequences and malformed input. The machine is byte-at-a-time and allocation-free, which matters: it runs on every byte a program writes.

Implement it as an enum State and a match in fn advance(&mut self, byte: u8, sink: &mut impl Perform), where Perform is a trait with print, execute, csi_dispatch, osc_dispatch, esc_dispatch. Splitting the machine from the action behind a trait is what lets §11's tests drive it with a recording sink.

One rule saves a lot of pain: an unrecognised sequence must be silently discarded, never printed. The single fastest way to make a terminal look broken is to emit garbage when a program sends something you have not implemented yet.

No oracle crate — build the corpus instead

Earlier revisions of this plan kept Alacritty's vte as a dev-dependency to differential-test the parser against. That is gone: it implements exactly the diagram above, and having it in the repo undermines the point of writing it.

The replacement is better anyway. Capture real byte streams with script -c "vim +q" -f /dev/null and friends, check them into tests/corpus/, and assert your parser's action sequence against a committed snapshot. Then let vttest be the external judge. You get a regression suite specific to the programs you actually run, rather than agreement with someone else's implementation.

§ 8

The display stack

No SDL2, no Xlib, no FreeType. A unix socket, a documented binary protocol, and a bitmap font — which is, not coincidentally, exactly how a real VT100 put characters on a screen.

Talking to X11 directly

X11 is a binary protocol over a socket, and it has been stable since 1987. You connect to /tmp/.X11-unix/X0 (the display number comes from $DISPLAY), complete a handshake, and then exchange length-prefixed requests and events. Roughly 400 lines gets you a window with pixels in it.

astroterm X server connect(AF_UNIX, /tmp/.X11-unix/X0) setup request + MIT-MAGIC-COOKIE-1 16 bytes read from ~/.Xauthority — omit it and you get "Authorization required" setup reply: resource-id-base, root, visual, depth you mint your own XIDs: base | (counter & mask) CreateWindow (1) + CreateGC (55) + event mask MapWindow (8) Expose (event 12) — time to draw PutImage (72) — ZPixmap, your ARGB buffer KeyPress (event 2) — a hardware keycode, not a character decode via GetKeyboardMapping (101): keycode → keysym → bytes
Figure 4 — The full connection sequence, and the two steps that block everyone. The highlighted handshake fails silently-ish if you skip the auth cookie, and KeyPress delivers a hardware keycode that means nothing until you have fetched the keyboard mapping. Both are cheap once you know; both cost an evening if you don't.

Three practical notes. Request sizes are capped — a full-window PutImage at 1920×1080 exceeds the default maximum request length, so either split the blit by row bands or negotiate the BIG-REQUESTS extension. Since you are damage-tracking anyway, per-band blits fall out naturally. Resize arrives as ConfigureNotify (event 22), which is where you recompute rows and cols and issue the TIOCSWINSZ ioctl. Closing the window requires cooperating with the window manager: intern the WM_PROTOCOLS and WM_DELETE_WINDOW atoms and watch for a ClientMessage, or your terminal cannot be closed by its own title bar.

For throughput, MIT-SHM lets you hand the server a shared memory segment instead of pushing every frame down the socket. It is a worthwhile optimisation, and firmly a later phase — the socket path is fast enough to build and debug against.

Glyphs from a bitmap font

Your machine already has 456 PSF fonts in /usr/share/consolefonts/. PSF2 is about as simple as a binary format gets: a 32-byte header, then numglyph fixed-size records, each a packed bitmap of height rows at (width + 7) / 8 bytes per row. An optional Unicode table at the end maps codepoints to glyph indices.

astroterm-font/src/psf.rs — the whole format

const PSF2_MAGIC: u32 = 0x864a_b572;

pub struct Psf2Header {
    magic:         u32,   // 0x864ab572, little-endian
    version:       u32,   // 0
    headersize:    u32,   // offset of the first glyph, usually 32
    flags:         u32,   // bit 0 = a unicode table follows the glyphs
    numglyph:      u32,
    bytesperglyph: u32,
    height:        u32,   // e.g. 16
    width:         u32,   // e.g. 8  → your cell size is 8x16 px
}

// Rendering one cell is then: for each of `height` rows, walk the bits
// left to right and write fg where set, bg where clear. No anti-aliasing,
// no hinting, no shaping — which is exactly what a VT100 did.
fn blit(&self, g: GlyphId, cell: &Cell, buf: &mut [u32], stride: usize, x: usize, y: usize) {
    for row in 0..self.height {
        let bits = self.row_bits(g, row);
        for col in 0..self.width {
            let on = bits & (0x80 >> (col % 8)) != 0;
            buf[(y + row) * stride + x + col] = if on { cell.fg.argb() } else { cell.bg.argb() };
        }
    }
}

That is the entire renderer. The upgrade path — parsing TrueType glyf outlines and scan-converting quadratic Béziers with anti-aliasing — is a genuinely satisfying one-to-two week project, and it is Phase 8 precisely because it sits between you and ever seeing a character on screen if you attempt it first.

§ 9

Concurrency: one thread, one poll

Dropping SDL2 removes the reason threads existed. X11 is a socket and the PTY master is a file descriptor, so a single poll() handles both — and that erases an entire class of bug.

A previous revision of this plan had a reader thread pushing chunks down an mpsc channel because SDL2 insists on pumping events from the thread that created the window. With a hand-rolled X11 client that constraint disappears: you own the socket, so you can wait on it and on the PTY at the same time in one place.

Signals fold into the same loop via the self-pipe trick: the handler for SIGWINCH and SIGCHLD does nothing but write() one byte to a pipe — which is async-signal-safe — and the read end becomes a third fd in the poll set. All the real work happens in the loop, where allocation and error handling are ordinary code.

poll(&mut fds, -1) x11 · pty master · signal pipe decode X11 event keycode → keysym → bytes write(pty_master) and ConfigureNotify → TIOCSWINSZ read(pty_master) → parser → Grid mark rows dirty drain the fd fully first blit dirty rows → PutImage flush socket · 16 ms budget POLLIN x11 POLLIN pty next iteration
Figure 5 — The whole program, in one loop, on one thread. No channels, no locks, no Send bounds, and no possibility of the fork hazard below. Draining the PTY fd completely before rendering is what keeps cat on a large file fast — you coalesce thousands of writes into one blit.

Why this matters: the fork hazard you are now avoiding

After fork() in a multi-threaded process, POSIX permits the child to call only async-signal-safe functions until it execs. Rust's allocator is not one of them. If another thread held the malloc lock at the instant of the fork, that lock is held forever in the child, and the next allocation — including one hidden inside format! or a panic — deadlocks silently. No message, no backtrace.

Staying single-threaded makes the whole category unreachable. But astrosh forks on every command, so the discipline is still worth keeping, and it becomes mandatory the moment anyone adds a thread:

  • Allocate everything the child needs (argv, envp, path) before forking.
  • Between fork and exec, call only raw syscalls: dup2, close, setpgid, execve.
  • On exec failure use _exit(127), never std::process::exit — the latter runs atexit handlers and flushes buffers belonging to the parent.

astrosh/src/exec.rs — one stage of a pipeline

// Every allocation happens here, in the parent, before the fork.
let argv: Vec<CString> = build_argv(&cmd)?;
let (rd, wr) = sys::pipe()?;

match unsafe { sys::fork()? } {
    0 => {
        // Async-signal-safe territory. No println!, no String, no ?, no panic.
        sys::dup2(wr, 1);
        sys::close(rd);
        sys::close(wr);
        sys::setpgid(0, pgid);          // job control: join the group
        sys::execve(&argv[0], &argv, &envp);
        unsafe { sys::_exit(127) }       // command not found
    }
    child => {
        sys::setpgid(child, pgid).ok(); // race-free: both sides set it
        sys::close(wr)?;                // parent must close its copy or the
        Ok((child, rd))                 // reader never sees EOF
    }
}

Two classic bugs are pre-empted above. Both parent and child call setpgid because either may run first, and whichever loses the race would otherwise operate on a process group that does not exist yet. And the parent must close its copy of every pipe end it handed to a child — otherwise the pipe never reports EOF, and ls | wc -l hangs forever with wc waiting for a writer that is really you.

§ 10

The phased roadmap

Nine phases. Each ends in something you can run, and each has a concrete test that decides whether it is finished. Estimates assume evenings and weekends — call it five months end to end.

  1. 0

    Spike: prove the PTY in 60 lines

    ~1 weekend·throwaway·Low risk

    Install the toolchain, then write a single main.rs that opens /dev/ptmx, forks, execs /bin/bash in the child, puts your own stdin in raw mode, and poll()s both fds copying bytes each way. Run it inside your existing terminal. No window, no parser.

    Why first

    It makes Figure 1 concrete in an afternoon, and everything after this is a refinement of it.

    Done when

    You type ls, see coloured output, and Ctrl+C interrupts a sleep 100 instead of killing your program.

  2. 1

    astroterm-sys — your own syscall layer

    1–2 weeks·replaces nix + signal-hook·Unsafe-heavy

    Turn the spike's ad-hoc calls into a real crate. openpty is not magic: open /dev/ptmx, TIOCSPTLCK to unlock, TIOCGPTN to get the number, open /dev/pts/N. termios is TCGETS/TCSETS with a struct you transcribe from the kernel headers.

    Scope
    • PTY pair allocation
    • termios: raw mode, ECHO/ICANON flags
    • TIOCSWINSZ / TIOCGWINSZ
    • poll, read, write, pipe
    • fork, execve, waitpid, setpgid
    • Self-pipe signal handling
    Done when

    The Phase 0 spike is rewritten on top of it with zero direct libc:: calls outside this crate, and still works.

  3. 2

    astrosh v1 — REPL, pipes, redirection

    2–3 weeks·first real deliverable·Low risk

    Read a line, tokenise it respecting "double", 'single' and backslash escapes, build a small AST, execute it on astroterm-sys. Builtins (cd, exit, export, pwd) run in the parent — a forked child changing directory achieves nothing.

    Scope
    • Tokeniser with quoting
    • AST: command, pipeline, redirect list
    • > >> < 2> and |
    • $PATH search
    Done when

    cat < in.txt | tr a-z A-Z | sort -u > out.txt produces the right file, cd .. persists, and an unknown command errors instead of leaving a zombie.

  4. 3

    astrosh v2 — expansion and job control

    3–4 weeks·Hardest phase

    Expansion is fiddly but mechanical: $VAR, ${VAR:-default}, ~, globs, $(command substitution), then && and ||. Job control is the genuinely hard part, and it is where most hobby shells stop.

    Job control mechanics
    • Every pipeline gets its own process group
    • tcsetpgrp hands over the terminal
    • Shell must ignore SIGTTOU while doing so, or it stops itself
    • waitpid with WUNTRACED
    • fg / bg / jobs
    Done when

    Ctrl+Z on vim returns you to the prompt, jobs lists it Stopped, fg restores it with the screen intact, and Ctrl+C kills the child without touching the shell.

  5. 4

    astroterm-core — parser and grid, headless

    3–4 weeks·no graphics yet·Detail-heavy

    The emulator's brain, with no window at all. The state machine from §7, the Grid from §6, and the CSI dispatch table. Render to plain text in tests. Stub character width as 1 for now; Phase 5 swaps in real tables and your golden tests will show the diff.

    Minimum sequence set
    • CUP, CUU/CUD/CUF/CUB, CNL/CPL
    • ED, EL, ICH, DCH, IL, DL
    • SGR incl. 256-colour and 24-bit
    • DECSTBM, IND/RI, DECSC/DECRC
    • DEC modes ?1049, ?25, ?7, ?2004
    • OSC 0/2 (title), OSC 52 (clipboard)
    Done when

    Golden-file tests pass: capture real output with script -c "htop -n1", feed the bytes in, and assert the rendered grid matches a checked-in snapshot.

  6. 5

    astroterm-unicode — tables from the UCD

    ~1 week·replaces two crates·Low risk

    A build.rs that parses EastAsianWidth.txt and GraphemeBreakProperty.txt from the Unicode Character Database and emits a sorted range table plus a binary-search lookup. Check the UCD files into the repo so builds stay reproducible and offline.

    Why it's a phase

    Deriving the tables from the published data files is genuinely from scratch; hand-copying someone's generated table is not.

    Done when

    A line of CJK text and a ZWJ emoji sequence each occupy the right number of cells, verified by a golden test, and unicode-width appears nowhere.

  7. 6

    astroterm-x11 — a window on screen

    2–3 weeks·replaces SDL2·High risk

    The sequence in Figure 4. Get to a window filled with a solid colour first, then a window that reports key presses. Resist the urge to render text until both work — debugging the handshake and the keymap at the same time as the compositor is how this phase goes wrong.

    Order of attack
    • Socket + setup with auth cookie
    • CreateWindow / MapWindow
    • PutImage a solid colour
    • GetKeyboardMapping + keysym decode
    • ConfigureNotify → resize
    • WM_DELETE_WINDOW atom
    Done when

    A window appears, fills with a gradient, prints the right character to stdout for every key you press including modifiers, resizes cleanly, and closes from its own title bar.

  8. 7

    astroterm-font + render — the first real terminal

    2–3 weeks·everything connects·Medium risk

    Parse a PSF2 from /usr/share/consolefonts/, composite Grid plus glyphs into an ARGB buffer, and wire the poll loop from Figure 5. This is the phase where the eight crates become one program.

    Scope
    • PSF2 parser + unicode table
    • Cell → glyph blit
    • Damage-tracked band blits
    • Key → escape sequence mapping (incl. application cursor mode)
    • Mouse selection + clipboard via OSC 52
    Done when

    vim, htop and less render correctly inside astroterm running bash, and resizing reflows them live.

  9. 8

    Integration, conformance, and TrueType

    Ongoing·the long tail·Incremental

    Point astroterm at astrosh — the moment the project closes on itself. Then vttest, the MIT-SHM optimisation, and a TrueType rasterizer as a self-contained sub-project: sfnt container, cmap, loca, glyf, quadratic Béziers, scanline anti-aliasing.

    Done when

    You use it as your daily driver for a week without reaching for GNOME Terminal, and vttest menus 1, 2 and 11 pass cleanly.

§ 11

Testing strategy

The three pure crates from Figure 2 are what make this tractable. Bytes in, grid out, no mocking required.

LayerTechniqueWhat it catches
ParserTable-driven unit tests: byte string → expected Vec<Action> via a recording Perform sinkOff-by-one in parameter parsing, missing defaults (ESC [ H means row 1 col 1, not row 0)
ParserCorpus replay: real streams captured with script -c, checked into tests/corpus/Regressions against the programs you actually run
GridGolden snapshots of the rendered text gridScroll-region and alt-screen bugs, the ones you would otherwise find by eye
GridProperty tests: after any op sequence, cursor in bounds and cells.len() == rows * colsResize bugs, which is where the invariants actually break
UnicodeAssert generated tables against a handful of known codepoints per categoryParser bugs in build.rs, silent table truncation
X11Record the request byte stream and compare against a captured xtrace of a working clientWrong opcodes, bad padding — X11 pads every request to 4 bytes and it is easy to miscount
Emulatorvttest, the standard VT100/VT220 conformance suiteEverything you did not know existed
ShellRun a .sh under both astrosh and dash, diff stdout and exit codesQuoting, expansion and exit-status divergence from POSIX

Set up the golden-snapshot harness in Phase 4, not Phase 8. Retrofitting it after the renderer exists means debugging through pixels, which is many times slower than debugging through a text dump of the grid.

§ 12

Risk register

The things most likely to cost you a frustrating weekend, and the mitigation for each.

RiskSevWhereMitigation
X11 auth and padding — the server closes the connection with a terse error, or silently ignores a malformed request. Every request is padded to a 4-byte boundary and the length field is in units of 4 bytes. HighPhase 6 Read ~/.Xauthority properly before anything else. Compare your byte stream against xtrace output from a working client.
Job control — process groups, tcsetpgrp, SIGTTOU. Symptoms are bizarre: the shell suspends itself, or Ctrl+C kills everything. HighPhase 3 Read the POSIX rationale before writing any of it. Test with vim and sleep, not toy programs.
Keycode → keysym — X11 gives hardware keycodes. Modifiers, shift levels and groups all factor into which keysym you get, and getting it wrong makes half the keyboard wrong. MedPhase 6 Implement the core Latin path first and print unmapped keycodes loudly. Full XKB is out of scope.
Hand-written ioctl structs — a wrong field offset in termios or winsize corrupts memory silently rather than failing. MedPhase 1 Assert size_of against the C value in a test. Transcribe from /usr/include/asm-generic/, not from memory.
Unicode width — one wrong width shifts an entire line, and it shows up only with CJK or emoji. MedPhases 4, 5 Stub to 1 in Phase 4 and treat Phase 5 as the fix. Never infer width from codepoint ranges by hand.
Render performance — a naive full-window PutImage per frame saturates the socket, and cat on a large file crawls. MedPhase 7 Damage tracking from the first commit; drain the PTY fd fully before blitting so thousands of writes coalesce into one frame.
Scope creep into a scripting languageif, while, functions, arrays. Each is reasonable; together they are a second project. LowPhase 3 Explicitly deferred in §14. Interactive use needs none of it.
§ 13

Dependencies

One, and it contains no code.

Cargo.toml — every crate in the workspace

[dependencies]
libc = "0.2"   # astroterm-sys only. extern "C" declarations + constants.
               # Every other crate in the workspace has an empty [dependencies].

Everything a conventional build would have pulled in, and what replaces it:

Not usedWhat it would have doneReplaced byCost
sdl2 + SDL_ttfWindow, input, font rasterizationastroterm-x11 + astroterm-font~4–6 wks
nixTyped syscall wrappersastroterm-sys~1–2 wks
signal-hookSafe signal handlingSelf-pipe trick in astroterm-sys~1 day
vteThe VT state machineastroterm-core §7incl. Ph 4
unicode-width
unicode-segmentation
UAX #11 and #29 tablesastroterm-unicode, generated from the UCD~1 wk
bitflagsThe Attrs typeHand-rolled consts and bit ops~20 lines

Roughly seven extra weeks over a dependency-using build, concentrated almost entirely in the display stack. Whether that is a good trade depends on what the project is for — and for a side project whose purpose is understanding, it plainly is.

§ 14

Explicitly out of scope

Named here so that skipping them is a decision rather than an omission.

Shell scripting

if, while, case, functions, arrays. Interactive use needs none of them, and they double the parser.

Wayland

The protocol is XML-generated and buffer sharing is far more ceremony. Mint runs X11; revisit only if that changes.

GPU rendering

A glyph atlas on wgpu is a second project. CPU blitting is comfortably fast enough at 200×50 cells.

Full XKB

Layout groups, compose keys, dead keys. Implement the Latin path and print unmapped keycodes.

Sixel & Kitty graphics

Inline image protocols. Orthogonal to the core, and only meaningful once everything else is solid.

Tabs & splits

Multiplexing is a layer above the emulator. tmux already runs inside anything you build.

Ligatures & shaping

Needs full text shaping, which fights the fixed-cell grid model outright.

Reflow on resize

Re-wrapping scrollback when the window narrows. Genuinely hard; almost every emulator got it wrong first.

Windows / macOS

ConPTY is a different API with different semantics. Keep the PTY layer behind a trait, port later if ever.

§ 15

Environment setup

A pleasing side effect of building from scratch: you need to install less than the dependency-using plan required. No SDL2, no X11 development headers, no font libraries.

Verified present on this machine already:

  • XDG_SESSION_TYPE=x11, DISPLAY=:0, socket at /tmp/.X11-unix/X0
  • ~/.Xauthority — 119 bytes, holding your MIT-MAGIC-COOKIE-1
  • 456 PSF console fonts in /usr/share/consolefonts/
  • glibc 2.39, gcc 13.3, x86_64

Toolchain — Linux Mint 22.3

# Rust, via rustup (the apt package lags badly)
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh
source "$HOME/.cargo/env"
rustc --version

# Linker only. No SDL2, no libx11-dev, no libfreetype-dev.
sudo apt install -y build-essential

# Debugging aids: vttest for conformance, xtrace to compare X11 byte streams
sudo apt install -y vttest xtrace

# Pick a font for Phase 7 — these are already on your disk
zcat /usr/share/consolefonts/Uni3-TerminusBold16.psf.gz > assets/font.psf

Workspace skeleton

cd ~/Desktop/"terminal project"
for c in sys pty unicode core font x11 render; do cargo new --lib astroterm-$c; done
cargo new --bin astroterm
cargo new --bin astrosh

cat > Cargo.toml <<'EOF'
[workspace]
resolver = "2"
members  = [
  "astroterm-sys", "astroterm-pty", "astroterm-unicode", "astroterm-core",
  "astroterm-font", "astroterm-x11", "astroterm-render",
  "astroterm", "astrosh",
]
EOF

git init && git add -A && git commit -m "workspace skeleton"

One environment note worth knowing early: $TERM tells a program which escape sequences you claim to understand. While developing, set TERM=xterm-256color for the child — implementing the xterm subset is the pragmatic target, since that is what every program actually assumes. Publishing your own terminfo entry is a Phase 8 nicety.

§ 16

References

With no dependencies, specifications become the dependencies. These are the documents you are implementing.

  • A parser for DEC's ANSI-compatible video terminals — Paul Williams The canonical state diagram behind §7. Read it before writing the parser, not after.
  • X Window System Protocol, version 11 The specification you implement in Phase 6. Appendix B (the encoding) is the part you will live in — it gives the exact byte layout of every request and event.
  • XTerm Control Sequences — Thomas Dickey The de-facto specification for what a modern terminal must support. The reference you keep open for the whole project.
  • GNU libc manual — Job Control The one document that makes Phase 3 tractable. Its "Initializing the Shell" section is effectively a specification for the hardest code you will write.
  • PSF font format — Andries Brouwer The complete PSF1 and PSF2 specification, in about two pages. Everything Phase 7's font parser needs.
  • UAX #11 East Asian Width and UAX #29 Text Segmentation The rules behind Phase 5, plus the EastAsianWidth.txt and GraphemeBreakProperty.txt data files your build.rs parses.
  • st — the suckless terminal A complete working emulator in ~5,000 lines of readable C, small enough to read end to end. Uses Xlib rather than raw protocol, but the grid and escape handling are the clearest reference there is.
  • vttest The VT100/VT220 conformance suite from Phase 8's exit criteria. Brutal and extremely informative.
  • POSIX Shell Command Language What astrosh is measured against in §11. Consult it for quoting and expansion order; do not read it front to back.
§ 17

Glossary

Every term this document uses, defined in one or two sentences. 56 entries, alphabetical. §1 explains the same concepts in the order they build on each other; this is for looking one up mid-read.

alternate screen
The second, blank Grid that full-screen programs switch to with ESC [ ? 1049 h. Quitting vim restores your prompt because the original grid was never touched.
AST
Abstract syntax tree — the tree a parser builds. ls -l | wc becomes a pipeline node with two command children.
async-signal-safe
The short list of functions legal to call inside a signal handler. write qualifies; anything that allocates does not. Violating it deadlocks silently.
ARGB
A pixel packed into one u32 as alpha, red, green, blue — one byte each.
bitmask
Many true/false values packed one-per-bit into an integer. x & FLAG tests, x |= FLAG sets.
canonical mode
The terminal's default: the kernel buffers input until Enter and handles editing itself. Opposite of raw mode.
Cell
One character position on screen: a character plus foreground colour, background colour, and attributes. The grid is an array of these.
CSI
Control Sequence Introducer — the ESC [ that begins most escape sequences.
damage tracking
Redrawing only the rows that changed instead of the whole window. The difference between a fast terminal and a slow one.
DEC private mode
Escape sequences with a ? after the CSI, controlling terminal behaviour rather than drawing. ?25 is cursor visibility, ?1049 the alternate screen.
device file
A path that is really a driver, not stored bytes. Opening /dev/ptmx asks the kernel to create a new pseudoterminal.
dup2(old, new)
Makes descriptor new point at whatever old points at. The mechanism behind every redirect and pipe.
ECHO / ICANON
Two termios flags. ECHO makes the kernel print what you type; ICANON makes it buffer until Enter. Clearing both is most of what raw mode means.
escape sequence
A run of bytes starting with ESC (0x1B) that means a command rather than text — move the cursor, set a colour, clear the screen.
exec()
Replaces the program running in a process, keeping its PID and open descriptors. Never returns if it succeeds.
file descriptor
A small integer naming something a process has open. Index into a per-process kernel table. 0, 1 and 2 are stdin, stdout, stderr.
fork()
Clones the calling process. Returns twice — the child's PID in the parent, 0 in the child.
framebuffer
A plain array of pixels, one per screen position, laid out row by row. Drawing means writing integers into it.
glyph
The drawn shape of a character. A font maps characters to glyphs; the rasterizer turns a glyph into pixels.
Ground
The parser's resting state, where an incoming byte is ordinary text to be placed in a cell.
hex
Base 16, written 0x1B. One hex digit is exactly four bits, which is why byte values are written this way.
ioctl
"I/O control" — a numbered command sent to a driver for things that do not fit read or write, such as setting the terminal size.
job control
The machinery letting you suspend a program with Ctrl+Z and resume it with fg. Built on process groups and tcsetpgrp.
kernel
The core of the operating system. It owns the hardware; your program must ask it for everything via syscalls.
keycode / keysym
X11 gives you a keycode, a meaningless hardware number. Looking it up in the keyboard map gives a keysym, the actual symbol, which you then turn into bytes.
libc
The C standard library — ordinary functions wrapping syscalls, plus buffering and conveniences. Not the kernel.
line discipline
Kernel code between a PTY's master and slave. Echoes input, buffers lines, handles backspace, turns Ctrl+C into SIGINT.
master / slave
The two ends of a pseudoterminal. The emulator holds the master; the shell gets the slave as its stdin, stdout and stderr.
MIT-MAGIC-COOKIE-1
The X11 authentication scheme. A 16-byte secret stored in ~/.Xauthority that you must send in the setup request or the server refuses you.
OSC
Operating System Command — escape sequences of the form ESC ] ... BEL, used for the window title and clipboard access.
PID
Process identifier — the number the kernel uses to name a running process.
pipe()
Creates two joined descriptors; bytes written to one emerge from the other. The plumbing behind |.
poll()
Blocks until at least one of several descriptors has data available. Lets one thread wait on many sources at once.
process
A running program with its own memory and PID.
process group
A set of processes the kernel signals together. Every pipeline gets one, so Ctrl+C reaches all its stages.
PSF2
A bitmap font format: a 32-byte header then fixed-size packed glyph bitmaps, one bit per pixel. What console fonts use.
PTY
Pseudoterminal — a kernel-provided pair of descriptors that impersonates the serial terminal hardware Unix was designed for.
PutImage
The X11 request that copies a block of your pixels into a window. Opcode 72.
raw mode
Every keystroke delivered immediately, with no echo, no line buffering and no signal generation. What vim and htop use.
ring buffer
A fixed-size buffer that discards its oldest entry when full. Used for scrollback so memory has a hard ceiling.
scrollback
The lines that have scrolled off the top of the screen and are still held in memory.
self-pipe trick
A signal handler writes one byte to a pipe and does nothing else; the main loop notices the pipe is readable and does the real work. Sidesteps async-signal-safety entirely.
SGR
Select Graphic Rendition — the ESC [ ... m sequences that set colour, bold, underline and so on.
signal
An asynchronous notification from the kernel that interrupts a process. SIGINT is Ctrl+C, SIGWINCH means the window resized.
socket
A bidirectional channel between programs. A Unix domain socket lives as a path on disk, like /tmp/.X11-unix/X0.
state machine
A program always in exactly one named state, where each input causes a transition and perhaps an action.
syscall
A system call — the only way user-space code asks the kernel to do anything. A CPU instruction, not a function call.
termios
The struct holding every terminal setting: flags, special characters, speeds. Read with TCGETS, written with TCSETS.
TIOCSWINSZ
The ioctl that tells the kernel a terminal's new size in rows and columns. The kernel then sends SIGWINCH to the foreground program.
tty
Short for teletype. The general term for a terminal device.
UCD
Unicode Character Database — the published data files from which character width and grapheme-clustering tables are generated.
user space
Where ordinary programs run, walled off from hardware. The other side of the wall is kernel space.
VT100
The 1978 DEC video terminal whose escape sequences became the de-facto standard everything still implements.
waitpid()
Collects a finished child's exit status. Until called, the dead child remains a zombie.
XID
An X11 resource identifier. The client mints its own from a base and mask handed out in the setup reply.
zombie
A finished process still occupying a process-table slot because its parent has not called waitpid yet.