Architecture & Build Plan · Rev 9

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 — 42 concepts in seven clusters, each with commands you can run right now to watch the idea happen on your own machine. 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.

Try this — it takes ten seconds
strace -e trace=write echo hi
hi
write(1, "hi\n", 3)                     = 3

That one line is the entire system call. echo opened nothing and formatted nothing — it asked the kernel to put 3 bytes on descriptor 1, and the kernel returned 3 to say it did.

Everything in this document eventually reduces to a line like that. strace shows you every syscall a program makes, with its arguments and its result — it is the most useful tool in systems programming and you already have it installed.

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.

Try this — it takes ten seconds
ls -l /proc/self/fd
tty

# now the same thing, redirected — watch entry 1 change
ls -l /proc/self/fd > /tmp/fds.txt ; cat /tmp/fds.txt

The first command prints your process's descriptor table — the very thing drawn in Figure 2. Normally 0, 1 and 2 all point at your terminal, and tty tells you which /dev/pts/N that is.

The second is the whole of redirection, visible. Entry 1 no longer points at the terminal; it points at /tmp/fds.txt. Nothing about ls changed — one row of a table did.

Run under a pipe rather than a terminal, this machine showed 1 -> pipe:[1931423] — the same slot, holding a pipe instead.

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.

bash one process · PID 4021 fork() PARENT · PID 4021 same process as before fork() returned 4022 CHILD · PID 4022 brand new process fork() returned 0 waitpid(4022) execve("/bin/ls") same code · same open descriptors · separate memory
Figure 1 — The one call that returns twice. Both processes resume on the line after fork(), running identical code. The only thing distinguishing them is the return value, so every program that forks immediately branches on it.
BEFORE — in the forked child descriptor table 0 /dev/pts/3 1 /dev/pts/3 2 /dev/pts/3 3 out.txt AFTER dup2(3, 1) one row rewritten 0 /dev/pts/3 1 out.txt 2 /dev/pts/3 3 out.txt dup2(3, 1) ls then calls write(1, ...) exactly as always, and never learns anything changed.
Figure 2 — Redirection is one table row. ls > out.txt does not modify ls in any way. The shell rewrites entry 1 of the descriptor table before exec, and the program writes to descriptor 1 in blissful ignorance. Pipes work by the identical trick.
astrosh pipe() then fork() twice the pipe wr rd ls dup2(wr, 1) then close(rd), close(wr) wc -l dup2(rd, 0) then close(rd), close(wr) stdout stdin shell closes BOTH its copies 4 closes in total — one per end, per process that isn't using it If the shell keeps wr open, wc never sees EOF. A pipe reports end-of-file only when every write end everywhere is closed. ls exits, wc waits forever, and the shell hangs. This is the classic bug.
Figure 3 — ls | wc -l, and the close discipline that makes it terminate. The wiring is easy; the closing is what people get wrong. Every process holding a write end keeps the pipe alive, including the shell that created it.
Try this — it takes ten seconds
strace -f -e trace=clone,execve,pipe2,dup2 bash -c 'ls | wc -l'

pipe2([3, 4], 0)              = 0        <- the pipe: read end 3, write end 4
clone(...)                    = 286668   <- fork #1
clone(...)                    = 286669   <- fork #2
[pid 286668] dup2(4, 1)       = 1        <- ls: stdout becomes the write end
[pid 286669] dup2(3, 0)       = 0        <- wc: stdin becomes the read end
[pid 286668] execve("/usr/bin/ls", ["ls"], ...)

That is Figures 1, 2 and 3 happening at once, with real numbers. This output is from your machine, not an illustration.

Read it slowly: one pipe2 creating descriptors 3 and 4, two clone calls (the syscall behind fork), then each child rewiring one descriptor with dup2 before execve replaces it with a different program entirely.

-f means follow children. Without it you would see only bash's own syscalls and miss the entire point.

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.

SESSION astrosh its own group pgid 100 FOREGROUND GROUP · pgid 205 ls pid 205 wc pid 206 one pipeline = one group so signals reach every stage sleep 100 & background group pgid 210 terminal (PTY slave) foreground pgid = 205 Ctrl+C SIGINT to that group only
Figure 4 — Why Ctrl+C hits the right things. The terminal stores one number: which group is in front. The kernel sends the signal to every process in it, so both halves of a pipeline die together while the shell and background jobs are untouched. tcsetpgrp is how that number gets set, and Phase 3 is mostly this diagram.
Try this — it takes ten seconds
kill -l

# start a pipeline in the background, then look at the groups
bash -c 'sleep 5 | cat & sleep 0.3; ps -o pid,pgid,sid,comm --ppid $$; wait'

    PID    PGID     SID COMMAND
 287289  287287  287275 sleep
 287290  287287  287275 cat

kill -l lists every signal the kernel can send you, with its number.

Look at the PGID column: sleep and cat are different processes sharing one group. That is Figure 4, measured. It is why Ctrl+C on a pipeline kills every stage rather than just the last one — the kernel signals the group, not the process.

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.

CANONICAL MODE — the default RAW MODE — vim, htop, less you type: h i ⌫ ⏎ you type: h i ⌫ ⏎ line discipline echoes each key back to you buffers: "h" → "hi" → "h" applies the backspace itself line discipline no echo no buffering no editing — pure pass-through only on ⏎ instantly the program gets one read: "h\n" it never saw the i or the backspace the program gets 4 reads: h i 0x7F 0x0D and must do all editing itself Clearing two termios flags — ECHO and ICANON — is the whole difference.
Figure 5 — The same four keystrokes, two modes. This is the single most useful thing to understand about terminals. Everything in the left column is kernel work that cat gets for free and vim deliberately gives up.
Try this — it takes ten seconds
stty -a

# turn off ECHO, type something, then turn it back on
stty -echo
# (type anything — nothing appears)
stty echo

stty -a prints every flag in the termios struct from §6 — you will see echo, icanon, isig and the special characters like intr = ^C listed by name.

Turning off ECHO makes your typing invisible. That is not a trick; it is exactly how every password prompt on your system works, and it proves the point from Figure 5 — the kernel was doing the echoing all along, not the shell.

If you ever leave a terminal in a strange state, stty sane or reset restores it. You will need this during Phase 0.

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.

Try this — it takes ten seconds
printf '\033[1;31mred\033[0m\n' | xxd

00000000: 1b5b 313b 3331 6d72 6564 1b5b 306d 0a

That is Figure 9's anatomy as actual bytes. 1b is ESC, 5b is [, then 31 3b 33 31 spells the parameters 1;31, and 6d is the final byte m. Then the letters red, then 1b 5b 30 6d to reset.

Drop the | xxd and your terminal renders it instead of showing it. The only difference between seeing the bytes and seeing red text is who is reading them.

G · When things go wrong

errno

How a failed syscall tells you why it failed.

Syscalls signal failure by returning -1 and setting a separate thread-local value called errno. It is not the return value — you read it afterwards.

Check it immediately. The very next syscall overwrites it, including ones hidden inside a print statement.

EINTR

"A signal arrived, so I did nothing. Ask again."

A blocking call interrupted by a signal returns -1 with errno == EINTR, having accomplished nothing. It is not an error in any meaningful sense.

You will hit this the first time you resize the window, because SIGWINCH interrupts your poll(). The fix is always the same: retry the call.

Short reads and writes

write(fd, buf, 100) may write fewer than 100 bytes.

It returns how many it actually transferred. This is not an error, and it happens routinely on pipes and terminals once buffers fill.

Always loop until everything is written. A single unchecked write is one of the most common bugs in beginner systems code, and it fails only under load.

Blocking vs non-blocking

By default, read() sleeps until data arrives.

That is fine with one descriptor and fatal with two — blocking on the PTY means going deaf to the keyboard. This is the entire reason poll() exists and why §9 is built around it.

Opening with O_NONBLOCK makes read return -1 with EAGAIN instead of sleeping.

Race condition

Two processes act, and the order is not guaranteed.

Whichever runs first must leave things correct either way. §9 calls setpgid in both parent and child for exactly this reason — either may be scheduled first, and the loser must not operate on a group that does not exist yet.

Races are the bugs that pass a hundred test runs and fail on the hundred-and-first.

Pointers across the FFI boundary

You hand the kernel a raw address and a length. It trusts both.

When you pass &mut buf to read, the kernel writes directly into your process's memory. Rust's guarantees stop at that boundary — nothing is checked.

Get the length wrong and you corrupt your own memory silently. This is why task 1.6 asserts size_of against the C value, and why all the unsafe lives in one crate.

strace

Watch every syscall a program makes, live.

When something silently does nothing — a wrong ioctl number, a request the X server ignored — strace shows you what actually happened rather than what you meant.

-f follows child processes, -e trace=... filters to the calls you care about, -o file writes to a file so it doesn't tangle with the program's own output.

Man pages

The real documentation — and it beats this document.

Section numbers matter: 2 is syscalls, 3 is library functions, 7 is concepts. So man 2 fork, man 3 termios, man 7 pty.

On your machine sections 2 and 3 are not installed yet — only man 7 pty works. Fix that before Phase 0: sudo apt install manpages-dev.

Try this — it takes ten seconds
# install the syscall and library man pages — not present on this machine
sudo apt install -y manpages-dev
man 2 fork

# watch a syscall fail, and see errno by name
strace -e trace=openat cat /nonexistent

openat(AT_FDCWD, "/nonexistent", O_RDONLY) = -1 ENOENT (No such file or directory)

= -1 ENOENT is errno, made visible. The syscall returned -1, and strace looked up the error name for you. In your own code you would read errno yourself and get the integer.

Once manpages-dev is installed, man 2 fork lists every error fork can return and what each means. When this document and a man page disagree, the man page is right.

§ 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 0x1B → Escape → [ → 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 6 — 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 6. 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 7 — 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
}
what you see: rows × cols 0,0 0,1 0,2 0,3 0,4 0,5 1,0 1,1 1,2 1,3 1,4 1,5 2,0 2,1 2,2 2,3 2,4 2,5 what is stored: one flat Vec<Cell> 0 1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 index = row × cols + col = 1 × 6 + 2 = 8 One allocation, cache-friendly row scans, and scrolling a margin region is a single copy_within. Lines pushed off the top go to the scrollback ring; the alt screen is a second Grid swapped in whole.
Figure 8 — The grid is not a grid. Two dimensions on screen, one dimension in memory. Every cursor operation in §7 reduces to arithmetic on that index, which is why the type in §6 is a flat Vec rather than a vector of rows.

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
make the text bold and red ESC 0x1B — a sequence starts [ CSI — a control sequence 1 parameter: bold ; separator 31 parameter: red text m final byte: SGR The final byte decides the meaning. Everything before it is arguments. switch to the alternate screen ESC introducer [ CSI ? DEC private mode 1049 which mode h set — l would reset
Figure 9 — Anatomy of two real sequences. Every one has the same shape: an introducer, optional parameters, and a final byte in the range 0x40–0x7E that says what to do. The parser in Figure 10 is a machine that collects the middle and dispatches on the end.
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 10 — 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 11 — 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 console fonts in /usr/share/consolefonts/. They come in two formats, and you need both — a survey of that directory on this machine found 235 PSF1 files and 221 PSF2 files, near enough an even split. A parser that handles only PSF2 rejects half your fonts, including the Terminus faces most people reach for first.

Tell them apart by the first two bytes. Both formats are trivial: a header, then fixed-size packed bitmaps of height rows at (width + 7) / 8 bytes per row, optionally followed by a table mapping codepoints to glyph indices.

astroterm-font/src/psf.rs — both headers, verified against real files on this machine

// PSF1 — 4-byte header. Width is ALWAYS 8; there is no width field.
const PSF1_MAGIC: u16 = 0x0436;         // on disk: 36 04
pub struct Psf1Header {
    magic:    u16,   // 0x0436
    mode:     u8,    // bit0: 512 glyphs (else 256) · bit1: unicode table follows
    charsize: u8,    // bytes per glyph == height, since width is 8
}

// PSF2 — 32-byte header.
const PSF2_MAGIC: u32 = 0x864a_b572;    // on disk: 72 b5 4a 86
pub struct Psf2Header {
    magic:         u32,
    version:       u32,   // 0
    headersize:    u32,   // 32 — offset of the first glyph
    flags:         u32,   // bit0: a unicode table follows the glyphs
    numglyph:      u32,
    bytesperglyph: u32,   // == height * ((width + 7) / 8)
    height:        u32,
    width:         u32,
}

Check which format a font is before you write the parser

Uni3-TerminusBold16.psf.gz — a natural first choice, and one an earlier revision of this document recommended — is PSF1, not PSF2. Its header is 4 bytes, not 32. Point a PSF2 parser at it and you get an immediate magic-number mismatch, or worse, garbage.

# first four bytes tell you which format you have
zcat /usr/share/consolefonts/Uni3-TerminusBold16.psf.gz | head -c4 | xxd
# 3604 0310  -> PSF1, mode 3 (512 glyphs + unicode table), 16 rows tall, 8 wide

# list every PSF2 font you have
for f in /usr/share/consolefonts/*.psf.gz; do
  [ "$(zcat "$f" | head -c4 | xxd -p)" = "72b54a86" ] && echo "$f"
done

Start with PSF1: it is the simpler of the two and covers the Terminus fonts. Add PSF2 immediately after — the glyph-blitting code is identical once you have height, width and the bitmap offset.

Rendering a cell is then: for each of height rows, walk the bits left to right and write the foreground colour where set and the background where clear. No anti-aliasing, no hinting, no shaping — which is precisely what a VT100 did.

The upgrade path — parsing TrueType glyf outlines and scan-converting quadratic Béziers with anti-aliasing — is a satisfying one-to-two week project, and it is Phase 8b 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 12 — 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 build plan

Nine phases, 84 numbered tasks. Each phase ends in something that runs, and each task is sized to fit an evening or two. Two estimate columns, because one number would be a lie: what this takes if it goes well, and what it takes the first time you meet process groups and a wire protocol.

PhaseWhat you end up holdingOptimisticRealistic, first time
0 · SpikeA working PTY byte-pump running bash0.5 wk1 wk
1 · astroterm-sysYour own syscall layer; no nix, no signal-hook1.5 wk3 wk
2 · astrosh v1A shell you can genuinely use — pipes, redirects, builtins2.5 wk4 wk
3 · astrosh v2Expansion, globbing, and working job control3.5 wk7 wk
4 · astroterm-coreA complete VT emulator with no window3.5 wk6 wk
5 · astroterm-unicodeWidth and grapheme tables generated from the UCD1 wk2 wk
6 · astroterm-x11A window you opened by speaking X11 down a socket2.5 wk6 wk
7 · font + renderA working terminal. vim and htop run inside it2.5 wk5 wk
8a · IntegrationYour shell in your terminal, passing vttest— wk3 wk
8b · TrueTypeAnti-aliased outline fonts — a separate project— wk2+ wk
TotalTo a terminal you use daily (through 8a)17.5 wk37 wk

Read the right-hand column

37 weeks of evenings and weekends is roughly nine months, and with life happening around it, 9 to 14 months is the honest range for a first-timer. The optimistic column is what this costs someone who has written a shell before. You have not, which is the entire point of doing it.

The two columns diverge most where the work is unfamiliar rather than merely long: job control (3.5 → 7) and the X11 wire protocol (2.5 → 6) are both cases where you will spend more time reading specifications and staring at hexdumps than writing code.

This is the risk the technical register in §12 does not name: abandonment. Not a bug — losing interest in month four with nothing on screen yet. Every phase below therefore carries an If you stall box: a legitimate way to keep moving that trades purity for momentum. Taking one is not failure. Stopping is.

How to use this

Do the phases in order. Each one depends on the last, and the ordering is deliberate: the shell comes first because it gives you a real program weeks before any pixel exists, and Phase 3 — the hardest thing here — arrives while your tooling is still simple.

Stop at every Checkpoint. Those are the points where something visible happens. If a checkpoint does not work, do not carry on — the next task will only bury the bug deeper.

Read the Traps box before starting a phase, not after. Every entry in it is a failure whose symptom does not resemble its cause. They are the difference between an evening and a weekend.

SHELL TRACK EMULATOR TRACK 0 spike 1 sys 2 astrosh v1 3 astrosh v2 4 core 5 unicode 6 x11 7 working terminal 8 polish
Figure 13 — The two tracks are independent after Phase 1. Nothing in the shell work blocks the emulator work or the reverse, so if you stall on job control you can switch to the parser and come back. They only have to meet at Phase 7.
  1. 0

    Spike — prove the PTY works

    ~1 weekend·throwaway code·Low risk

    Before any architecture, make §2's trace real. One file, no crates but libc, run inside your existing terminal. Delete it afterwards — its only job is to convince you the PTY is not magic.

    Tasks

    1. 0.1
      Install rustup. Confirm rustc --version reports 1.8x or newer.
      20 min
    2. 0.2
      cargo new --bin spike, add libc = "0.2", print hello world.
      15 min
    3. 0.3
      Open /dev/ptmx with O_RDWR. Print the fd number you get back — it will be 3.
      30 min
    4. 0.4
      Unlock the pair: ioctl(fd, TIOCSPTLCK, &0), then ioctl(fd, TIOCGPTN, &n) to learn the slave number. Print /dev/pts/N.
      1 hr
    5. 0.5
      fork(). In the child: setsid(), open the slave, dup2 it onto fds 0, 1 and 2, then execvp("/bin/bash").
      2 hr
    6. 0.6
      In the parent: loop reading the master and writing to your own stdout. You should see bash's prompt appear. Checkpoint
      45 min
    7. 0.7
      Put your stdin into raw mode — tcgetattr, clear ECHO|ICANON, tcsetattr. Otherwise your outer terminal eats the keystrokes.
      1 hr
    8. 0.8
      poll() on both stdin and the master; copy bytes each way. You can now type into it. Checkpoint
      1 hr
    9. 0.9
      Restore the original termios on exit, including on panic. Skip this and every crash leaves your terminal unusable.
      30 min
    Files

    spike/src/main.rs, roughly 120 lines.

    Verify

    Type ls and see colours. Run sleep 100 and press Ctrl+C — it interrupts the sleep, not your program. Exit and your outer shell still behaves normally.

    Traps

    Forgetting setsid() leaves the child with no controlling terminal, so job control silently never works. A read returning 0 means the child exited, not an error. If you do wreck your terminal, reset fixes it.

    If you stall

    Use libc's forkpty() — one call that does the entire /dev/ptmx dance for you. Get the byte pump working, then come back and open /dev/ptmx by hand once you know what forkpty was hiding.

  2. 1

    astroterm-sys — your own syscall layer

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

    Turn the spike's scattered calls into one auditable crate. Every unsafe block in the whole project should end up living here.

    Tasks

    1. 1.1
      Create the workspace and all nine crates using the commands in §15.
      30 min
    2. 1.2
      errno.rs — one Result type. Every libc call returns -1 on failure and sets errno; wrap that pattern exactly once.
      2 hr
    3. 1.3
      fd.rs — an owned Fd newtype whose Drop calls close. Makes descriptor leaks impossible.
      2 hr
    4. 1.4
      io.rs — read, write, pipe, dup2, close. Handle short writes: write may transfer fewer bytes than asked.
      3 hr
    5. 1.5
      ioctl.rs — the wrapper plus constants: TIOCSPTLCK, TIOCGPTN, TCGETS (0x5401), TCSETS, TIOCSWINSZ (0x5414), TIOCGWINSZ.
      Verify every constant by printing it from a two-line C program. A wrong number does not error — it corrupts memory.
      4 hr
    6. 1.6
      termios.rs — the struct, tcgetattr/tcsetattr, and a raw_mode() helper. Add a test asserting size_of::<Termios>() equals the C value.
      4 hr
    7. 1.7
      process.rs — fork, execve, waitpid, setpgid, _exit. Read §9's callout before writing this.
      4 hr
    8. 1.8
      poll.rs — the pollfd struct and wrapper. Handle EINTR by retrying.
      2 hr
    9. 1.9
      signal.rs — the self-pipe. Handler writes one byte and nothing else; expose the read end as an Fd.
      3 hr
    10. 1.10
      pty.rs in astroterm-pty — allocate a pair, spawn a child with the slave as its controlling terminal, resize, reap.
      4 hr
    11. 1.11
      Port the Phase 0 spike onto these crates and delete every direct libc:: call from it.
      3 hr
    Files

    astroterm-sys/src/{errno,fd,io,ioctl,termios,process,poll,signal}.rs and astroterm-pty/src/lib.rs. Around 600 lines.

    Verify

    The spike still works, and grep -rn "libc::" --exclude-dir=astroterm-sys returns nothing.

    Traps

    In C, ioctl is variadic — the third argument's type varies by request, so a wrong pointer type compiles fine and misbehaves at runtime. Get the struct field order wrong and you read garbage rather than an error.

    If you stall

    Add nix and port module by module. The crate boundary is the point: you can replace astroterm-sys's internals later without touching a line above it.

  3. 2

    astrosh v1 — REPL, pipes, redirection

    2–3 weeks·first genuinely usable program·Low risk

    The first thing you will actually use. Run it inside your normal terminal — none of this needs a window.

    Tasks

    1. 2.1
      REPL skeleton: print a prompt, read a line, echo it back, exit cleanly on Ctrl+D (read returns 0).
      2 hr
    2. 2.2
      Tokenizer: split on whitespace, honouring "double", 'single' and backslash escapes. Write the unit tests first — this is pure and trivially testable.
      6 hr
    3. 2.3
      AST types: Command { argv, redirects } and Pipeline(Vec<Command>).
      2 hr
    4. 2.4
      Parser: tokens into that AST, recognising |, >, >>, < and 2>.
      5 hr
    5. 2.5
      $PATH search — split on :, test each directory for an executable file.
      2 hr
    6. 2.6
      Run one command: fork, execve, waitpid. Report the exit status. Checkpoint
      4 hr
    7. 2.7
      Builtins — cd, exit, pwd, export. These must run in the parent; a forked child that changes directory then exits accomplishes nothing.
      3 hr
    8. 2.8
      Redirection: open the target file in the child and dup2 it onto the right descriptor before exec.
      4 hr
    9. 2.9
      Pipelines: N commands need N−1 pipes. Close every unused end in both parent and child. Checkpoint
      6 hr
    10. 2.10
      Track the last exit status and expose it as $?.
      1 hr
    Files

    astrosh/src/{main,lexer,ast,parser,exec,builtins}.rs. Around 700 lines.

    Verify

    cat < in.txt | tr a-z A-Z | sort -u > out.txt produces the right file. cd .. persists across commands. ps aux | grep defunct shows nothing after running a hundred commands.

    Traps

    The classic hang: the parent forgets to close its copy of a pipe's write end, so the reader never sees EOF and ls | wc -l waits forever. Missing waitpid leaks zombies steadily.

    If you stall

    Throw away the parser. Split on whitespace with no quoting and no pipes, and get fork/exec/waitpid rock solid first. Add the tokenizer once the process plumbing is boring.

  4. 3

    astrosh v2 — expansion and job control

    3–4 weeks·Hardest phase in the project

    Expansion is fiddly but mechanical. Job control is where most hobby shells stop, and it is worth reading the GNU libc chapter end to end before writing any of it.

    Tasks

    1. 3.1
      Variable storage plus $VAR, ${VAR} and ${VAR:-default}.
      5 hr
    2. 3.2
      Tilde expansion — ~ and ~user.
      2 hr
    3. 3.3
      Globbing: read the directory and match *, ? and [abc] yourself. A recursive matcher is about 60 lines.
      6 hr
    4. 3.4
      Command substitution $(...) — recursive: run the inner command with stdout on a pipe, capture it, splice the result back in.
      5 hr
    5. 3.5
      Operators &&, || and ;, honouring exit status.
      3 hr
    6. 3.6
      Background execution with & — do not wait, record the job.
      2 hr
    7. 3.7
      Process groups: give every pipeline its own, calling setpgid in both parent and child so neither loses the race.
      4 hr
    8. 3.8
      Terminal ownership: tcsetpgrp to the foreground group before running, take it back after.
      Block SIGTTOU around the call or the shell sends itself a stop signal and freezes.
      5 hr
    9. 3.9
      waitpid with WUNTRACED and a job table recording state per pipeline.
      4 hr
    10. 3.10
      jobs, fg and bg, sending SIGCONT to resume. Checkpoint
      5 hr
    11. 3.11
      Signal disposition: the shell ignores SIGINT, SIGQUIT and SIGTSTP; children reset them to default before exec.
      3 hr
    Files

    Adds astrosh/src/{expand,glob,jobs,signals}.rs. Around 900 more lines.

    Verify

    Ctrl+Z inside vim returns you to the prompt; jobs lists it as Stopped; fg restores it with the screen intact; Ctrl+C kills a running child without touching the shell.

    Traps

    Symptoms here are bizarre rather than obvious — the shell suspending itself, or Ctrl+C killing everything including you. If something odd happens, suspect SIGTTOU or a process group that does not exist yet.

    If you stall

    Ship without job control. No Ctrl+Z, no fg, no bg. The shell stays fully usable for the overwhelming majority of interactive work. Come back after Phase 7 with more Unix behind you — this is the single most reasonable thing to defer in the whole plan.

  5. 4

    astroterm-core — parser and grid, headless

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

    The emulator's brain, with no window anywhere. Everything here is pure functions over bytes, so correctness is cheap — there is nothing to look at and nothing to mock.

    Tasks

    1. 4.1
      Cell, Color and Attrs — hand-roll the bitflags, it is about 20 lines.
      3 hr
    2. 4.2
      Grid: allocation, indexing, clearing, resizing. Keep it one flat Vec.
      5 hr
    3. 4.3
      Cursor movement with bounds clamping in every direction.
      3 hr
    4. 4.4
      Scrolling inside a margin region, and pushing displaced lines into the scrollback ring.
      5 hr
    5. 4.5
      The Perform trait — print, execute, csi_dispatch, osc_dispatch, esc_dispatch.
      2 hr
    6. 4.6
      The state machine: Ground, Escape, CsiEntry, CsiParam, with parameter accumulation and defaults.
      6 hr
    7. 4.7
      CSI dispatch — CUP, CUU/CUD/CUF/CUB, CNL/CPL, ED, EL.
      5 hr
    8. 4.8
      SGR: the 16 base colours, 256-colour mode, 24-bit truecolour, bold, underline, reverse. Checkpoint
      6 hr
    9. 4.9
      DEC private modes ?25 (cursor), ?1049 (alt screen), ?7 (autowrap), ?2004 (bracketed paste).
      4 hr
    10. 4.10
      OSC handling — window title (0 and 2) and clipboard (52).
      3 hr
    11. 4.11
      The remaining editing sequences: ICH, DCH, IL, DL, DECSTBM, IND/RI, DECSC/DECRC, tab stops.
      6 hr
    12. 4.12
      Golden-test harness: capture with script -c "htop -n1" -f out.raw, replay the bytes, snapshot the grid as plain text.
      Build this now, not in Phase 8. Debugging a text dump is many times faster than debugging pixels.
      5 hr
    Files

    astroterm-core/src/{cell,grid,parser,dispatch,scrollback}.rs plus tests/corpus/. Around 1,200 lines.

    Verify

    Golden snapshots captured from vim, htop and less replay to byte-identical grids.

    Traps

    Escape sequence parameters default to 1, not 0 — ESC [ H means row 1 column 1. An unrecognised sequence must be discarded silently; printing it is the fastest way to make a terminal look broken.

    If you stall

    Stop implementing from the specification. Capture what ls --color, vim and htop actually emit on your machine, and implement only what appears. Grow the set from real captures rather than from XTerm ctlseqs top to bottom.

  6. 5

    astroterm-unicode — tables from the UCD

    ~1 week·replaces two crates·Low risk

    Deriving the tables from Unicode's published data files is genuinely from scratch. Copying someone's pre-generated table is not.

    Tasks

    1. 5.1
      Download and commit EastAsianWidth.txt, GraphemeBreakProperty.txt and emoji-data.txt so builds stay offline and reproducible.
      1 hr
    2. 5.2
      build.rs: parse those files into sorted (start, end, value) ranges and emit a Rust source file.
      6 hr
    3. 5.3
      width(ch) by binary search — W and F are 2 cells, combining marks are 0, everything else is 1.
      3 hr
    4. 5.4
      Grapheme clustering so a ZWJ emoji sequence collapses into one cell.
      6 hr
    5. 5.5
      Swap it into astroterm-core in place of the Phase 4 stub, then re-run the golden tests and read the diff carefully. Checkpoint
      3 hr
    Files

    astroterm-unicode/{build.rs,src/lib.rs,data/*.txt}. Around 300 lines plus generated tables.

    Verify

    A line of CJK text and a ZWJ emoji each occupy the correct number of cells, asserted by a golden test. unicode-width appears nowhere in the tree.

    Traps

    The data files use ranges written 4E00..9FFF with comments after #; a sloppy parser silently truncates the table and you get wrong widths only for rare characters.

    If you stall

    Hardcode the main wide ranges — U+1100–115F, U+2E80–A4CF, U+AC00–D7A3, U+F900–FAFF, U+FF00–FF60 as width 2, everything else 1. That covers almost everything you will actually type. Generate the real tables later.

  7. 6

    astroterm-x11 — a window on screen

    2–3 weeks·replaces SDL2·Highest risk

    Follow Figure 11 exactly. Get to a solid-coloured window before you even think about text — debugging the handshake, the keymap and the compositor simultaneously is how this phase goes wrong.

    Tasks

    1. 6.1
      Parse $DISPLAY and connect to /tmp/.X11-unix/X0 as a Unix domain socket.
      3 hr
    2. 6.2
      Read ~/.Xauthority — its own small binary format of family, address, display, name and the 16-byte cookie.
      4 hr
    3. 6.3
      Send the setup request carrying that cookie; parse the setup reply for root window, visual, depth, resource-id-base and resource-id-mask.
      Omit the cookie and the server just refuses you. This is where most people stall.
      6 hr
    4. 6.4
      XID allocator — mint your own ids as base | (counter & mask).
      1 hr
    5. 6.5
      CreateWindow (opcode 1) and MapWindow (opcode 8). A window appears. Checkpoint
      5 hr
    6. 6.6
      CreateGC (55) and PutImage (72) with a solid colour, then a gradient. Checkpoint
      5 hr
    7. 6.7
      Event loop: read the 32-byte event headers off the socket and decode by type.
      4 hr
    8. 6.8
      GetKeyboardMapping (101), then keycode → keysym → bytes, including Shift and Ctrl. Checkpoint
      8 hr
    9. 6.9
      ConfigureNotify (22) → recompute rows and columns → TIOCSWINSZ.
      3 hr
    10. 6.10
      InternAtom for WM_PROTOCOLS and WM_DELETE_WINDOW, then handle the ClientMessage so the title bar can close you.
      3 hr
    11. 6.11
      Split PutImage into row bands — a full 1920×1080 frame exceeds the maximum request length.
      3 hr
    Files

    astroterm-x11/src/{conn,auth,proto,window,events,keymap}.rs. Around 900 lines.

    Verify

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

    Traps

    Every X11 request is padded to a 4-byte boundary and its length field counts units of 4 bytes, not bytes. Miscount and the server desynchronises or ignores you silently. Compare your byte stream against xtrace on a working client.

    If you stall

    Use x11rb to get a window. Keep every part of Phase 7 moving, then swap your own client in behind the Surface trait afterwards. Of all the hatches here this is the one most likely to save the project — a window that exists beats a protocol you are still debugging.

  8. 7

    astroterm-font and render — the first real terminal

    2–3 weeks·everything connects·Medium risk

    The phase where nine crates become one program. You already have 456 PSF fonts on disk, so there is nothing to download.

    Tasks

    1. 7.1
      PSF1 and PSF2 parser — 4-byte and 32-byte headers respectively. Half the fonts on your disk are each. Dispatch on the first two bytes.
      5 hr
    2. 7.2
      Font::glyph(ch) returning the packed bitmap for a character, falling back to U+FFFD.
      3 hr
    3. 7.3
      Framebuffer: a Vec<u32> plus the cell-to-pixel mapping from font width and height.
      2 hr
    4. 7.4
      Blit one cell — walk the glyph's bits, writing foreground where set and background where clear.
      3 hr
    5. 7.5
      Blit the whole grid and PutImage it. Text appears on screen for the first time. Checkpoint
      3 hr
    6. 7.6
      Damage tracking — redraw only rows flagged dirty, in contiguous bands.
      4 hr
    7. 7.7
      Wire the §9 poll loop over three descriptors: X11 socket, PTY master, signal pipe. Checkpoint
      5 hr
    8. 7.8
      Key encoding: arrows, Home/End, Page keys, function keys, Ctrl and Alt modifiers, and application cursor mode (arrows send ESC O A rather than ESC [ A when it is on).
      6 hr
    9. 7.9
      Cursor rendering — block shape, and blinking on a timer.
      3 hr
    10. 7.10
      Mouse selection and clipboard, using OSC 52 for copy.
      6 hr
    Files

    astroterm-font/src/psf.rs, astroterm-render/src/{fb,blit,damage}.rs, astroterm/src/main.rs. Around 800 lines.

    Verify

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

    Traps

    Arrow keys breaking inside vim almost always means application cursor mode is being ignored. A wrong character width shifts an entire line — check §5's tables first.

    If you stall

    Skip font parsing entirely. Hardcode an 8×16 bitmap array for the 128 ASCII glyphs — mechanical, and a short script can generate it from an existing font. Get text on screen first; parse PSF afterwards.

  9. 8

    Integration, conformance and polish

    Ongoing·the long tail·Incremental

    Point astroterm at astrosh instead of bash — the moment the project closes on itself and every piece is yours.

    Tasks

    1. 8.1
      Launch astrosh as astroterm's child. Your shell, in your terminal. Checkpoint
      1 hr
    2. 8.2
      Run vttest and work down menus 1, 2 and 11.
      ongoing
    3. 8.3
      Profile against the 16 ms frame budget; add MIT-SHM so frames skip the socket.
      1 wk
    4. 8.4
      TrueType rasterizer as a self-contained sub-project: sfnt container, cmap, loca, glyf, quadratic Béziers, scanline anti-aliasing.
      2 wk
    5. 8.5
      A config file for font, size and palette; publish your own terminfo entry.
      3 days
    Files

    astroterm-font/src/truetype.rs and assorted polish.

    Verify

    You use it as your daily driver for a full week without reaching for GNOME Terminal.

    Traps

    Resist starting 8.4 early. A TrueType rasterizer is genuinely fun and will absorb a month that Phase 6 needs more.

    If you stall

    Nothing here is load-bearing. 8a is worth finishing; 8b is a separate project you may quite reasonably never start.

What in this document has actually been verified

A plan full of confident numbers is dangerous if some are wrong. An earlier revision told you to write a PSF2 parser and to use a font that turns out to be PSF1 — an error that would have cost you an evening. So here is the provenance of every hard number, split honestly.

ClaimStatusHow
TCGETS 0x5401, TCSETS 0x5402, TIOCGWINSZ 0x5413, TIOCSWINSZ 0x5414VerifiedCompiled against your asm/ioctls.h
ECHO 0o10, ICANON 0o2, ISIG 0o1; sizeof(termios) = 60, sizeof(winsize) = 8VerifiedCompiled against your termios.h
PSF1 magic 0x0436 / PSF2 magic 0x864ab572; both header layouts; 235 vs 221 file splitVerifiedParsed real fonts in /usr/share/consolefonts/
X11 request length is counted in units of 4 bytesVerifiedX11 protocol specification, §Overview
X11 session live, ~/.Xauthority present, 456 console fonts, glibc 2.39VerifiedInspected on this machine
X11 opcodes — CreateWindow 1, MapWindow 8, CreateGC 55, PutImage 72, InternAtom 16, GetKeyboardMapping 101UnverifiedFrom memory. Check before use — see below
X11 event codes — KeyPress 2, Expose 12, ConfigureNotify 22, ClientMessage 33UnverifiedFrom memory. Same check
~/.Xauthority internal field layoutUnverifiedFrom memory. Confirm against a hexdump of your own file
Escape sequences in §7 and §9 (CUP, ED, SGR, DEC private modes)Widely knownStandard and stable, but confirm against XTerm ctlseqs as you implement
All effort estimatesJudgementNot measurements. See the two columns in §10

Verify the X11 numbers yourself in one minute

The authoritative source is a header file, not this document. Install the protocol headers — they are text definitions, not a library you would be linking against, so this does not compromise the from-scratch line:

sudo apt install -y x11proto-dev
grep -E "define X_(CreateWindow|MapWindow|CreateGC|PutImage|InternAtom|GetKeyboardMapping) " \
  /usr/include/X11/Xproto.h
grep -E "define (KeyPress|Expose|ConfigureNotify|ClientMessage) " /usr/include/X11/X.h

Do this at the start of Phase 6, before writing a single request. The same discipline applies to task 1.5: every constant you transcribe should be printed by a two-line C program first. A wrong ioctl number does not raise an error — it corrupts memory.

§ 11

Testing strategy

The three pure crates from Figure 7 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
Abandonment — losing momentum in month four with nothing on screen yet. For a solo side project this is far more likely to end it than any bug. HighPhases 1, 3, 6 Take the If you stall hatch in §10 the moment a phase stops being fun. A terminal that used x11rb for three weeks is infinitely better than one you stopped building. Purity is recoverable; motivation is not.
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 language — if, 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 — already on your disk. CHECK THE FORMAT FIRST:
zcat /usr/share/consolefonts/Uni3-TerminusBold16.psf.gz | head -c4 | xxd
# 3604 -> PSF1 (4-byte header)   72b54a86 -> PSF2 (32-byte header)
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.