KB KEDBYTE TECHNOLOGIES PRIVATE LIMITED
CHAPTER
19

The Terminal and the Shell

Part D · Software|24,240 words|about 105 min read|Volume 2

19.0 What this chapter gives you#

  1. You will be able to say what a terminal actually was, as a physical machine, and why the word survived after the machine died.
  2. You will be able to tell apart five things that people constantly mix up: terminal, terminal emulator, shell, console, and command line.
  3. You will be able to explain what a pseudo-terminal is, and describe exactly what happens inside the machine when you press Ctrl-C.
  4. You will be able to describe the shell’s read-parse-expand-execute loop, and name the two system calls that start every program you run.
  5. You will be able to use redirection and pipes correctly, including the case where 2>&1 in the wrong place does nothing useful.
  6. You will be able to read an exit code, chain commands with && and ||, and say why continuous integration systems only look at that number.
  7. You will be able to fix a broken PATH, and say which file to edit on macOS, on a Linux server, and why the answer differs between the two.
  8. You will be able to explain the Windows registry properly, and say why a macOS or Linux developer almost never thinks about one.
  9. You will be able to use around forty commands with confidence, and read a manual page to find the fortieth-first.
  10. You will be able to write a real shell script with error handling that stops instead of continuing into damage.

19.1 What a terminal actually is#

PLAIN19.1.1 in simple words#

  1. Today, a terminal is a window on your screen with text in it.
  2. That is a costume. The word means something older and physical.
  3. A terminal was a machine that sat at the end of a wire.
  4. The other end of the wire went to a computer, often in another building.
  5. The terminal had a keyboard and a way to show what came back.
  6. It had almost no brain of its own. It sent characters out and printed characters in. Nothing more.
  7. The earliest ones printed onto a paper roll, like a till receipt that never ends.
  8. Later ones had a small screen instead of paper, and that was a huge saving in paper and noise.
  9. When personal computers arrived, nobody needed the physical machine any more. The computer was on your desk.
  10. But all the software already knew how to talk to a terminal. So we kept pretending. A program on your screen now acts like the old machine.
  11. That is why the window is called a terminal, and why it still obeys commands designed for a device made in 1978.

PLAIN19.1.2 a picture in your head#

  1. Imagine a hotel in 1970 with one telephone switchboard in the basement.
  2. Every room has a simple phone. The phone has no brain. It has a handset and a dial.
  3. All the intelligence, all the routing, all the connections, live in the basement.
  4. The phone in the room is the terminal. The switchboard is the computer.
  5. Ten rooms can be connected at once. The basement machine handles all ten, giving each a slice of its attention.
  6. Now the hotel modernizes. Every room gets its own full computer. The basement machine is thrown away.
  7. But the guests learned to use the handset. So the new computers put a picture of a handset on their screens, and it works the same way.
  8. That picture is the terminal emulator you use today.

Where this comparison breaks: a hotel phone carries sound as a continuous wave, while a terminal carries discrete characters, one byte at a time, with a strict alphabet. And the old terminal was not entirely brainless. It knew how to move its own cursor, clear its own screen, and pick a colour, when told to by special character sequences. That small amount of local intelligence is exactly the part that survives in your terminal window today.

PLAIN19.1.3 a worked example#

  1. Picture the Teletype Model 33, sold from 1963. Engineers called it the ASR-33.
  2. ASR stands for Automatic Send-Receive: it could also punch and read paper tape.
  3. It weighed around 25 kilograms. It printed onto a roll of paper. It was loud, like a typewriter that never stopped.
  4. It ran at 110 bits per second. That is 10 characters per second.
  5. Type a line of 80 characters and wait 8 seconds for the reply to finish printing. That was the normal rhythm of computing.
  6. Because printing was slow, command names were made short. This is the real reason UNIX has ls, cp, mv, rm and not list, copy, move, remove.
  7. There is no scrolling back on a paper terminal. What is printed is printed. To see something again, you print it again.
  8. There is no clearing the screen either. There is no screen.
  9. Now compare the DEC VT100, introduced in 1978. It had a glass screen showing 24 rows of 80 characters.
  10. Suddenly the cursor could be moved anywhere, text could be erased, and the screen could be redrawn. Full-screen text editors became possible.
  11. Below is a real capture of the bytes a modern terminal still uses to turn text red and bold. This was taken from the sandbox used to write this chapter.
$ printf '\033[1;31mRED BOLD\033[0m\n' | od -c
0000000 033   [   1   ;   3   1   m   R   E   D       B   O
0000020   L   D 033   [   0   m  \n
  1. 033 is the octal number for the ESC character, decimal 27, hex 1B.
  2. ESC [ 1 ; 3 1 m means “bold, red foreground”. ESC [ 0 m means “reset everything”.
  3. That exact sequence was defined for terminals of the VT100 era. Your terminal in 2026 still speaks it.

PLAIN19.1.4 what is really happening inside#

  1. A terminal and a computer are joined by a serial line: one wire for characters going out, one for characters coming in.
  2. When you press the A key, the terminal sends the single byte 65 down the wire. It does not draw an A on its own.
  3. The computer receives 65, decides what to do, and usually sends 65 straight back. That is called echo.
  4. Only when the byte comes back does the terminal print it. So on a slow or broken line, your typing does not appear.
  5. This is why an old terminal feels like a conversation: everything you see is something the far end chose to send you.
  6. Most bytes mean “print this character”. A few mean “do this action”.
  7. Byte 7 rings the bell. Byte 8 moves the print head back one space. Byte 10 moves down a line. Byte 13 returns to the left margin.
  8. Byte 27, ESC, means “the next few bytes are not text, they are an instruction”.
  9. ESC [ starts a control sequence. Numbers and a final letter follow.
  10. ESC [ 2 J clears the screen. ESC [ 10 ; 5 H puts the cursor on row 10, column 5.
  11. Every manufacturer invented their own sequences at first, which was chaos. A standard fixed that, and the VT100 was the terminal that made the standard popular.
  12. In UNIX, every device is a file. The file that represents the connection to a terminal was named after the machine on the other end.
  13. Teletype was shortened to tty. The device is /dev/tty. That is the whole origin of the name.

TECHNICAL19.1.5 the engineer’s version#

  1. The Teletype Corporation Model 33 was introduced as a commercial product in 1963, after an original design for the United States Navy.
  2. It was one of the first products to use ASCII, first published in 1963. Over half a million Model 33 units had been built by 1975.
  3. The Model 33 defined by accident two long-lived conventions: code 17 (Ctrl-Q, DC1) as XON and code 19 (Ctrl-S, DC3) as XOFF for flow control.
  4. Ctrl-S still freezes many terminals today, and confused users still think their machine has crashed. It has not. Press Ctrl-Q.
  5. The DEC VT52 arrived in September 1975 with a proprietary escape set. The DEC VT100 arrived in 1978 and implemented the emerging ANSI sequences.
  6. The relevant standards, in order: ECMA-48 adopted 1976; ANSI X3.41-1974 and ANSI X3.64-1977 cited in the VT100 manual; the name “ANSI escape sequence” dates from ANSI’s 1979 adoption of X3.64.
  7. ECMA-48 and X3.64 were merged into ISO/IEC 6429. ANSI withdrew X3.64 in 1994 in favour of the international standard. Japan adopted JIS X 0211.
  8. So the correct name in 2026 is ECMA-48 or ISO/IEC 6429. “ANSI escape codes” is a convention of speech, not a live standard name.
  9. The VT100 used an Intel 8080 processor, showed 24 lines of 80 columns, and supported 132-column mode. Its market success created a clone industry: Zenith Z-19 in 1979, Qume QVT-108, Televideo TVI-970, Wyse WY-99GT.
  10. Because sequences still varied, UNIX grew a capability database: termcap, then terminfo. Programs ask the database, not the hardware.
  11. The TERM environment variable names your terminal type. tput and infocmp read the database. Real output from this sandbox:
$ TERM=xterm-256color tput colors
256
$ TERM=xterm-256color tput setaf 1 | od -c
0000000 033   [   3   1   m
$ infocmp xterm | head -5
xterm|xterm-debian|xterm terminal emulator (X Window System),
        am, bce, km, mc5i, mir, msgr, npc, xenl,
        colors#8, cols#80, it#8, lines#24, pairs#64,
        bel=^G, blink=\E[5m, bold=\E[1m, cbt=\E[Z,
  1. Note the honest detail: plain xterm claims 8 colours, xterm-256color claims 256. Setting TERM wrongly is a common cause of broken colours and broken arrow keys over SSH.
Machine Year Output Speed
Teletype 33ASR 1963 paper roll 110 bit/s
DEC VT52 1975 24x80 screen up to 19200
DEC VT100 1978 24x80 screen up to 19200
Modern emul. 2026 window memory speed
  1. The honest version: your terminal is not emulating a VT100 exactly. It emulates a superset, usually announced as xterm-256color. Mouse reporting, bracketed paste, true colour and Unicode are all later additions that no VT100 ever had.

WORDS19.1.6 remember these#

  1. Terminal — a machine at the end of a wire for typing and reading — a character-oriented I/O device with a keyboard and display.
  2. Teleprinter — a typewriter that talks over a wire — an electromechanical send-receive device using a serial character code.
  3. ASR-33 — the famous noisy paper terminal — Teletype Model 33 Automatic Send-Receive, 1963, 110 bit/s, 10 characters per second.
  4. Escape sequence — a few characters that mean “do something” not “print something” — a control string beginning with ESC, per ECMA-48.
  5. tty — the name of the terminal device file — abbreviation of teletype, exposed as /dev/tty and /dev/ttyN.
  6. terminfo — a list of what each terminal model can do — a compiled capability database consulted through tput and curses.
  7. Echo — the far end sending your keystroke back so you can see it — local or remote reflection of input characters, controlled by the line discipline.

19.2 Terminal, terminal emulator, shell, console and command line#

PLAIN19.2.1 in simple words#

  1. Five words get used as if they mean the same thing. They do not.
  2. A terminal is the device, real or pretended, that carries characters in and out.
  3. A terminal emulator is a program that draws a terminal on your screen. Terminal.app and iTerm2 on macOS are terminal emulators.
  4. A shell is a different program entirely. It reads what you type and runs programs for you. bash and zsh are shells.
  5. A console originally meant the one screen physically attached to the machine, used for its most important messages.
  6. A command line is not a program at all. It is a style of working: you type a line, you press Enter, something happens.
  7. When you open Terminal.app on a Mac, you start a terminal emulator, and it starts a shell inside itself.
  8. The window is the emulator. The % or $ prompt inside it comes from the shell.
  9. If the shell crashes, the window stays but nothing responds. If the window closes, the shell dies with it.

PLAIN19.2.2 a picture in your head#

  1. Think of a theatre.
  2. The building is the terminal emulator. It has seats, lighting, a stage, and a door to the street.
  3. The play performed on the stage is the shell. It is what you actually came to watch.
  4. The building does not know the plot. It only supplies a place to perform and a way for the audience to see and be heard.
  5. You can put a different play in the same building. Run zsh instead of bash and nothing about the window changes.
  6. You can also put the same play in a different building. Run bash under iTerm2 instead of Terminal.app and the play is identical.
  7. The console is the stage manager’s desk backstage: the one place that gets the emergency announcements even when the audience does not.

Where this comparison breaks: a theatre and a play are physically separate, while your emulator and shell are joined by a specific and strange piece of kernel plumbing, not by air. And the shell can run with no building at all, reading commands from a file, with nobody watching. That is a shell script.

PLAIN19.2.3 a worked example#

  1. Open Terminal.app on macOS. You now have four things stacked up.
  2. Terminal.app itself is a normal macOS application, written with Apple’s UI toolkit. It draws characters and reads your keyboard.
  3. It asked the operating system for a pseudo-terminal, a fake wire.
  4. It started /bin/zsh on the far end of that fake wire.
  5. zsh printed a prompt into the fake wire, and Terminal.app drew it.
  6. Prove it to yourself. Type tty and press Enter. On macOS you will see something like /dev/ttys003. On Linux you will see /dev/pts/0.
  7. Real output from the Linux sandbox used to write this chapter, running a shell inside a real pseudo-terminal:
$ tty
/dev/pts/0
$ echo $SHELL
/bin/bash
$ ps -o pid,ppid,pgid,sid,tpgid,stat,tty,comm
  PID  PPID  PGID   SID TPGID STAT TT       COMMAND
30511 30510 30511 30511 30512 Ss   pts/0    bash
30512 30511 30512 30511 30512 R+   pts/0    ps
  1. Read that table. bash has process ID 30511. ps has 30512 and its parent is 30511, so the shell started it.
  2. Both are attached to terminal pts/0. That is the fake wire.
  3. TPGID is 30512, meaning the terminal’s foreground group is currently ps, not the shell. That is what “the shell is waiting” looks like from outside.

PLAIN19.2.4 what is really happening inside#

  1. The emulator owns one end of the fake wire. The shell owns the other.
  2. Everything you type goes into the emulator, which writes those bytes into its end.
  3. The operating system carries them across and hands them to the shell.
  4. Everything the shell or its programs print goes the other way, and the emulator draws it.
  5. The emulator has one extra job: it must understand escape sequences. When bytes ESC [ 2 J arrive, it clears its own drawing area.
  6. The shell has one extra job: it must decide what your line of text means.
  7. Neither knows much about the other. You can replace either one.
  8. The console is a separate idea. On a Linux server it is where kernel messages appear, even before any emulator exists.
  9. On a physical Linux machine, pressing Ctrl-Alt-F2 gives you a real text console, /dev/tty2, drawn by the kernel itself with no emulator involved.
  10. On macOS there is no such thing for the user. There is a boot-time verbose console and a system log, but no switchable text consoles.
  11. Windows is different again. Its window is conhost.exe or Windows Terminal, and the thing inside is cmd.exe or powershell.exe.

TECHNICAL19.2.5 the engineer’s version#

Thing What it is Examples
Terminal character device tty, pty, serial
Terminal emulator GUI application Terminal.app, iTerm2
Shell command language bash, zsh, fish
Console primary device /dev/console, tty1
Command line interaction mode not a program
  1. On Linux, virtual consoles are /dev/tty1 through /dev/tty63, driven by the kernel VT subsystem, with no user-space emulator.
  2. /dev/console is the kernel’s message destination, set by the console= kernel command-line parameter at boot.
  3. /dev/tty with no number is a magic alias meaning “the controlling terminal of the process reading it”.
  4. Pseudo-terminal slaves on Linux are /dev/pts/N, provided by the devpts filesystem. On macOS and BSD they are /dev/ttysNNN.
  5. Common emulators, with real facts: xterm, first released 1984, still the compatibility reference. GNOME Terminal, KDE Konsole, Alacritty, kitty, WezTerm, iTerm2 on macOS, Windows Terminal from Microsoft.
  6. Windows Terminal reached version 1.0 on 19 May 2020. Before it, Windows had only the legacy console host.
  7. macOS Terminal.app ships with macOS. It defaults TERM to xterm-256color in recent versions. iTerm2 is a third-party replacement with split panes and its own escape extensions.
  8. The distinction matters operationally. Colour problems and key problems are emulator or TERM problems. Completion and prompt problems are shell problems. Never debug one by changing the other.

WORDS19.2.6 remember these#

  1. Terminal emulator — the window program — a user-space application that allocates a pty and renders ECMA-48 output.
  2. Shell — the program that runs your commands — a command language interpreter, specified for sh by POSIX IEEE Std 1003.1.
  3. Console — the machine’s own primary screen — /dev/console, the kernel’s message device, selected at boot.
  4. Virtual console — a full-screen text session on Linux — /dev/ttyN, implemented in the kernel VT layer.
  5. Controlling terminal — the terminal that owns your session — the tty a session leader has opened, reachable as /dev/tty.

19.3 The pseudo-terminal#

PLAIN19.3.1 in simple words#

  1. There is no wire and no machine any more. So the operating system fakes one.
  2. The fake is called a pseudo-terminal, usually shortened to PTY.
  3. It comes as a pair of ends that are joined inside the kernel.
  4. One end is held by the terminal emulator. Whatever it writes appears as keyboard input on the other end.
  5. The other end is held by the shell. Whatever the shell prints comes out of the first end for the emulator to draw.
  6. In between sits a piece of kernel code called the line discipline.
  7. The line discipline is not a pipe. It is an active thing that changes the characters as they pass.
  8. It is why backspace deletes a letter instead of printing a strange symbol.
  9. It is why the shell does not see your line until you press Enter.
  10. It is why Ctrl-C stops a program, instead of being delivered to it as the letter it technically is.

PLAIN19.3.2 a picture in your head#

  1. Imagine two people passing notes through a slot in a wall.
  2. On the wall’s slot sits a clerk. The clerk reads every note before passing it on.
  3. When you write a letter, the clerk copies it onto a public board so you can see what you wrote. That is echo.
  4. When you scribble out a letter, the clerk erases it from the board and from the note. That is backspace.
  5. The clerk holds each note until you write a full stop. Only then is the note pushed through. That is line buffering.
  6. If you write a special mark meaning “stop”, the clerk does not pass the mark along. The clerk runs to the other room and pulls the person out of their chair. That is Ctrl-C.
  7. The clerk can be told to stop doing all of this. In that mode every letter goes through instantly, unchanged, uncopied.
  8. Text editors and games ask for exactly that mode, because they want to react to every single key.

Where this comparison breaks: the clerk is not slow and not optional. It is kernel code running in microseconds, and there is always one, even in the mode where it does almost nothing. Also, the clerk does not understand your notes. It only recognizes about a dozen specific characters and treats every other byte as data to be passed along.

PLAIN19.3.3 a worked example#

  1. Here are the special characters the line discipline is watching for, taken from a real stty -a run inside a pseudo-terminal on Linux.
$ stty -a
speed 38400 baud; rows 0; columns 0; line = 0;
intr = ^C; quit = ^\; erase = ^?; kill = ^U; eof = ^D;
start = ^Q; stop = ^S; susp = ^Z; rprnt = ^R; werase = ^W;
lnext = ^V; discard = ^O; min = 1; time = 0;
isig icanon iexten echo echoe echok -noflsh -tostop
  1. Read the important ones. intr = ^C means Ctrl-C is the interrupt key.
  2. susp = ^Z means Ctrl-Z suspends. eof = ^D means Ctrl-D signals end of input.
  3. erase = ^? means the Delete character erases one letter. kill = ^U erases the whole line.
  4. werase = ^W erases one word. lnext = ^V means “take the next key literally, do not treat it specially”.
  5. Now the flags on the last line, which are the settings, not the keys.
  6. icanon means canonical mode is on: input is collected into lines.
  7. echo means the kernel prints your keystrokes back for you.
  8. isig means the special keys generate signals. Turn this off with stty -isig and Ctrl-C becomes an ordinary byte.
  9. Notice speed 38400 baud. There is no wire, so this number is fiction. It is kept only because programs still ask for it.

PLAIN19.3.4 what is really happening inside#

  1. Here is the whole chain, from your finger to a character on the glass.
   your keyboard
        |
        v
  [ window system: macOS AppKit / X11 / Wayland ]
        |  key event
        v
  [ terminal emulator process ]
        |  writes bytes
        v
  +-----------------------------+
  |  PTY MASTER  (/dev/ptmx)    |
  +-----------------------------+
        |    kernel
        v
  +-----------------------------+
  |  LINE DISCIPLINE            |
  |  echo, erase, line buffer,  |
  |  Ctrl-C -> SIGINT,          |
  |  Ctrl-Z -> SIGTSTP          |
  +-----------------------------+
        |
        v
  +-----------------------------+
  |  PTY SLAVE   (/dev/pts/0)   |
  +-----------------------------+
        |  read() returns a line
        v
  [ shell process: bash or zsh ]
        |  fork + exec
        v
  [ the command you ran ]
        |  writes to fd 1
        v
   back up through slave, line
   discipline, master, emulator,
   and onto the screen
  1. Step by step, when you type l then s then Enter:
  2. The emulator writes the byte l into the master.
  3. The line discipline copies it back towards the master, so the emulator draws l. It also stores it in a small buffer, not yet given to the shell.
  4. Same for s. The buffer now holds ls.
  5. You press Enter, which sends carriage return, byte 13. The line discipline translates it to newline, byte 10, and now considers the line complete.
  6. The shell’s read() call, which was blocked and waiting, returns the three bytes l, s, newline.
  7. Now the interesting one. You press Ctrl-C. That is byte 3.
  8. The line discipline sees byte 3 matches intr. It does not put it in the buffer.
  9. Instead it sends the signal SIGINT to every process in the terminal’s foreground process group.
  10. The default action of SIGINT is to end the process. So your running command stops. The shell was not in the foreground group, so it survives.
  11. Ctrl-D is different. It is not a signal. It means “end of file now”.
  12. If the buffer has text in it, Ctrl-D pushes that text through immediately.
  13. If the buffer is empty, read() returns zero bytes, which means end of input, and the shell exits.
  14. That is why Ctrl-D on an empty line logs you out, and why pressing it in the middle of a typed line does nothing visible.
  15. Ctrl-Z sends SIGTSTP, which suspends. The process freezes in place. The shell notices, takes back the terminal, and prints your prompt.

TECHNICAL19.3.5 the engineer’s version#

  1. A PTY is a bidirectional character device pair. On Linux the master is obtained by opening /dev/ptmx, which allocates a new slave in /dev/pts.
  2. The relevant calls are posix_openpt, grantpt, unlockpt, ptsname, then open on the slave. openpty and forkpty wrap all of it.
  3. The slave is made the controlling terminal by the child calling setsid then ioctl(TIOCSCTTY), or implicitly on first open by a session leader.
  4. Terminal settings live in struct termios with four flag sets: c_iflag input, c_oflag output, c_cflag control, c_lflag local, plus the c_cc array of control characters.
  5. ICANON in c_lflag selects canonical mode. Cleared, you are in raw mode and VMIN and VTIME in c_cc control read behaviour.
  6. ECHO, ECHOE, ECHOK, ISIG, IEXTEN are the other c_lflag bits you will actually touch. OPOST and ONLCR in c_oflag translate newline to carriage-return newline on output.
  7. Signals generated by the line discipline, with their Linux numbers:
Key Byte Signal Default action
Ctrl-C 0x03 SIGINT (2) terminate
Ctrl-\ 0x1C SIGQUIT (3) terminate, core
Ctrl-Z 0x1A SIGTSTP (20) stop
none none SIGTTIN (21) stop background
none none SIGTTOU (22) stop background
  1. Signals go to the foreground process group of the controlling terminal, found with tcgetpgrp and set with tcsetpgrp. This is the field shown as TPGID by ps.
  2. Job control was first implemented in the C shell by Jim Kulp at IIASA in Austria, using features of the 4.1BSD kernel, released 1981.
  3. A background process that reads from the terminal gets SIGTTIN and stops. That is why a backgrounded interactive program mysteriously freezes.
  4. Window size is not part of termios. It is struct winsize set with ioctl(TIOCSWINSZ), and changing it delivers SIGWINCH, signal 28, to the foreground group.
  5. Real job-control evidence from a pseudo-terminal session in this sandbox:
demo$ sleep 300 &
[1] 30514
demo$ jobs
[1]+  Running                 sleep 300 &
demo$ ps -o pid,pgid,stat,tty,comm --ppid $$
  PID  PGID STAT TT       COMMAND
30514 30514 S    pts/0    sleep
30515 30515 R+   pts/0    ps
demo$ kill %1
  1. Note that sleep has its own PGID equal to its PID. Each job gets its own process group, which is what makes Ctrl-C hit one job and not all of them.
  2. The + in R+ means “in the foreground process group”. sleep has no plus, because it is a background job.
  3. Windows had no equivalent until ConPTY, the Windows Pseudo Console API, which first shipped in the Windows 10 October 2018 update, version 1809. Before that, tools faked it by screen-scraping the console.
  4. The honest version: a PTY is not a perfect terminal. There is no real baud rate, no parity, no modem control lines. stty will happily accept settings for hardware that does not exist and silently ignore them.

WORDS19.3.6 remember these#

  1. PTY — a fake wire between a window and a shell — a pseudo-terminal device pair, master plus slave, joined in the kernel.
  2. Line discipline — the kernel code that edits your typing — the tty layer implementing canonical mode, echo and signal generation.
  3. Canonical mode — input is handed over one whole line at a time — ICANON set in termios.c_lflag, with line editing by the kernel.
  4. Raw mode — every key reaches the program instantly — ICANON and ECHO cleared, VMIN and VTIME controlling read behaviour.
  5. Foreground process group — the job that owns the keyboard right now — the process group returned by tcgetpgrp on the controlling terminal.
  6. SIGINT — the polite “stop that” signal from Ctrl-C — signal 2, default action terminate, catchable and ignorable.
  7. Job control — running several programs from one terminal — process groups, sessions and the SIGTSTP, SIGCONT, SIGTTIN, SIGTTOU family.

19.4 What a shell is#

PLAIN19.4.1 in simple words#

  1. A shell is a program with a very small job description.
  2. It reads a line of text. It works out what you meant. It asks the operating system to run something. It waits. It prints a prompt again.
  3. That is the whole loop. Everything else is decoration on those four steps.
  4. The shell is not part of the operating system. It is an ordinary program.
  5. You can have several shells installed and switch between them freely.
  6. The shell is also a programming language, with variables, conditions and loops. That is what makes shell scripts possible.
  7. A few commands are built into the shell itself, because they must be. cd is the main one.
  8. cd cannot be a separate program, because a separate program cannot change its parent’s current directory.
  9. Everything else, ls and grep and python, is a real file on disk that the shell finds and launches.

PLAIN19.4.2 a picture in your head#

  1. Think of a restaurant with one waiter and a kitchen.
  2. You say “two coffees and the soup”. The waiter does not cook.
  3. The waiter interprets. “Two coffees” becomes two separate drink orders. “The soup” means today’s soup, so the waiter fills in the detail.
  4. The waiter walks to the kitchen and passes the orders. The kitchen is the operating system.
  5. The waiter then stands and waits until the food is ready, and brings it back.
  6. Then the waiter returns to your table and stands ready again. That is the prompt.
  7. If you ask for something not on the menu, the waiter comes back and says so. That is command not found.
  8. The waiter can also do a few things without the kitchen: move you to another table, remember your name. Those are the built-in commands.

Where this comparison breaks: the waiter understands meaning, and will guess sensibly if you are vague. The shell does not understand anything. It applies fixed textual rules in a fixed order. If those rules turn your line into nonsense, it passes the nonsense to the kitchen without hesitation. A waiter who behaved like a shell would fetch a chair when you said “chair” in the middle of a sentence about the weather.

PLAIN19.4.3 a worked example#

  1. You type this and press Enter:
grep -c 500 access.log
  1. Step 1, read. The shell has the line as a string of 24 characters.
  2. Step 2, split into words. It uses spaces and tabs, giving four words: grep, -c, 500, access.log.
  3. Step 3, expand. It checks each word for things it must rewrite: variables, wildcards, braces, tildes, backticks. Here there are none, so nothing changes.
  4. Step 4, find the program. grep has no slash in it, so the shell searches the directories listed in PATH, in order.
  5. It finds /usr/bin/grep and stops looking.
  6. Step 5, run it. The shell makes a copy of itself, and the copy replaces itself with grep.
  7. Step 6, wait. The shell sleeps until grep finishes.
  8. grep prints 3 and exits with status 0.
  9. Step 7, record the result. The shell stores 0 in the variable ? and prints the prompt.
  10. The real run, from this sandbox:
$ grep -c 500 access.log
3
$ echo $?
0

PLAIN19.4.4 what is really happening inside#

  1. The shell cannot simply “become” grep, because then it would be gone and you would have no shell left.
  2. So it uses a two-step trick that UNIX has used since the beginning.
  3. First it calls fork. The operating system makes a near-identical copy of the shell process. Two processes now exist, running the same code.
  4. Both come back from fork, but with different answers. The parent gets the child’s process number. The child gets zero.
  5. That difference is how each one knows who it is.
  6. The child then calls exec. This does not create a process. It throws away the current program’s memory and loads a new program in its place.
  7. The process number does not change. Open files do not change. Environment variables do not change. Only the code and data are replaced.
  8. This is exactly why redirection works. Between fork and exec, the child can quietly rearrange its own files, and the new program inherits the arrangement without knowing.
  9. The parent calls wait. It sleeps until the child ends, and collects the child’s exit number.
  10. If you typed & at the end, the parent skips the waiting and prints the prompt immediately. That is a background job.
  11. Between pressing Enter and seeing output, then: line read, words split, expansions applied, program located, fork, file descriptors set up, exec, program runs, output travels back up the pseudo-terminal, parent collects the exit status, prompt printed.
  12. On a modern machine that whole sequence takes well under a millisecond, plus however long your program actually takes.

TECHNICAL19.4.5 the engineer’s version#

  1. The canonical loop, in C-like pseudocode, is short enough to memorize:
for (;;) {
    print_prompt();
    line = read_line();          /* from fd 0 */
    words = tokenize(line);
    words = expand(words);       /* order matters, see 19.12 */
    if (is_builtin(words[0])) { run_builtin(words); continue; }
    pid = fork();
    if (pid == 0) {
        setup_redirections();    /* dup2 on 0,1,2 */
        execvp(words[0], words); /* only returns on failure */
        _exit(127);              /* command not found */
    }
    waitpid(pid, &status, 0);
    last_status = WEXITSTATUS(status);
}
  1. fork is defined in POSIX. Linux implements it with clone. Modern implementations use copy-on-write, so the copy is cheap: page tables are duplicated, physical pages are shared until written.
  2. vfork and posix_spawn exist as cheaper variants. Real shells often use fork anyway because the child needs to run arbitrary setup code.
  3. execvp is the variant that searches PATH and takes an argument vector. The e variants take an explicit environment; without them the current environ is inherited.
  4. A successful exec never returns. Any code after it runs only on failure, which is why the example calls _exit(127).
  5. Exit status is packed into an integer by wait. WEXITSTATUS extracts the low 8 bits. WIFSIGNALED and WTERMSIG cover death by signal.
  6. Builtins are required for anything that must change shell state: cd, export, exec, set, unset, shift, trap, read, wait, eval, source, umask, and the job control commands.
  7. Some builtins exist only for speed. echo, test, [, pwd, kill and printf all exist as real binaries in /usr/bin as well.
  8. You can see the duplication:
$ type -a echo
echo is a shell builtin
echo is /usr/bin/echo
echo is /bin/echo
$ type cd
cd is a shell builtin
  1. This matters. /usr/bin/echo -e and the bash builtin echo -e behave differently, and scripts that assume one get the other under sh. Use printf when it matters. That advice is a convention, not a standard, but it is close to universal among careful script authors.
  2. Instrument the whole thing with strace -f -e trace=execve,clone,wait4 on Linux, or dtruss on macOS, which needs elevated privileges and System Integrity Protection considerations.

WORDS19.4.6 remember these#

  1. Shell — the program that runs your typed commands — a command language interpreter and scripting language.
  2. Built-in — a command the shell performs itself — a function inside the shell binary, not a file in PATH.
  3. fork — make a copy of the running program — the POSIX system call that duplicates a process, returning 0 to the child.
  4. exec — replace this program with another — the execve family, which overwrites the address space and keeps the PID.
  5. wait — pause until the child is finished — waitpid, which reaps the child and returns its termination status.
  6. Prompt — the text asking you for a command — a shell-generated string from PS1, printed to the terminal before each read.

19.5 The shells themselves#

PLAIN19.5.1 in simple words#

  1. There have been many shells. A handful matter.
  2. The first UNIX shell was written by Ken Thompson in 1971. It could run programs and redirect their input and output. It could not do much else.
  3. Stephen Bourne wrote a much better one at Bell Labs, and it shipped as the default in Version 7 UNIX. That is the Bourne shell, the program sh.
  4. It added variables, loops, conditions and functions. It made shell scripting a real thing. Everything since is measured against it.
  5. Bill Joy wrote the C shell at Berkeley, with a syntax that looked more like the C language, and with job control and command history.
  6. David Korn at Bell Labs wrote the Korn shell, taking the good ideas from the C shell and putting them into a Bourne-compatible shell.
  7. Brian Fox wrote bash for the GNU project, free of licence restrictions, and it became the shell of Linux.
  8. Paul Falstad wrote zsh, which is Bourne-compatible but with far better completion and matching. Apple made it the macOS default in 2019.
  9. fish broke compatibility on purpose, to be friendly out of the box.
  10. PowerShell from Microsoft is not in this family at all. It moves objects between commands instead of text.

PLAIN19.5.2 a picture in your head#

  1. Think of English and its descendants over centuries.
  2. Old English is the Thompson shell: recognizable, but you cannot read a newspaper with it.
  3. Middle English is the Bourne shell: the grammar settles, and texts written in it are still readable today with effort.
  4. Then two dialects grow in different towns. One is the Korn shell, which keeps the old grammar and adds new words. The other is the C shell, which changes the grammar and confuses travellers.
  5. bash is the modern standard dialect, taught in schools, understood everywhere, slightly dull.
  6. zsh is the same language spoken by people who care about pronunciation, with a large vocabulary of convenience.
  7. fish is a constructed language: cleaner and easier, but nobody else’s documents work in it.
  8. PowerShell is not a dialect of English. It is a different language family entirely, from a different continent.

Where this comparison breaks: languages drift by accident, but shells were designed on purpose, by named people, with written justifications. And unlike English, there is a formal written standard for shell grammar: POSIX. A script written to that standard genuinely runs on all of the Bourne-family shells, which is not something you can say about human dialects.

PLAIN19.5.3 a worked example#

  1. The same task, count files ending in .md, in four shells.
# sh, bash, zsh, ksh: the Bourne family
n=$(ls *.md | wc -l)
echo "there are $n files"

# csh and tcsh: different assignment syntax entirely
set n = `ls *.md | wc -l`
echo "there are $n files"

# fish: no dollar on assignment, no equals sign
set n (ls *.md | wc -l)
echo "there are $n files"

# PowerShell: no text at all, real objects
$n = (Get-ChildItem *.md).Count
"there are $n files"
  1. Notice that the Bourne family line is identical across four shells. That is the value of POSIX compatibility.
  2. Notice that the C shell needs spaces around = and uses set. Scripts do not port between the families.
  3. Notice that PowerShell never counted lines of text. Get-ChildItem returned a list of file objects, and .Count asked the list how long it was.
  4. That last point is the whole design difference. In UNIX shells, the thing passing between commands is bytes. In PowerShell it is typed objects with properties.

PLAIN19.5.4 what is really happening inside#

  1. Why did so many shells appear? Because the shell is small, personal, and used constantly. Small annoyances are worth fixing.
  2. The C shell’s improvements were real: job control, history with !!, aliases, directory stacks.
  3. Its scripting was genuinely bad. A widely circulated essay by Tom Christiansen, “Csh Programming Considered Harmful”, listed the reasons, and the argument was largely won.
  4. The Korn shell showed you could have both: Bourne syntax plus the interactive features. It became the commercial UNIX default.
  5. bash exists for a legal reason as much as a technical one. GNU needed a shell it could ship freely, without AT&T code.
  6. zsh went further on interactive quality: completion that understands the command you are typing, spelling correction, shared history, better globbing.
  7. Apple’s move to zsh in 2019 was also partly legal. bash version 4 changed to the GPL version 3 licence, which Apple will not ship, so macOS was frozen on bash 3.2 from 2007.
  8. fish decided that compatibility was the thing holding shells back. It has syntax highlighting and autosuggestions with no configuration at all.
  9. PowerShell came from a different problem. Windows configuration is not text files, so parsing text was useless. Passing objects was the natural answer on that system.

TECHNICAL19.5.5 the engineer’s version#

Shell Year Author Note
sh (V6) 1971 Ken Thompson first UNIX shell
sh 1979 Stephen Bourne shipped in V7 UNIX
csh 1978 Bill Joy job control, history
ksh 1983 David Korn Bourne plus csh ideas
bash 1989 Brian Fox GNU, Linux default
zsh 1990 Paul Falstad macOS default 2019
fish 2005 A. Liljencrantz not POSIX by design
PwrShell 2006 Jeffrey Snover object pipeline
  1. Precise dates worth knowing. Bash 1.0 was released on 8 June 1989 by Brian Fox at the Free Software Foundation. The name is Richard Stallman’s pun: Bourne-again shell.
  2. zsh 1.0 was released in 1990 by Paul Falstad, then a sophomore at Princeton University. The name comes from the login ID of a Princeton teaching assistant, Zhong Shao.
  3. fish 1.0 was released on 13 February 2005 by Axel Liljencrantz. The name is “friendly interactive shell”.
  4. PowerShell’s design was published in the Monad Manifesto by Jeffrey Snover in August 2002. It was demonstrated in October 2003, renamed PowerShell on 25 April 2006, and version 1.0 shipped on 14 November 2006.
  5. PowerShell Core 6.0, released January 2018, made it cross-platform and open source. It runs on Linux and macOS today.
  6. macOS 10.15 Catalina, released 7 October 2019, made zsh the default login shell for new accounts. Existing accounts kept bash. Apple ships /bin/bash as version 3.2.57 for licence reasons and it is not updated.
  7. The POSIX shell is specified in IEEE Std 1003.1, the Shell and Utilities volume. sh on Debian and Ubuntu is dash, not bash, which is a common source of “works on my machine” script failures.
  8. Verified in this sandbox:
$ ls -l /bin/sh
lrwxrwxrwx 1 root root 4 Mar 31  2024 /bin/sh -> dash
$ bash --version | head -1
GNU bash, version 5.2.21(1)-release (x86_64-pc-linux-gnu)
  1. Practical advice, and this is opinion held by most working engineers rather than a rule: write scripts for sh or bash, use whatever you like interactively. Never write scripts in csh. Never assume #!/bin/sh gives you bash.
  2. Where experts disagree: some argue that fish and zsh’s improvements should be adopted in scripts too, and that POSIX compatibility is a museum concern. Others point out that servers, containers and rescue images often contain only sh, so portable scripts still pay. Both are right in their own context.

WORDS19.5.6 remember these#

  1. Bourne shell — the original serious shell — sh, from Version 7 UNIX 1979, ancestor of the POSIX shell grammar.
  2. POSIX shell — the written standard for shell syntax — IEEE Std 1003.1 Shell and Utilities volume.
  3. bash — the common Linux shell — Bourne-again shell, GNU project, first released June 1989.
  4. zsh — the macOS default since 2019 — Z shell, Bourne-compatible with advanced completion and globbing.
  5. dash — a small fast sh — Debian Almquist shell, POSIX-only, /bin/sh on Debian and Ubuntu.
  6. Object pipeline — passing structured data instead of text — PowerShell’s model, where commands emit and consume .NET objects.

19.6 stdin, stdout and stderr#

PLAIN19.6.1 in simple words#

  1. Every program starts life with three channels already open.
  2. Channel 0 is standard input. It is where the program reads from. By default, your keyboard.
  3. Channel 1 is standard output. It is where results go. By default, your screen.
  4. Channel 2 is standard error. It is where complaints go. Also your screen, by default.
  5. Output and errors are separate on purpose. This is one of the best design decisions in UNIX.
  6. If they were mixed, saving a program’s results to a file would also save its error messages into the same file, ruining the data.
  7. Because they are separate, you can capture the results and still see the errors on your screen, live.
  8. The shell lets you point any of these channels somewhere else before the program starts.
  9. That is called redirection, and the program never knows it happened.

PLAIN19.6.2 a picture in your head#

  1. Think of a factory machine with three pipes attached.
  2. One pipe brings raw material in at the top.
  3. One pipe sends finished product out of the front.
  4. One pipe sends scrap and warning notes out of the side.
  5. The machine does not know or care where the pipes go. It just pushes things into them.
  6. Before switching the machine on, you can move any pipe. Put the product pipe into a barrel. Leave the scrap pipe pointing at the floor so you notice it.
  7. Or connect the product pipe of one machine to the input pipe of the next machine. Now you have a production line.
  8. Nothing inside either machine changed. Only the plumbing.

Where this comparison breaks: real pipes have no memory, but these have a small buffer, so a fast machine can run ahead of a slow one for a while. And the scrap pipe is not really for scrap. Progress messages, prompts and warnings all come out of it, including from programs that are working perfectly.

PLAIN19.6.3 a worked example#

  1. Here is a real run. The directory has a.txt and does not have nope.txt.
  2. First, no redirection at all. Both streams reach the screen and get mixed:
$ ls a.txt nope.txt
ls: cannot access 'nope.txt': No such file or directory
a.txt
  1. Now send only standard output to a file:
$ ls a.txt nope.txt > out.txt
ls: cannot access 'nope.txt': No such file or directory
$ cat out.txt
a.txt
  1. The error still appeared live on the screen. The file has only the good result. That is exactly what you want.
  2. Now send only standard error to a file:
$ ls a.txt nope.txt 2> err.txt
a.txt
$ cat err.txt
ls: cannot access 'nope.txt': No such file or directory
  1. Now send both to the same file, correctly:
$ ls a.txt nope.txt > both.txt 2>&1
$ cat both.txt
ls: cannot access 'nope.txt': No such file or directory
a.txt
  1. Now the same two pieces in the wrong order. Watch what happens:
$ ls a.txt nope.txt 2>&1 > wrong.txt
ls: cannot access 'nope.txt': No such file or directory
$ cat wrong.txt
a.txt
  1. The error went to the screen, not the file. The redirection did not work.
  2. Why: 2>&1 means “make channel 2 point wherever channel 1 points right now”. At that moment channel 1 still points at the screen.
  3. Only afterwards did > wrong.txt move channel 1 to the file. Channel 2 was already aimed at the screen and stayed there.
  4. The rule to remember: redirections are applied left to right, and 2>&1 copies the current destination, not a promise to follow.
  5. So > file 2>&1 is right and 2>&1 > file is almost always a mistake.

PLAIN19.6.4 what is really happening inside#

  1. The three channels are numbers, not names. The number is a file descriptor: an index into a table the kernel keeps for your process.
  2. Entry 0, 1 and 2 are filled in before your program starts. They are not special to the kernel. They are special only by agreement.
  3. When you run a program from a terminal, all three entries point at the same pseudo-terminal device. That is why output and errors both land on screen.
  4. When you write > out.txt, the shell does this between fork and exec:
  5. It opens out.txt for writing, creating or emptying it. The kernel gives back the lowest free number, say 3.
  6. It calls dup2(3, 1). That copies entry 3 over entry 1. Entry 1 now points at the file.
  7. It closes 3, which is no longer needed. Then it calls exec.
  8. The new program starts with entry 1 already pointing at the file. It writes to 1 as usual, with no idea anything is different.
  9. 2>&1 is just dup2(1, 2). Copy whatever entry 1 currently holds into entry 2.
  10. That is the entire mechanism. There is no magic and no cooperation from the program.
  11. You can see the table on Linux. Every process has one under /proc:
$ ls -l /proc/self/fd
lr-x------ 0 -> /dev/null
l-wx------ 1 -> pipe:[159625]
l-wx------ 2 -> /dev/null
lr-x------ 3 -> /proc/10189/fd
  1. In that real capture, the process was reading nothing, writing into a pipe, and throwing errors away.

TECHNICAL19.6.5 the engineer’s version#

Syntax Meaning Underlying call
> f stdout to f, truncate open O_TRUNC, dup2
>> f stdout to f, append open O_APPEND
< f stdin from f open O_RDONLY
2> f stderr to f dup2 to fd 2
2>&1 stderr to current stdout dup2(1, 2)
&> f both to f (bash, zsh) two dup2 calls
<<< str here-string as stdin temp file or pipe
<< EOF here-document as stdin temp file or pipe
2>/dev/null discard errors open the null dev
>&- close the descriptor close(1)
  1. &> and &>> are bash and zsh extensions. The portable POSIX spelling is > file 2>&1. Use the portable form in scripts with #!/bin/sh.
  2. |& in bash is shorthand for 2>&1 |. It is also not POSIX.
  3. Buffering is a libc behaviour, not a kernel one, and it surprises people. The C standard library uses line buffering for stdout when it is a terminal and full buffering, typically 4096 or 8192 bytes, when it is a pipe or file.
  4. stderr is unbuffered by default. That is why error messages can appear before the output that logically came first.
  5. Fix it when it matters with stdbuf -o0 -e0 command, or unbuffer from the expect package, or in Python with python3 -u.
  6. /dev/null is the discard device, major 1 minor 3 on Linux. Writes succeed and vanish, reads return end of file immediately.
  7. /dev/stdout, /dev/stderr and /dev/fd/N exist on Linux and macOS and let you name descriptors as paths. Useful with tools that only accept a filename.
  8. Descriptors above 2 are yours to use. exec 3< file opens a private read channel that survives across commands until exec 3<&- closes it.
  9. Inspect a live process’s descriptors with lsof -p PID, or on Linux read /proc/PID/fd. Real lsof output from this sandbox:
$ lsof -i -P -n | head -3
COMMAND  PID USER FD  TYPE NAME
claude   462 root 11u IPv4 TCP 192.0.2.2:39400->160.79.104.10:443
claude   462 root 13u IPv4 TCP 127.0.0.1:43279 (LISTEN)
  1. The honest version: “everything is a file” is a slogan, not a fact. Descriptors point to file descriptions, which may be regular files, pipes, sockets, devices, epoll instances, timers or signal queues. They all support read and write but not equally: you cannot seek in a pipe, and write to a socket can partially succeed.

WORDS19.6.6 remember these#

  1. File descriptor — the number a program uses to name an open channel — a small non-negative integer indexing the process file descriptor table.
  2. stdin — where a program reads from — file descriptor 0, FILE *stdin in C.
  3. stdout — where results go — file descriptor 1, line buffered to a terminal, block buffered otherwise.
  4. stderr — where complaints go — file descriptor 2, unbuffered by default, kept separate so results stay clean.
  5. Redirection — pointing a channel somewhere else — open plus dup2 performed by the shell between fork and exec.
  6. /dev/null — the bin — the null device, discards writes, returns EOF on read.
  7. Here-document — inline text fed to a program as input — the << WORD construct, terminated by WORD on its own line.

19.7 Pipes#

PLAIN19.7.1 in simple words#

  1. A pipe connects the output of one program straight to the input of the next.
  2. You write it with the vertical bar: a | b.
  3. Nothing is saved on disk. The data goes from one program to the other through memory.
  4. Both programs run at the same time. The second does not wait for the first to finish.
  5. This means you can process something enormous without ever holding all of it at once.
  6. It also means each program can be small, do one job, and know nothing about the others.
  7. That combination is generally considered the single best idea in UNIX.
  8. Doug McIlroy proposed it at Bell Labs, and it was added to UNIX in 1973.
  9. Before pipes, you saved to a temporary file and read it back. Everyone did it. Nobody enjoyed it.

PLAIN19.7.2 a picture in your head#

  1. Think of a bucket chain at a fire, before fire engines existed.
  2. Ten people stand in a line from the well to the fire.
  3. The first person does not fill a thousand buckets and then start passing them. They pass each bucket as soon as it is full.
  4. Water starts arriving at the fire within seconds, not hours.
  5. Nobody in the chain knows where the water comes from or where it goes. Each person knows only “take from the left, give to the right”.
  6. If a person in the middle is slow, the people behind them naturally wait. Nobody needs to coordinate this. The line just backs up.
  7. If the fire is put out, the person at the end walks away. The next person tries to hand over a bucket, finds nobody there, and stops too.
  8. That last case is important, and it has a name in UNIX. We come back to it.

Where this comparison breaks: real people can hold one bucket, while a pipe holds a fixed amount of data, 65536 bytes on Linux by default. And the writer does not “see” that the reader has gone. It gets told, forcefully, by a signal.

PLAIN19.7.3 a worked example#

  1. Here is a real log file, eight lines, from this sandbox. We will find which client caused the most server errors.
10.0.0.7  ... "GET /index.html HTTP/1.1" 200 5120
10.0.0.9  ... "GET /style.css HTTP/1.1" 200 812
10.0.0.7  ... "GET /missing HTTP/1.1" 404 152
10.0.0.12 ... "POST /api/login HTTP/1.1" 500 90
10.0.0.7  ... "GET /api/user HTTP/1.1" 500 90
10.0.0.9  ... "GET /index.html HTTP/1.1" 200 5120
10.0.0.12 ... "GET /api/user HTTP/1.1" 500 90
10.0.0.3  ... "GET /index.html HTTP/1.1" 200 5120
  1. Stage 1, keep only the server errors. Real output:
$ grep " 500 " access.log
10.0.0.12 ... "POST /api/login HTTP/1.1" 500 90
10.0.0.7  ... "GET /api/user HTTP/1.1" 500 90
10.0.0.12 ... "GET /api/user HTTP/1.1" 500 90
  1. Stage 2, keep only the first field, which is the client address:
$ grep " 500 " access.log | cut -d' ' -f1
10.0.0.12
10.0.0.7
10.0.0.12
  1. Stage 3, sort, so identical lines sit together:
$ ... | sort
10.0.0.12
10.0.0.12
10.0.0.7
  1. Stage 4, collapse the runs and count them:
$ ... | uniq -c
      2 10.0.0.12
      1 10.0.0.7
  1. Stage 5, sort by that count, largest first:
$ ... | sort -rn
      2 10.0.0.12
      1 10.0.0.7
  1. Stage 6, keep the top two:
$ grep " 500 " access.log | cut -d' ' -f1 | sort | uniq -c \
  | sort -rn | head -2
      2 10.0.0.12
      1 10.0.0.7
  1. Six programs. None of them knows about logs. Each does one small thing.
  2. uniq -c only collapses adjacent duplicates, which is exactly why sort must come before it. This trips up everyone once.
  3. The same pipeline works unchanged on a log of eight lines or eight hundred million, and uses the same tiny amount of memory either way.

PLAIN19.7.4 what is really happening inside#

  1. When the shell sees a | b, it asks the kernel for a pipe.
  2. The kernel creates a small buffer in memory and gives back two file descriptors: one you can read from, one you can write to.
  3. The shell forks twice, once for a and once for b.
  4. In the child that will run a, it points descriptor 1 at the write end.
  5. In the child that will run b, it points descriptor 0 at the read end.
  6. Both children close the ends they do not need. This matters enormously.
  7. Then both children exec. Both are now running at the same time.
  8. a writes as usual to descriptor 1, thinking it is writing to the screen.
  9. b reads as usual from descriptor 0, thinking it is reading a keyboard.
  10. When the buffer is full, a’s next write simply blocks. The kernel puts it to sleep until b takes some data out.
  11. When the buffer is empty, b’s read blocks until a puts something in.
  12. This automatic waiting is called back pressure, and it is why a pipeline cannot run out of memory no matter how much data flows through.
  13. Here is real proof from this sandbox that a pipe streams rather than waits:
$ time (seq 1 50000000 | head -1)
1

real    0m0.002s
  1. Producing fifty million numbers would take seconds. It finished in two thousandths of a second, because head stopped after one line and seq was killed.
  2. Now the ending case. When b exits, the read end is closed.
  3. The next time a writes, the kernel sees there is no reader left and sends a the signal SIGPIPE.
  4. The default action of SIGPIPE is to kill the process silently. That is by design: it stops a producer from filling the world with data nobody wants.
  5. This is why yes | head -3 ends instead of running forever.

TECHNICAL19.7.5 the engineer’s version#

  1. The system call is pipe(int fd[2]), giving fd[0] for reading and fd[1] for writing. pipe2 adds flags such as O_CLOEXEC.
  2. On Linux the default pipe capacity is 65536 bytes, which is sixteen pages of 4096 bytes. Verified here:
$ python3 -c 'import os, fcntl
> r, w = os.pipe()
> print(fcntl.fcntl(w, 1032))'
65536
$ cat /proc/sys/fs/pipe-max-size
1048576
  1. PIPE_BUF, which is 4096 on Linux and 512 minimum by POSIX, is a different number. It is the largest write guaranteed to be atomic when several writers share one pipe.
  2. Capacity can be changed per pipe with fcntl(fd, F_SETPIPE_SZ, n) up to /proc/sys/fs/pipe-max-size. On macOS the pipe buffer starts at 16384 bytes and the kernel may grow it; there is no F_SETPIPE_SZ.
  3. Closing unused ends is mandatory. If the shell left the write end open in the reader, the reader would never see end of file and the pipeline would hang forever. This is the classic pipe bug.
  4. SIGPIPE is signal 13. The shell reports death by signal N as exit status 128 plus N, so a SIGPIPE death shows as 141.
$ yes | head -3 >/dev/null; echo "${PIPESTATUS[@]}"
141 0
$ seq 1 100000000 | head -2 >/dev/null; echo "${PIPESTATUS[@]}"
141 0
  1. PIPESTATUS is a bash array holding every stage’s status. zsh calls it pipestatus. The plain $? gives only the last stage, which is why a failing first stage is invisible by default.
  2. set -o pipefail makes the pipeline’s status the rightmost non-zero status. Verified:
$ set -o pipefail; yes | head -3 >/dev/null; echo $?
141
  1. Servers and daemons usually call signal(SIGPIPE, SIG_IGN) and check for EPIPE from write instead, because a killed web server is worse than a dropped client.
  2. Doug McIlroy proposed pipes at Bell Labs; Ken Thompson implemented them in Version 3 UNIX in 1973. The vertical bar notation and the tee command date from that period.
  3. Named pipes, or FIFOs, are the same buffer with a filesystem name, created with mkfifo. Opening one for writing blocks until a reader appears, which is a real behaviour, demonstrated in this sandbox: a writer sat blocked for a full second until cat opened the other end.
  4. Process substitution, <(command), is bash and zsh only. It creates a FIFO or a /dev/fd entry and substitutes its path, letting you feed a command’s output to a tool that demands a filename.

WORDS19.7.6 remember these#

  1. Pipe — a direct connection from one program’s output to another’s input — an anonymous unidirectional kernel buffer with two file descriptors.
  2. Back pressure — a fast producer waiting for a slow consumer — blocking of write when the pipe buffer is full.
  3. SIGPIPE — the “nobody is listening” signal — signal 13, sent to a writer whose read end has closed, default action terminate.
  4. PIPESTATUS — the list of results from every stage — a bash array of exit statuses for the last foreground pipeline.
  5. FIFO — a pipe with a name on disk — a named pipe created by mkfifo, type p in ls -l.
  6. tee — split the flow so you can see it and save it — a filter that copies stdin to stdout and to named files.

19.8 Exit codes#

PLAIN19.8.1 in simple words#

  1. Every program, when it finishes, hands back a single number.
  2. That number is called the exit code or exit status.
  3. It has nothing to do with what the program printed. It is a separate, private answer to one question: did this work?
  4. Zero means success. Anything else means failure.
  5. That feels backwards. It is not. There is one way to succeed and many ways to fail, so zero is the single success and the rest label the failure.
  6. The number is small: 0 to 255 only. It cannot carry a message, only a code.
  7. You see the last one with echo $?.
  8. The shell uses it to decide whether to run the next command when you chain commands together.
  9. Automated build systems test only this number. Not your output. Not your colours. The number.

PLAIN19.8.2 a picture in your head#

  1. Think of a factory quality inspector at the end of a line.
  2. The inspector does not describe the product. There is a label for that.
  3. The inspector stamps one thing on the crate: a code.
  4. Code 0 means “passed”. Any other code means “rejected”, and different codes say which test it failed.
  5. The next station on the line reads only the stamp. It never opens the crate.
  6. If the stamp is 0, the crate moves on. If not, the line stops and an alarm sounds.
  7. A crate can contain a beautiful description of a disaster and still be stamped 0, if the inspector was careless. Then the disaster moves down the line unnoticed.

Where this comparison breaks: the stamp is applied by the program itself, and a badly written program can stamp 0 on anything. This happens constantly and is the single most common cause of a build system reporting success on a broken build. The check is only as honest as the program doing the checking.

PLAIN19.8.3 a worked example#

  1. Real runs from this sandbox. Each shows the command, the message, and the code.
$ ls /nonexistent
ls: cannot access '/nonexistent': No such file or directory
$ echo $?
2

$ true;  echo $?      -> 0
$ false; echo $?      -> 1

$ grep -q zzz /etc/hostname; echo $?
1

$ bash -c 'exit 42'; echo $?
42

$ /etc/hostname; echo $?      # a file that is not executable
126

$ nosuchcommand123; echo $?
127

$ bash -c 'kill -INT $$'; echo $?
130

$ bash -c 'kill -TERM $$'; echo $?
143
  1. Read the meanings. grep returning 1 is not an error. It means “I searched correctly and found nothing”.
  2. That distinction is why grep -q pattern file && do_something works cleanly. It is also why grep returning 2 means a real error, like an unreadable file.
  3. 126 means “found it, but could not run it”. Usually a missing execute permission.
  4. 127 means “could not find it at all”. If you see 127 in a build log, look at PATH first.
  5. 130 is 128 plus 2, and signal 2 is SIGINT. Somebody pressed Ctrl-C.
  6. 143 is 128 plus 15, and signal 15 is SIGTERM. Something asked it to stop, usually a timeout or an orchestrator.
  7. 137 is 128 plus 9, SIGKILL. In a container that almost always means the memory limit was hit and the kernel killed it.
  8. Now chaining. Real output:
$ true  && echo "ran because true"
ran because true
$ false && echo "not printed"
$ false || echo "ran because false"
ran because false
$ true  || echo "not printed"
$ false ; echo "semicolon always runs"
semicolon always runs
  1. A && B runs B only if A succeeded. A || B runs B only if A failed.
  2. A ; B runs B either way. This is the one that hides failures.

PLAIN19.8.4 what is really happening inside#

  1. When a program calls exit(3), the number 3 goes to the kernel.
  2. The process dies, but a small record stays behind holding that number.
  3. The parent calls wait, collects the record, and the record is freed. A process whose record has not yet been collected is a zombie.
  4. The kernel packs more than the exit code into that record. It also stores whether the process was killed by a signal, and which one.
  5. The shell unpacks it. If the process exited normally, $? is the code. If it was killed by signal N, the shell reports 128 plus N.
  6. That 128 rule is a shell convention, not a kernel fact. The kernel keeps the two cases genuinely separate.
  7. Why the limit of 255: the exit status field is eight bits wide in the traditional wait encoding.
  8. So exit 256 becomes 0 and exit -1 becomes 255. Both are silent traps.
  9. In a pipeline, $? reports only the last command. The others are lost unless you ask for them.
  10. set -e makes the shell exit as soon as any command returns non-zero. It is how you stop a script from carrying on after a disaster.

TECHNICAL19.8.5 the engineer’s version#

Code Meaning Typical source
0 success everything
1 general failure most tools
2 misuse or real error grep, ls, bash
64-78 sysexits.h categories BSD-style tools
126 found but not executable shell
127 command not found shell
128+N killed by signal N shell reporting
130 Ctrl-C, SIGINT interactive use
137 SIGKILL, often out of memory containers
143 SIGTERM timeouts, systemd
255 out of range or -1 buggy exit calls
  1. The kernel encoding is defined by POSIX macros: WIFEXITED, WEXITSTATUS, WIFSIGNALED, WTERMSIG, WCOREDUMP, WIFSTOPPED.
  2. WEXITSTATUS is only valid when WIFEXITED is true. Reading it otherwise returns rubbish, and this is a real bug in real code.
  3. The 64 to 78 range comes from sysexits.h, introduced in 4.3BSD. EX_USAGE is 64, EX_DATAERR 65, EX_NOINPUT 66, EX_UNAVAILABLE 69, EX_SOFTWARE 70, EX_CONFIG 78. It is a convention, widely ignored, but useful if you follow it consistently within one project.
  4. grep documents its own contract in its manual page, and the exact wording from the real page in this sandbox is worth reading:
EXIT STATUS
       Normally the exit status is 0 if a line is selected, 1 if
       no lines were selected, and 2 if an error occurred.
  1. diff uses 0 for identical, 1 for different, 2 for trouble. curl has about 100 documented codes; 6 is “could not resolve host”, 7 “failed to connect”, 28 “operation timed out”, 56 “failure receiving data”. A real failure captured here returned 56.
  2. Continuous integration works exactly like this. A GitHub Actions step fails when the process exits non-zero. Nothing else is inspected.
  3. Consequences that bite in practice. A step that ends with a pipeline reports only the last stage, so run_tests | tee log.txt reports the status of tee, which basically always succeeds.
  4. The fix is set -o pipefail, or checking PIPESTATUS, or restructuring so the important command is last.
  5. set -e has genuine sharp edges. It does not trigger inside a condition, inside && or || chains except the last element, or inside a command whose status is being tested. Experts disagree about whether it is safe.
  6. Both sides of that argument: the “always use it” camp says most scripts are short and unhandled failure is worse than surprising exits. The “never use it” camp says the exceptions are too subtle to remember and explicit if ! cmd; then checks are honest. The compromise most teams settle on is set -euo pipefail plus explicit handling around known-noisy commands.

WORDS19.8.6 remember these#

  1. Exit code — the single number a program leaves behind — the 8-bit exit status collected by wait.
  2. $? — the shell variable holding the last one — expands to the exit status of the most recently completed foreground command.
  3. Zombie — a finished process whose result nobody collected — a process in state Z, holding only its exit record.
  4. && — run the next one only if this worked — the AND list operator, short circuits on non-zero status.
  5. || — run the next one only if this failed — the OR list operator, short circuits on zero status.
  6. set -e — stop the script at the first failure — errexit, which exits on any untested non-zero status.
  7. sysexits — an agreed table of failure categories — the 64 to 78 range from the BSD sysexits.h header.

19.9 Environment variables#

PLAIN19.9.1 in simple words#

  1. Every running program carries a small list of name and value pairs.
  2. That list is the environment. It is just text, copied into the program when it starts.
  3. HOME says where your home directory is. PATH says where to look for commands. LANG says which language and character set to use.
  4. When a program starts another program, the child gets a copy of the list.
  5. It is a copy, not a link. The child can change its own copy and the parent never notices.
  6. There is no way for a child to change its parent’s environment. This is a hard rule, and it explains many confusing situations.
  7. Inside the shell you can also have plain variables that are not in the environment. They exist only in that shell.
  8. To move one into the environment you export it. That is the whole difference.
  9. Where these get set is a separate mess, and it is why your PATH can be right in one window and wrong in another.

PLAIN19.9.2 a picture in your head#

  1. Think of a person leaving home for work with a small notebook in their pocket.
  2. The notebook lists their address, their language, and the list of shops they are allowed to visit.
  3. When they hire an assistant, they photocopy the notebook and hand the copy over.
  4. The assistant can scribble in their copy. The original is untouched.
  5. When the assistant hires their own assistant, another photocopy is made, including any scribbles.
  6. So changes flow downwards only, and only to people hired after the change.
  7. If you edit the master notebook at home, everybody already out working still has the old copy.
  8. That is exactly why changing a setting file does not affect terminal windows you already have open.

Where this comparison breaks: a notebook can hold anything, but the environment holds only text, with a size limit, and no structure. There are no lists, no numbers and no nesting. Everything is a string, and any structure you think you see, like the colons in PATH, is a convention agreed by the programs reading it.

PLAIN19.9.3 a worked example#

  1. Real run showing the difference between a shell variable and an exported one:
$ MYVAR=hello
$ echo "in this shell: $MYVAR"
in this shell: hello
$ bash -c 'echo child sees: [$MYVAR]'
child sees: []
$ export MYVAR
$ bash -c 'echo child sees: [$MYVAR]'
child sees: [hello]
  1. Before export, the child saw nothing. After export, it saw the value. Nothing else changed.
  2. Now PATH. Here is the real one from this sandbox, shortened:
$ echo $PATH
/home/claude/.npm-global/bin:/root/.local/bin:/root/.cargo/bin:
/usr/local/go/bin:/opt/node22/bin:/usr/local/sbin:/usr/local/bin:
/usr/sbin:/usr/bin:/sbin:/bin
  1. It is one string. Directories separated by colons. Searched strictly left to right, first match wins.
  2. Find out which one wins:
$ which ls
/usr/bin/ls
$ type ls
ls is /usr/bin/ls
$ command -v grep
/usr/bin/grep
  1. Order matters enormously. If /usr/local/bin comes before /usr/bin and both contain python3, you get the one in /usr/local/bin.
  2. This is the whole explanation for “it works in my terminal but not in the cron job”. Different PATH, different program.

PLAIN19.9.4 what is really happening inside#

  1. The environment is passed to execve as an array of strings, each shaped NAME=value, ending with a null pointer.
  2. The kernel copies that array onto the new program’s stack. The C library makes it available as the global environ.
  3. Nothing validates it. Any string with an = is accepted.
  4. When you type a command with no slash in it, the shell splits PATH on colons and tries each directory in turn.
  5. For each, it builds a full path and tries to execute it. First success wins. If none work, you get 127.
  6. An empty entry in PATH, such as a leading colon or two colons together, means the current directory. That is a security hazard and should be avoided.
  7. The current directory is not in PATH by default on any sane system. That is deliberate.
  8. If it were, someone could leave a file called ls in a shared directory, and you would run it by accident.
  9. So to run a program in the directory you are standing in, you must say ./program. The slash tells the shell not to search at all.
  10. Searching PATH for every command would be slow, so shells remember. This is called hashing.
$ hash -r          # forget everything
$ ls > /dev/null
$ grep --version > /dev/null
$ hash
hits    command
   1    /usr/bin/grep
   1    /usr/bin/ls
  1. The cache is why installing a new program sometimes does not take effect until you run hash -r or open a new shell. bash and zsh both do this.

TECHNICAL19.9.5 the engineer’s version#

Variable Holds Typical value
PATH command search list /usr/local/bin:/usr/bin
HOME your home directory /Users/name, /home/name
PWD current directory maintained by the shell
OLDPWD previous directory used by cd -
SHELL your login shell /bin/zsh
USER your login name from the passwd entry
TERM terminal capability name xterm-256color
LANG locale for everything en_US.UTF-8
EDITOR preferred text editor vim, nano, code -w
TMPDIR scratch directory /tmp, or per-user
LD_LIBRARY_PATH extra library dirs avoid unless forced
  1. PWD is maintained by the shell, not the kernel. The kernel truth is getcwd. They can disagree when symbolic links are involved, which is what cd -P and pwd -P exist to resolve.
  2. SHELL records your login shell from the password database. It does not tell you which shell is currently running. To find that, check $0, or $BASH_VERSION and $ZSH_VERSION.
  3. LANG and the LC_* family change program behaviour in ways people do not expect. Sort order is the classic case. Real measurement from this sandbox:
$ printf 'b\nA\na\nB\n' > letters.txt
$ LC_ALL=C sort letters.txt          -> A B a b
$ LC_ALL=en_US.UTF-8 sort letters.txt -> a A b B
  1. That is the same data, the same command, and a different answer. Scripts that must be reproducible set LC_ALL=C explicitly.
  2. Startup file order is the part everyone gets wrong. It depends on two independent questions: is this a login shell, and is it interactive.
Shell Login shell reads Interactive non-login
bash /etc/profile, then the ~/.bashrc only
first of ~/.bash_profile
~/.bash_login, ~/.profile
zsh /etc/zprofile, ~/.zprofile /etc/zshrc,
then /etc/zshrc, ~/.zshrc, ~/.zshrc
/etc/zlogin, ~/.zlogin
sh /etc/profile, ~/.profile ENV file if set
  1. bash reads only one of ~/.bash_profile, ~/.bash_login, ~/.profile, in that order, and stops at the first that exists. If you have both a .bash_profile and a .profile, the .profile is silently ignored.
  2. zsh always reads ~/.zshrc for interactive shells, login or not. That is why zsh setup is simpler and why macOS advice usually says “put it in .zshrc”.
  3. This explains the classic complaint. An SSH login is a login shell. A new tab in Terminal.app on macOS is also a login shell, by Apple’s choice, which differs from most Linux terminal emulators where a new tab is an interactive non-login shell.
  4. So a PATH line added to ~/.bashrc on a Linux server works in new tabs but not over ssh host command, which is non-interactive and reads neither.
  5. Real check of which files exist, from this sandbox:
$ for f in /etc/profile ~/.bash_profile ~/.bashrc \
>          ~/.profile ~/.zshrc
> do
>   [ -e $f ] && s=EXISTS || s=missing
>   printf '%-20s %s\n' "$f" "$s"
> done
/etc/profile         EXISTS
/root/.bash_profile  missing
/root/.bashrc        EXISTS
/root/.profile       EXISTS
/root/.zshrc         EXISTS
  1. GUI applications on macOS do not read your shell startup files at all. They inherit the environment from launchd. This is why a program launched from the Dock cannot find a tool that works fine in Terminal.
  2. Environment size is limited. On Linux the combined size of arguments and environment is capped by MAX_ARG_STRLEN at 128 KiB per string, and the total by the stack limit, typically a quarter of ulimit -s. Exceeding it gives E2BIG, seen as “Argument list too long”.
  3. Never put secrets in environment variables on a shared machine. On Linux /proc/PID/environ is readable by the owner, and process listings can leak command-line arguments to everyone.

WORDS19.9.6 remember these#

  1. Environment — the list of settings a program inherits — a null-terminated array of NAME=value strings passed through execve.
  2. Export — move a shell variable into the environment — mark it for inclusion in the child’s environment on the next exec.
  3. PATH — where the shell looks for commands — a colon-separated directory list, searched left to right, first match wins.
  4. Hashing — remembering where a command was found — the shell’s command location cache, cleared with hash -r.
  5. Login shell — the shell started when you sign in — a shell whose argv[0] begins with -, reading the profile files.
  6. Locale — language and formatting rules — the LANG and LC_* variables controlling collation, case, dates and messages.

19.10 Configuration on each system#

PLAIN19.10.1 in simple words#

  1. Every operating system needs somewhere to keep settings.
  2. Windows keeps almost all of them in one giant database called the registry.
  3. macOS and Linux keep them in ordinary files scattered around the disk.
  4. The Windows way is one place, one format, one tool. It is fast to read and hard to inspect by eye.
  5. The UNIX way is many places, many formats, and any text editor works.
  6. On Linux, system-wide settings live in /etc, and your personal settings live in files in your home directory whose names begin with a dot.
  7. A leading dot means “hidden” by convention. ls will not show it unless you ask with -a.
  8. These are called dotfiles, and people keep them in version control and copy them between machines.
  9. macOS does both. It has UNIX dotfiles and /etc underneath, and Apple’s own settings system on top, using files called property lists.
  10. That is why a Mac developer edits ~/.zshrc for the shell and never thinks about a registry, even though macOS has a settings database of its own.

PLAIN19.10.2 a picture in your head#

  1. Imagine two libraries.
  2. The first library has one enormous card catalogue in the entrance hall. Every fact about every book is in a drawer somewhere in it.
  3. Finding anything is fast, if you know the drawer. Browsing is impossible. The drawers are labelled in code.
  4. If the catalogue is damaged, the whole library stops working, because no book can be located.
  5. The second library writes each subject’s notes on a sheet of paper and pins it to the shelf that subject lives on.
  6. Finding a note means walking to the right shelf. Slower, but you can read it with your eyes, and you can photocopy one shelf’s notes and carry them elsewhere.
  7. If one sheet is torn, only that shelf is affected.
  8. Library one is the Windows registry. Library two is /etc and dotfiles.

Where this comparison breaks: the registry is far better engineered than a card catalogue. It is transactional, it supports permissions per entry, and it can be changed by policy across ten thousand machines at once. The scattered-files approach has no transactions at all: a half-written config file is simply a broken config file.

PLAIN19.10.3 a worked example#

  1. Say you want to change the shell prompt colour and the editor a tool opens.
  2. On Linux or macOS, you edit one text file:
# in ~/.zshrc or ~/.bashrc
export EDITOR=vim
export PS1='%n@%m %1~ %# '
  1. You then run source ~/.zshrc to apply it to the current shell, or open a new window.
  2. You can email that file to a colleague. It is 20 lines of text.
  3. On macOS, an application setting is different. To make Finder show hidden files:
defaults write com.apple.finder AppleShowAllFiles -bool true
killall Finder
  1. That wrote into a property list file at ~/Library/Preferences/com.apple.finder.plist.
  2. On Windows, the same class of change is a registry edit. In regedit you would navigate to a path such as:
HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion
    \Explorer\Advanced
  1. Then set the value Hidden to the number 1.
  2. Or from PowerShell, without the graphical tool:
Set-ItemProperty -Path 'HKCU:\Software\Microsoft\Windows\
  CurrentVersion\Explorer\Advanced' -Name Hidden -Value 1
  1. Three systems, one idea, three completely different mechanisms.

PLAIN19.10.4 what is really happening inside#

  1. The registry is a tree, like a filesystem, but stored in a few binary files.
  2. The top-level branches are called hives. The two you meet are:
  3. HKEY_LOCAL_MACHINE, shortened to HKLM. Settings for the whole computer. Changing it needs administrator rights.
  4. HKEY_CURRENT_USER, shortened to HKCU. Settings for the person signed in right now. You can change your own.
  5. Inside a hive are keys, which are like folders, and values, which are like files.
  6. A value has a name, a type and data. Types include string, expandable string, 32-bit number, 64-bit number, binary blob and multi-string.
  7. regedit.exe is the graphical browser. It is a plain tree view with the values on the right.
  8. Why it exists: before it, Windows kept settings in hundreds of .ini text files. There was no locking, no types, no permissions and no network management.
  9. Why it became a problem: it is a single shared namespace with no ownership rules. Any program can write anywhere it has rights to.
  10. Uninstalling a program often leaves its keys behind. Over years the registry accumulates dead entries, and a whole industry of “registry cleaner” products grew up around this, most of them useless or harmful.
  11. On macOS, the equivalent store is thousands of separate property list files, most in ~/Library/Preferences and /Library/Preferences.
  12. Each is named after a reverse domain identifier, such as com.apple.finder.plist, so applications cannot collide by accident.
  13. They are usually stored in a compact binary format, not readable text. That is a storage choice, not a philosophy choice.
  14. On Linux, there is no central store at all. Each program picks its own file and its own format, guided loosely by an agreed set of directories.

TECHNICAL19.10.5 the engineer’s version#

System Store Tool to read
Windows registry hives regedit, reg.exe
macOS plist files defaults, plutil
Linux /etc and dotfiles any text editor
Modern XDG directories any text editor
  1. Registry history. It first appeared in Windows 3.1, released 1992, as a single file REG.DAT limited to 64 KB, holding only OLE registration and file associations.
  2. Windows NT 3.1 in 1993 and Windows 95 in 1995 expanded it into the full hive structure with HKEY_LOCAL_MACHINE and HKEY_CURRENT_USER, and moved registry access into kernel mode.
  3. On modern Windows the hive files live in C:\Windows\System32\config for machine hives, and NTUSER.DAT in each user profile for HKCU. They are not editable as text.
  4. The five root keys are HKLM, HKCU, HKCR (classes, a merged view), HKU (all loaded user hives) and HKCC (current hardware profile). Only HKLM and HKU are truly stored; the others are views.
  5. Value types are named REG_SZ, REG_EXPAND_SZ, REG_DWORD, REG_QWORD, REG_BINARY, REG_MULTI_SZ.
  6. Windows also has Group Policy, which writes into the registry under Software\Policies and is enforced centrally. That central-management ability is the strongest argument for the registry’s existence.
  7. macOS plists conform to Apple’s property list format. The XML variant has a DOCTYPE from Apple; the binary variant starts with the eight bytes bplist00.
  8. defaults read com.apple.finder prints a domain. defaults write sets a value. plutil -convert xml1 file.plist makes a binary file readable, and plutil -lint validates one.
  9. There is a caching daemon, cfprefsd. Editing a plist file by hand while an application is running can be overwritten by the cache. Use defaults or quit the application first. This is a real and frequent trap.
  10. Linux and modern macOS command-line tools increasingly follow the XDG Base Directory Specification, version 0.8, published 8 May 2021 by freedesktop.org.
XDG variable Default For
XDG_CONFIG_HOME $HOME/.config settings
XDG_DATA_HOME $HOME/.local/share app data
XDG_STATE_HOME $HOME/.local/state logs, history
XDG_CACHE_HOME $HOME/.cache rebuildable data
XDG_CONFIG_DIRS /etc/xdg system settings
XDG_DATA_DIRS /usr/local/share/ system data
  1. The point of XDG is that a home directory used to fill with dozens of top-level dotfiles. Now well-behaved tools use ~/.config/toolname/.
  2. Adoption is partial. git supports ~/.config/git/config. ssh still insists on ~/.ssh. bash still uses ~/.bashrc. This is a convention with incomplete uptake, not a standard anyone enforces.
  3. System configuration on Linux lives in /etc, a hierarchy defined loosely by the Filesystem Hierarchy Standard, current version 3.0 from 2015. Examples: /etc/passwd, /etc/hosts, /etc/ssh/sshd_config, /etc/fstab, /etc/systemd/system.
  4. Why a macOS or Linux developer never thinks about a registry: everything they touch daily, the shell, the editor, the compiler, git, ssh, docker, reads a text file at a documented path. Nothing they use has a central database, so the concept never comes up.
  5. The honest version: macOS does have a registry-like system, and its problems are the same as Windows’. Stale preference domains accumulate, cfprefsd caching causes lost edits, and there is no reliable uninstall. Developers avoid it only because their tools are UNIX tools, not Mac applications.

WORDS19.10.6 remember these#

  1. Registry — Windows’ one big settings database — a hierarchical binary store of keys and typed values, held in hive files.
  2. Hive — a top-level branch of the registry — a discrete file-backed subtree such as HKLM or a user’s NTUSER.DAT.
  3. HKLM and HKCU — machine settings and my settings — HKEY_LOCAL_MACHINE requires elevation, HKEY_CURRENT_USER does not.
  4. plist — a macOS settings file — an Apple property list, XML or binary, keyed by reverse-domain identifier.
  5. Dotfile — a hidden settings file in your home directory — a file whose name starts with ., omitted by ls unless -a is given.
  6. XDG base directories — the agreed places for config, data and cache — the freedesktop.org specification defining XDG_CONFIG_HOME and friends.
  7. /etc — where system settings live on UNIX — the machine-wide configuration hierarchy described by the Filesystem Hierarchy Standard.

19.11 The essential commands#

PLAIN19.11.1 in simple words#

  1. There are thousands of commands. About forty carry almost all daily work.
  2. They fall into groups, and learning them by group is far easier than learning them alphabetically.
  3. Navigation: where am I, what is here, move somewhere else.
  4. Files: create, copy, move, delete.
  5. Viewing: show me the contents, the top, the bottom, one page at a time.
  6. Searching: find files by name, find text inside files.
  7. Text processing: cut columns, replace text, sort, count.
  8. Permissions: who is allowed to do what.
  9. Processes: what is running, stop it.
  10. Disk: how much space, what is using it.
  11. Network: is it reachable, fetch it, log in to it.
  12. Archives: bundle a folder into one file, unpack it again.
  13. Help: what does this command do, where does it live, what did I type before.

PLAIN19.11.2 a picture in your head#

  1. Think of a workshop with a pegboard of hand tools.
  2. You do not learn a pegboard by reading every label. You learn it by doing three or four jobs.
  3. The saw, the hammer and the tape measure get used in every job. Those are ls, cd and cat.
  4. Some tools look alike but are for different materials. grep searches inside files; find searches for files. Beginners reach for the wrong one constantly.
  5. Some tools are power tools with a manual. sed and awk are those. You will use ten percent of them forever, and that is fine.
  6. Every tool has flags, which are like the settings on a drill. Two or three settings per tool are worth memorizing. The rest you look up.

Where this comparison breaks: hand tools are shaped so you cannot use them wrongly. Command flags are one letter long and unforgiving. rm -rf / and rm -rf ./ differ by one character and by everything else.

PLAIN19.11.3 a worked example#

  1. Navigation and files, run for real in this sandbox:
$ pwd
/tmp/shdemo
$ ls -l
-rw-r--r-- 1 root root 610 Aug 13 01:55 access.log
prw-r--r-- 1 root root   0 Aug 13 01:55 f
-rw-r--r-- 1 root root   0 Aug 13 01:56 note1.txt
  1. Read a long listing left to right. The first character is the type: - for a regular file, d for a directory, l for a symbolic link, p for a named pipe, which is what that f is.
  2. The next nine characters are permissions in three groups of three: owner, group, everyone else. r read, w write, x execute.
  3. Then the link count, the owner, the group, the size in bytes, the modification time and the name.
  4. Permissions changing for real:
$ echo 'echo hi' > s.sh
$ ls -l s.sh
-rw-r--r-- 1 root root 8 Aug 13 01:56 s.sh
$ chmod +x s.sh
$ ls -l s.sh
-rwxr-xr-x 1 root root 8 Aug 13 01:56 s.sh
$ chmod 640 s.sh
$ stat -c '%a %A %n' s.sh
640 -rw-r----- s.sh
  1. The number form is three octal digits. Read 4 for read, 2 for write, 1 for execute, added together. So 6 is read plus write, 4 is read only, 0 is nothing. 640 means owner read and write, group read, others nothing.
  2. Archives for real:
$ tar -czf proj.tar.gz proj
$ tar -tzf proj.tar.gz
proj/
proj/src/
proj/src/main.py
proj/README.md
$ tar -xzf proj.tar.gz -C out
  1. Remember the letters: c create, t list, x extract, z gzip, f the filename follows, v be chatty. Always put f last, because the filename comes straight after it.
  2. Networking for real, using the reader’s own recorded session on their home connection in India:
$ dig +short github.com
20.207.73.82
$ curl -v https://github.com
* Trying 20.207.73.82:443...
  ... times out after 15 seconds, no response at all
  1. That address is in a Microsoft-owned range, because GitHub is owned by Microsoft, acquired 2018. The name resolved fine. The connection did not complete. Those are two different failures and the commands separate them.
  2. A trace from the same session showed the path leaving the home router at 192.168.0.1, crossing private ISP addresses, reaching Microsoft’s network at hop 7, and then falling silent.

PLAIN19.11.4 what is really happening inside#

  1. Almost every one of these commands is a small program in /usr/bin or /bin. The shell finds it in PATH and runs it.
  2. Most read from standard input when given no filename, which is why they all work in pipes without any special support.
  3. Most write results to standard output and complaints to standard error, so redirection works uniformly.
  4. Most return 0 on success and non-zero on failure, so && chains work.
  5. These four properties are the entire reason the toolkit composes. Nothing else is shared between them.
  6. cd is the exception. It is a shell builtin, because changing directory must affect the shell itself.
  7. ls sorts alphabetically because it chooses to, not because directories are sorted. On disk, directory entries are in whatever order the filesystem likes.
  8. rm does not erase data. It removes a name. If another name points at the same data, the data survives. Only when the last name goes and no process has the file open is the space freed.
  9. mv within one filesystem does not move data either. It just changes which directory holds the name. Across filesystems it must copy and then delete, which is why it is slow there.
  10. kill does not necessarily kill. It sends a signal. The default is SIGTERM, which is a polite request the program may handle or ignore. kill -9 sends SIGKILL, which the program cannot refuse.

TECHNICAL19.11.5 the engineer’s version#

  1. Navigation and inspection.
Command Plain job Flags worth knowing
pwd print current path -P resolve symlinks
cd change directory - go back, no flag
ls list directory -l -a -h -t -r -S
tree show nested structure -L depth, -a
  1. Real note: ls -lt sorts newest first, ls -ltr reverses so newest is at the bottom, which is what you want on a long log directory.

  2. Files.

Command Plain job Flags worth knowing
mkdir make a directory -p make parents
touch create or update time -t set a time
cp copy -r -a -i -v
mv move or rename -i -n -v
rm delete -r -f -i
ln make another name -s symbolic link
  1. cp -a means archive: recursive, preserve permissions, times and links. rm -i asks before each delete and is worth aliasing on shared machines.

  2. There is no undelete. rm is final. The only protection is backups and care.

  3. Viewing.

Command Plain job Flags worth knowing
cat print whole file -n number lines
less page through a file -N -S, / to search
head first lines -n N, -c bytes
tail last lines -n N, -f follow
wc count lines and words -l -w -c
  1. tail -f logfile is the standard way to watch a log grow. tail -F also survives the file being rotated and recreated.

  2. Inside less: /text searches forward, n next match, G end, g start, q quit. It is also the default pager for man.

  3. Searching.

Command Plain job Flags worth knowing
grep find text in files -i -n -r -v -c -E
find find files by attribute -name -type -mtime
which show which file will run -a show all matches
locate fast name search needs an index
  1. Real grep and find runs from this sandbox:
$ grep -c "500" access.log
3
$ grep -n "404" access.log
3:10.0.0.7 ... "GET /missing HTTP/1.1" 404 152
$ grep -v "200" access.log | wc -l
4
$ find /home/claude/book/chapters -name 'ch1*.md' -size +100k \
    -printf '%s %p\n' | sort -rn | head -3
131233 /home/claude/book/chapters/ch14.md
124198 /home/claude/book/chapters/ch17.md
123297 /home/claude/book/chapters/ch16.md
  1. find syntax is unusual: path first, then tests, then actions. -exec cmd {} \; runs once per file; -exec cmd {} + batches them and is much faster. -print0 with xargs -0 handles filenames with spaces.

  2. Text processing.

Command Plain job Flags worth knowing
cut take columns -d delim, -f fields
sort order lines -n -r -u -k -t
uniq collapse adjacent dups -c -d -u
tr swap or delete chars -d delete, -s squash
sed edit a stream -n, s///, -i
awk field-aware processing -F, patterns, END
  1. Real runs:
$ cut -d: -f1,7 /etc/passwd | head -3
root:/bin/bash
daemon:/usr/sbin/nologin
bin:/usr/sbin/nologin
$ printf '10\n9\n100\n2\n' | sort      -> 10 100 2 9
$ printf '10\n9\n100\n2\n' | sort -n   -> 2 9 10 100
$ echo "Hello World" | tr 'a-z' 'A-Z'
HELLO WORLD
$ sed 's/GET/FETCH/' access.log | head -1
10.0.0.7 ... "FETCH /index.html HTTP/1.1" 200 5120
$ awk '{bytes += $10} END {print "total:", bytes, "lines:", NR}' \
    access.log
total: 16594 lines: 8
$ awk '$9 == 500 {print $1}' access.log
10.0.0.12
10.0.0.7
10.0.0.12
  1. Note sort without -n puts 100 before 2, because it is comparing text. That single default has produced more wrong reports than any other flag in UNIX.

  2. sed -i edits in place. GNU sed takes -i alone; BSD and macOS sed require an argument, so sed -i '' 's/a/b/' f on macOS and sed -i 's/a/b/' f on Linux. This difference breaks scripts crossing the two systems constantly.

  3. Permissions and ownership.

Command Plain job Flags worth knowing
chmod change permissions -R, +x, octal 644
chown change owner and group -R, user:group
umask default for new files 022 typical
id who am I, which groups -u -g -G
sudo run as another user -u, -i, -l
  1. Real: umask printed 0022 here, meaning new files get 644 and new directories 755, because the mask removes write from group and others.

  2. chown needs root for changing the owner. Changing only the group is allowed if you belong to the target group.

  3. Processes.

Command Plain job Flags worth knowing
ps snapshot of processes aux, -ef, -o fmt
top live process view -b -n batch mode
kill send a signal -TERM -KILL -HUP
pkill signal by name -f match full line
jobs this shell’s jobs %1 refers to job 1
nohup survive terminal close with &
  1. Real, from this sandbox:
$ ps aux --sort=-%mem | head -3
USER  PID  %CPU %MEM     VSZ    RSS STAT  TIME COMMAND
root  462   3.8  9.0 6045508 746736 Rsl   3:46 claude
root  473   0.3  0.4 1800580  33860 Sl    0:21 environment-manager
$ ps -o pid,ppid,stat,etime,comm -p 1
  PID  PPID STAT     ELAPSED COMMAND
    1     0 SLl     01:38:18 process_api
  1. ps aux is BSD-style, ps -ef is System V style. Both work on Linux and macOS. RSS is resident memory in kilobytes, the number that actually matters. VSZ is address space reserved and is usually meaningless.

  2. Escalation order for stopping something: kill PID first, wait a few seconds, then kill -9 PID. Going straight to -9 skips the program’s chance to flush data and clean up.

  3. Disk.

Command Plain job Flags worth knowing
df free space per mount -h -i inodes
du space used by a tree -sh, -d1, -h
ncdu interactive disk usage not always installed
  1. Real:
$ df -h | head -4
Filesystem      Size  Used Avail Use% Mounted on
/dev/vda        252G   12G   30G  29% /
/dev/vdc        327M  295M   26M  93% /opt/claude-code
$ du -sh /home/claude/book/chapters
1.6M    /home/claude/book/chapters
  1. If df shows space free but writes still fail, check df -i. You may have run out of inodes, which are the records that name files, not the bytes.

  2. Network.

Command Plain job Flags worth knowing
curl fetch a URL -I -L -o -s -v
wget download a file -c continue, -O
ssh log in to another host -p port, -i key, -v
scp copy files over ssh -r, -P port
ping is the host answering -c count
dig ask DNS a question +short, @server
traceroute show the path there -m max hops
netstat sockets and listeners -tlnp
ss modern netstat -tlnp
lsof what has this file open -i, -p PID
  1. Real runs from this sandbox:
$ dig +short github.com
140.82.112.4
$ dig @1.1.1.1 +short pypi.org
151.101.64.223
151.101.192.223
$ curl -s -o /dev/null \
   -w 'code=%{http_code} time=%{time_total}s ip=%{remote_ip}\n' \
   pypi.org
code=301 time=0.039756s ip=151.101.192.223
$ netstat -tlnp | head -3
Proto Local Address       State   PID/Program name
tcp   127.0.0.1:43279     LISTEN  462/claude
tcp   0.0.0.0:2024        LISTEN  -
$ ssh -V
OpenSSH_9.6p1 Ubuntu-3ubuntu13.18, OpenSSL 3.0.13 30 Jan 2024
  1. netstat is deprecated on Linux in favour of ss from the iproute2 package, but it is still present on macOS and on many servers. Learn both spellings of the same idea.

  2. ping needs ICMP to be allowed. Many cloud hosts drop ICMP by policy, so a failed ping proves nothing on its own. Prove reachability with a TCP connection instead: curl -v or nc -vz host port.

  3. Archives and transfer.

Command Plain job Flags worth knowing
tar bundle a directory -czf -xzf -tzf
gzip compress one file -d decompress, -9
zip Windows-friendly bundle -r recursive
unzip unpack a zip -l list, -d dir
rsync sync trees efficiently -av –delete -n
  1. tar bundles then compresses the whole bundle, so it compresses better but cannot extract one file without reading the stream. zip compresses each file separately, so it is worse at compression but supports random access. Real measurement here: the same tiny tree gave 213 bytes as .tar.gz and 640 bytes as .zip.

  2. Help and history.

Command Plain job Flags worth knowing
man read the manual -k search, section
which path of the command -a all matches
type what kind of thing is it -a all definitions
history what did I type before Ctrl-R to search
apropos search manual summaries same as man -k
  1. Real history behaviour, captured in a pseudo-terminal here:
demo$ history
    1  echo alpha
    2  ls note1.txt
    3  pwd
    4  history
demo$ !2
ls note1.txt
note1.txt
  1. !! repeats the previous command. sudo !! is the standard recovery after forgetting sudo. !$ is the last argument of the previous line.

WORDS19.11.6 remember these#

  1. Flag — a short option that changes a command’s behaviour — a single-letter or long option parsed by getopt.
  2. Recursive — apply to everything underneath — the -r or -R option, descending the directory tree.
  3. Inode — the record that describes a file — the filesystem structure holding permissions, times and block pointers, not the name.
  4. Signal — a message sent to a running process — an asynchronous notification such as SIGTERM 15 or SIGKILL 9.
  5. Pager — a program that shows text one screen at a time — less or more, the target of the PAGER variable.
  6. Symbolic link — a file that points at another path — created with ln -s, shown as type l by ls -l.

19.12 Globbing, quoting and expansion#

PLAIN19.12.1 in simple words#

  1. The shell rewrites your line before any program sees it.
  2. By the time ls *.txt reaches ls, the star is gone. ls receives a list of real filenames.
  3. This surprises people. ls has no wildcard support at all. It never needed any.
  4. The rewriting has several kinds, and they happen in a fixed order.
  5. ~ becomes your home directory.
  6. {a,b} becomes two separate words.
  7. $NAME becomes the value of a variable.
  8. $(command) becomes whatever that command printed.
  9. * and ? become matching filenames.
  10. Quotes switch parts of this off. That is their only job.

PLAIN19.12.2 a picture in your head#

  1. Think of a mail room that opens every letter before delivery.
  2. Wherever the letter says “the boss”, the clerk writes in the boss’s actual name. Wherever it says “all the branches”, the clerk writes out every branch address in full.
  3. The recipient never sees the shorthand. They see a fully spelled-out letter.
  4. Quotation marks are an instruction to the clerk: leave this part exactly as written.
  5. Single quotes mean “change nothing at all inside here”.
  6. Double quotes mean “you may still fill in names, but do not split the text into pieces or expand wildcards”.

Where this comparison breaks: the clerk works in a strict order and cannot go back. Once a variable has been replaced by text containing a space, the shell may split that text into two words, and there is no way to un-split it afterwards. That one-way order is the cause of nearly every quoting bug.

PLAIN19.12.3 a worked example#

  1. All real output from this sandbox. The directory holds note1.txt, note2.txt, notes.md, report.csv and my file.txt.
$ echo *.txt
my file.txt note1.txt note2.txt
$ echo note?.txt
note1.txt note2.txt
$ echo note[12].txt
note1.txt note2.txt
$ echo nomatch*.zzz
nomatch*.zzz
$ echo file{1..4}.log
file1.log file2.log file3.log file4.log
$ echo {a,b,c}.txt
a.txt b.txt c.txt
$ echo ~
/root
$ echo "there are $(ls | wc -l) entries"
there are 7 entries
$ echo $((3 + 4 * 2))
11
  1. Note the fourth one. When a wildcard matches nothing, bash leaves it unchanged. The program then receives a literal star and usually complains. zsh instead reports an error and runs nothing, which is arguably safer.
  2. Note that brace expansion produced filenames that do not exist. Braces are pure text generation; they never look at the disk.
  3. Quoting, for real:
$ NAME=world
$ echo "double: $NAME"
double: world
$ echo 'single: $NAME'
single: $NAME
$ echo backslash: \$NAME
backslash: $NAME
  1. Now the spaces problem, which is the one that destroys data:
$ f="my file.txt"
$ printf "[%s]\n" $f
[my]
[file.txt]
$ printf "[%s]\n" "$f"
[my file.txt]
$ rm $f
rm: cannot remove 'my': No such file or directory
rm: cannot remove 'file.txt': No such file or directory
  1. Unquoted, one filename became two arguments. With rm in a directory that happened to contain a file called my, that command would have deleted the wrong file silently.
  2. The rule is simple and absolute: put double quotes around every variable expansion unless you have a specific reason not to.

PLAIN19.12.4 what is really happening inside#

  1. You can watch the rewriting happen. set -x makes bash print each line after expansion. Real output:
$ bash -xc 'x=5; ls note*.txt
>            echo "x is $x, dir $(basename /tmp/shdemo)"'
+ x=5
+ ls note1.txt note2.txt
++ basename /tmp/shdemo
+ echo 'x is 5, dir shdemo'
  1. Look at line two. ls note*.txt became ls note1.txt note2.txt before running. The wildcard is gone.
  2. Look at the double plus. That is a nested expansion: the command substitution ran first, at one level deeper.
  3. The order the shell uses is fixed and worth memorizing:
  4. Brace expansion first. Purely textual, no filesystem involved.
  5. Then tilde expansion.
  6. Then, all at the same time and left to right: parameter expansion ($VAR), command substitution ($(...)) and arithmetic expansion ($((...))).
  7. Then word splitting, which cuts the results on spaces, tabs and newlines.
  8. Then filename expansion, the wildcards.
  9. Finally quote removal, which strips the quote characters themselves so the program never sees them.
  10. Two consequences follow directly. Word splitting happens after variable expansion, which is why unquoted variables split.
  11. And filename expansion happens after word splitting, which is why a variable holding a star can still expand to filenames unless quoted.

TECHNICAL19.12.5 the engineer’s version#

Form Name Consults disk
{a,b} brace expansion no
~user tilde expansion passwd file
$VAR parameter expansion no
$(cmd) command substitution runs a program
$((x+1)) arithmetic expansion no
* ? [] pathname expansion yes
<(cmd) process substitution creates a fifo
  1. The controlling variable for word splitting is IFS, the internal field separator. Its default is space, tab and newline. Setting IFS=$'\n' restricts splitting to line boundaries.
  2. Glob characters are * any string including empty, ? any single character, [abc] one of a set, [!abc] or [^abc] none of a set. Character classes such as [[:digit:]] are POSIX.
  3. A leading dot is never matched by * by default. shopt -s dotglob in bash changes that. This is why rm * does not remove .git.
  4. shopt -s nullglob makes a non-matching pattern expand to nothing instead of itself. shopt -s failglob makes it an error, matching zsh’s default.
  5. shopt -s globstar enables ** for recursive matching in bash 4 and later. macOS’s bundled bash 3.2 does not have it; zsh has it always.
  6. Parameter expansion has a large sub-language worth learning: ${VAR:-default} use a default, ${VAR:?message} fail if unset, ${VAR#prefix} and ${VAR%suffix} strip, ${VAR//old/new} replace all, ${#VAR} length, ${VAR:2:3} substring.
  7. Backticks are the old command substitution syntax. $(...) is POSIX, nests cleanly, and is what you should write. Backticks are a legacy convention.
  8. Single quotes protect everything, including backslashes. There is no way to put a single quote inside single quotes; you must close, escape, and reopen: 'it'\''s'.
  9. Double quotes still allow $, backtick and backslash. They suppress word splitting and globbing.
  10. "$@" expands to each argument as a separate quoted word. "$*" joins them into one word. This distinction matters in every wrapper script ever written.
  11. For filenames from find, use -print0 with xargs -0 or while IFS= read -r -d ''. Newlines are legal in UNIX filenames, and any line-based loop over filenames is broken in principle.
  12. shellcheck is a static analyser that catches unquoted expansions and dozens of similar faults. Running it on every script is the cheapest quality improvement available in shell programming.

WORDS19.12.6 remember these#

  1. Glob — a wildcard filename pattern — pathname expansion performed by the shell, not by the command.
  2. Expansion — the shell rewriting your line before running it — the ordered sequence from brace expansion to quote removal.
  3. Word splitting — cutting expanded text into separate arguments — splitting on the characters in IFS, after parameter expansion.
  4. IFS — the characters that separate words — the internal field separator, defaulting to space, tab and newline.
  5. Command substitution — replacing a command with its output — $(cmd), with trailing newlines removed.
  6. Quote removal — the final step that deletes the quote marks — performed after all expansions, so programs never see quotes.

19.13 Shell scripting#

PLAIN19.13.1 in simple words#

  1. A shell script is a file containing the same commands you would type.
  2. Put them in a file, mark the file as runnable, and you can run the whole sequence with one word.
  3. The first line should say which program interprets the file. That line starts with #! and is called the shebang.
  4. #!/usr/bin/env bash is the usual choice, because it finds bash wherever it is installed.
  5. Inside, you get variables, if for decisions, for and while for repetition, and functions for naming a block of steps.
  6. Arguments arrive as $1, $2, and so on. $@ is all of them. $# is how many.
  7. exit N ends the script with the code N, which the caller can test.
  8. One line near the top prevents most disasters: set -euo pipefail.
  9. It means: stop on the first failure, stop if I use a variable I never set, and notice failures inside pipelines.

PLAIN19.13.2 a picture in your head#

  1. Think of a recipe card taped to the kitchen wall.
  2. Typing commands is cooking from memory. A script is writing the recipe down so tomorrow’s version is identical.
  3. Without set -e, the recipe says “if the oven fails to heat, carry on anyway”, which produces a raw cake presented as finished.
  4. With set -e, the cook stops and tells you the oven failed.
  5. Functions are named sub-recipes: “make the sauce” written once, used three times.
  6. Arguments are the parts you leave blank: how many people, which flavour.

Where this comparison breaks: a human cook applies judgement and notices when something looks wrong. A script notices nothing at all. It will pour salt for an hour if you tell it to, and its only safety comes from checks you wrote in advance.

PLAIN19.13.3 a worked example#

  1. Here is the real difference set -euo pipefail makes. Both scripts were run in this sandbox.
#!/usr/bin/env bash
cd /nonexistent-dir
echo "still running, and about to work in $(pwd)"
echo "value is: $UNDEFINED_VAR"
  1. Real output:
./noset.sh: line 2: cd: /nonexistent-dir: No such file or directory
still running, and about to work in /tmp/shdemo
value is:
exit=0
  1. Read that carefully. The cd failed. The script continued in the wrong directory. It used an undefined variable as an empty string. And it reported success.
  2. If line 3 had been rm -rf ./*, it would have deleted the wrong directory and told the caller everything was fine.
  3. Now the same script with the guard:
#!/usr/bin/env bash
set -euo pipefail
cd /nonexistent-dir
echo "this line never runs"
  1. Real output:
./withset.sh: line 3: cd: /nonexistent-dir: No such file
  or directory
exit=1
  1. It stopped at the failure and reported failure. That is the whole argument for the line.

PLAIN19.13.4 what is really happening inside#

  1. The #! line is read by the kernel, not the shell. When you execute a file, the kernel looks at its first two bytes.
  2. If they are #!, the kernel reads the rest of the line, runs that program, and hands it the script’s path as an argument.
  3. So ./script.sh really becomes /usr/bin/env bash ./script.sh.
  4. env then searches PATH for bash and executes it. That indirection is why the env form works on machines where bash is not in /bin.
  5. Without the execute permission bit, the kernel refuses, and you get exit code 126.
  6. set -e sets the errexit shell option. After every simple command the shell checks the status and exits if it is non-zero and untested.
  7. set -u sets nounset. Expanding an unset variable becomes an error instead of an empty string.
  8. set -o pipefail changes a pipeline’s status from “the last command” to “the rightmost command that failed”.
  9. Functions are not separate processes. They run inside the same shell, so they can change its variables and its directory.
  10. A subshell, written ( ... ), is a forked copy. Changes inside it are thrown away when it ends. That is why cd inside ( ) does not move you.

TECHNICAL19.13.5 the engineer’s version#

  1. Here is a complete, working script, run for real in this sandbox. It reads a web server log and reports error counts, with a meaningful exit code.
#!/usr/bin/env bash
set -euo pipefail

usage() {
  echo "usage: $(basename "$0") LOGFILE [MIN_ERRORS]" >&2
  exit 64
}

[ $# -ge 1 ] || usage
logfile=$1
min_errors=${2:-1}

if [ ! -r "$logfile" ]; then
  echo "error: cannot read $logfile" >&2
  exit 66
fi

total=$(wc -l < "$logfile")
echo "file:  $logfile"
echo "lines: $total"

echo "status counts:"
awk '{print $9}' "$logfile" | sort | uniq -c | sort -rn

echo "clients with at least $min_errors error(s):"
found=0
while read -r count ip; do
  if [ "$count" -ge "$min_errors" ]; then
    printf '  %-12s %s\n' "$ip" "$count"
    found=$((found + 1))
  fi
done < <(awk '$9 >= 400 {print $1}' "$logfile" \
          | sort | uniq -c | sort -rn)

if [ "$found" -eq 0 ]; then
  echo "  none"
  exit 0
fi
exit 1
  1. Line by line. #!/usr/bin/env bash selects bash via PATH.
  2. set -euo pipefail turns on the three guards described above.
  3. usage() defines a function. >&2 sends its message to standard error, so it is not captured by a caller collecting output. exit 64 is EX_USAGE.
  4. [ $# -ge 1 ] || usage is an idiom: if there is not at least one argument, run usage. $# is the argument count.
  5. min_errors=${2:-1} uses parameter expansion to default the second argument to 1 when it is absent. Without this, set -u would abort.
  6. [ ! -r "$logfile" ] tests readability. The quotes are essential; a path with a space would otherwise become two arguments and break the test.
  7. wc -l < "$logfile" uses redirection rather than passing the filename, so wc prints only the number without the filename after it.
  8. The while read -r count ip loop reads two fields per line. -r stops backslashes being interpreted, and should always be present.
  9. done < <(...) is process substitution. The pipeline runs in a separate process and its output is fed to the loop’s standard input.
  10. This matters: a plain pipeline | while read puts the loop in a subshell, so found would be lost when the loop ends. Process substitution keeps the loop in the main shell.
  11. $((found + 1)) is arithmetic expansion. No external expr is needed.
  12. The script exits 0 when nothing was found and 1 when errors were found, which lets a monitoring system use it directly in an if.
  13. Real runs:
$ ./logsum.sh access.log 2
file:  access.log
lines: 8
status counts:
      4 200
      3 500
      1 404
clients with at least 2 error(s):
  10.0.0.7     2
  10.0.0.12    2
exit=1

$ ./logsum.sh
usage: logsum.sh LOGFILE [MIN_ERRORS]
exit=64

$ ./logsum.sh /nope.log
error: cannot read /nope.log
exit=66
  1. Portability notes. [[ ]] is a bash and zsh feature with better quoting behaviour; [ ] is POSIX and is really the program test. Process substitution and arrays are bash and zsh only, not sh.
  2. trap 'rm -f "$tmp"' EXIT is the standard way to clean up on any exit path, including errors. Create temporary files with mktemp, never with a fixed name in /tmp.
  3. Where experts disagree: some hold that anything over roughly 100 lines should be rewritten in Python. Others keep shell scripts of a thousand lines running happily. The practical dividing line most teams use is data structures: the moment you want a list of records with fields, leave shell.

WORDS19.13.6 remember these#

  1. Shebang — the first line naming the interpreter — the #! magic number read by the kernel’s execve.
  2. set -e — stop at the first failure — the errexit option, with documented exceptions inside conditions.
  3. set -u — treat unset variables as errors — the nounset option.
  4. pipefail — a pipeline fails if any stage fails — a bash and zsh option, not in POSIX.
  5. Subshell — a forked copy of the shell — created by ( ), pipelines and command substitution; its variable changes do not escape.
  6. Process substitution — treat a command’s output as a file — <(cmd), implemented with a FIFO or /dev/fd, bash and zsh only.
  7. trap — run cleanup code when the script ends — a handler registered for a signal or the pseudo-signal EXIT.

19.14 Getting comfortable#

PLAIN19.14.1 in simple words#

  1. Speed at the command line comes from about a dozen habits, not from typing faster.
  2. Tab completion: type the first few letters and press Tab. The shell finishes the name.
  3. History search: press Ctrl-R and type part of an old command. It appears. Press Enter to run it.
  4. Line editing: Ctrl-A jumps to the start of the line, Ctrl-E to the end, Ctrl-U deletes to the start, Ctrl-W deletes the last word.
  5. Aliases: give a long command a short name you choose.
  6. Sessions that survive: tmux keeps your work running when your connection drops.
  7. Reading manuals properly: every manual page has the same sections, and you only ever need three of them.

PLAIN19.14.2 a picture in your head#

  1. Think of learning a musical instrument.
  2. Nobody plays fast by moving their fingers faster. They play fast because common patterns became automatic.
  3. Tab completion is like a scale you no longer think about.
  4. Ctrl-R is like remembering a phrase you played last week instead of re-inventing it.
  5. tmux is like leaving your instrument set up on its stand, tuned, so tomorrow you sit down and continue mid-piece.

Where this comparison breaks: an instrument gives immediate feedback when you get it wrong. A shell often gives no feedback at all, so bad habits survive for years. The only cure is deliberately learning the correct form once.

PLAIN19.14.3 a worked example#

  1. Keyboard shortcuts worth memorizing. These come from the readline library, used by bash, and zsh has the same set in emacs mode.
Keys What it does Keys What it does
Ctrl-A start of line Ctrl-E end of line
Ctrl-U delete to line start Ctrl-K delete to end
Ctrl-W delete previous word Ctrl-Y paste last delete
Ctrl-R search history back Ctrl-G cancel the search
Ctrl-L clear the screen Ctrl-C cancel this line
Ctrl-D end of input, log out Ctrl-Z suspend the job
Alt-B back one word Alt-F forward one word
Alt-. last argument of prev Ctrl-_ undo the edit
  1. On macOS, Alt is the Option key, and Terminal.app needs “Use Option as Meta key” enabled in its settings before Alt-B and Alt-F work.
  2. Aliases, real output from this sandbox:
$ alias ll="ls -lah"
$ alias
alias l='ls -CF'
alias la='ls -A'
alias ll='ls -lah'
alias ls='ls --color=auto'
$ type ll
ll is aliased to `ls -lah'
  1. Aliases go in ~/.zshrc or ~/.bashrc so they exist in every new shell.
  2. An alias is text substitution on the first word only. For anything needing arguments in the middle, write a function instead.

PLAIN19.14.4 what is really happening inside#

  1. Tab completion is a shell feature, not a terminal feature. The terminal just sends byte 9.
  2. The shell’s line editor intercepts it, looks at what you have typed, and works out the candidates.
  3. bash uses the GNU readline library for this. zsh has its own editor, ZLE.
  4. Modern completion is programmable. When you type git che and press Tab, the shell runs a completion function shipped with git, which knows about branches.
  5. History is kept in memory during the session and written to a file when the shell exits. bash uses ~/.bash_history; zsh uses ~/.zsh_history.
  6. That end-of-session write is why history from one window can be missing in another. setopt INC_APPEND_HISTORY and setopt SHARE_HISTORY in zsh, or shopt -s histappend with PROMPT_COMMAND in bash, fix it.
  7. tmux works by putting your shell inside a pseudo-terminal that tmux itself owns, rather than the one your emulator made.
  8. When your SSH connection dies, your emulator’s pty is destroyed, but tmux’s is not, because the tmux server process is still running on the far machine.
  9. Reconnect, run tmux attach, and tmux re-draws its saved screen contents into your new terminal.
  10. Without tmux, losing the connection sends SIGHUP to your shell, which normally kills everything it started.

TECHNICAL19.14.5 the engineer’s version#

  1. tmux, real version and behaviour from this sandbox:
$ tmux -V
tmux 3.4
$ tmux new-session -d -s demo 'sleep 60'
$ tmux ls
demo: 1 windows (created Thu Aug 13 02:00:38 2026)
$ tmux kill-session -t demo
  1. The commands worth knowing: tmux new -s name, tmux ls, tmux attach -t name, tmux kill-session -t name. Inside, the prefix key is Ctrl-B by default: Ctrl-B d detach, Ctrl-B c new window, Ctrl-B " split horizontally, Ctrl-B % split vertically, Ctrl-B [ scrollback mode.
  2. GNU Screen is the older equivalent, first released 1987, prefix Ctrl-A. tmux, first released 2007 by Nicholas Marriott, is the more actively developed choice today.
  3. nohup cmd & and disown are lighter alternatives: they detach a single job from the terminal’s hangup signal but give you no way to reattach and see it.
  4. Manual page structure is standardized. The sections, in the order they always appear, and confirmed against the real grep page in this sandbox:
GREP(1)                 User Commands              GREP(1)
NAME
SYNOPSIS
DESCRIPTION
OPTIONS
REGULAR EXPRESSIONS
EXIT STATUS
ENVIRONMENT
NOTES
COPYRIGHT
BUGS
EXAMPLE
SEE ALSO
  1. How to read one properly: read NAME to confirm it is the right tool, read SYNOPSIS to learn the argument shape, then jump straight to EXAMPLES at the bottom. Read DESCRIPTION only when the examples are not enough.
  2. Inside less, which pages the manual: /word searches, n repeats, q quits, G goes to the end where the examples are.
  3. Manual sections are numbered. 1 user commands, 2 system calls, 3 library functions, 4 devices, 5 file formats, 7 miscellaneous and overviews, 8 administration commands.
  4. That numbering matters. man 1 printf is the command; man 3 printf is the C function. man 5 passwd is the file format; man 1 passwd is the tool.
  5. man -k word or apropos word searches all the NAME lines when you do not know the command’s name.
  6. GNU tools often have fuller documentation in info rather than man. curl --help all and git help -a are other real sources.
  7. tldr, a community project, gives example-first summaries and is worth installing when a manual page is 900 lines long.

WORDS19.14.6 remember these#

  1. readline — the library giving bash its line editing — GNU readline, configured through ~/.inputrc.
  2. Tab completion — the shell finishing a word for you — programmable completion driven by shell functions.
  3. Reverse search — finding an old command by fragment — Ctrl-R, an incremental backward search through the history list.
  4. Alias — a short name for a longer command — first-word text substitution performed before expansion.
  5. tmux — keeps sessions alive across disconnection — a terminal multiplexer holding its own ptys in a persistent server process.
  6. SIGHUP — the “your terminal has gone” signal — signal 1, sent to the foreground group when the controlling terminal is lost.

19.15 Package managers#

PLAIN19.15.1 in simple words#

  1. A package manager installs software for you and keeps track of what it installed.
  2. It knows which files belong to which program, so it can remove them cleanly.
  3. It knows which programs depend on which other programs, and installs those too.
  4. It gets the software from a repository: a server holding a catalogue and the files.
  5. It checks a cryptographic signature, so you can tell the files really came from the people who claim to have made them.
  6. Linux distributions each have one. Debian and Ubuntu use apt. Fedora and Red Hat use dnf. Arch uses pacman.
  7. macOS has no official one for developer tools, so the community built Homebrew.
  8. Windows now has winget, built by Microsoft.
  9. The pattern is always: update the catalogue, then install by name.

PLAIN19.15.2 a picture in your head#

  1. Think of a library with a strict lending system.
  2. You ask for one book. The librarian knows that book refers constantly to two others, so brings all three.
  3. Every book has a stamp proving it came from a real publisher, not a forgery somebody left on a shelf.
  4. When you return the first book, the librarian checks whether anyone else is using the other two before shelving them.
  5. Installing software by downloading it yourself is walking into the building and taking a book without telling anyone. Nothing tracks it and nothing removes it later.

Where this comparison breaks: books do not conflict. Software versions do. Two programs may need incompatible versions of the same library, and no librarian can satisfy both from one shelf. That problem is called dependency hell, and it is the reason containers and per-language package managers exist.

PLAIN19.15.3 a worked example#

  1. Real apt output from this sandbox, showing what a package manager actually knows:
$ apt-cache policy curl
curl:
  Installed: 8.5.0-2ubuntu10.9
  Candidate: 8.5.0-2ubuntu10.11
  Version table:
     8.5.0-2ubuntu10.11 500
        500 archive.ubuntu.com/ubuntu noble-updates/main
 *** 8.5.0-2ubuntu10.9 100

$ apt-cache depends curl
curl
  Depends: libc6
  Depends: libcurl4t64
  Depends: zlib1g

$ dpkg -L curl | head -4
/usr
/usr/bin
/usr/bin/curl
/usr/share
  1. Three separate facts there: which version is installed, which is available, what it needs, and exactly which files it owns.
  2. The repositories it trusts are listed in /etc/apt/sources.list.d/, and the signing keys in /etc/apt/trusted.gpg.d/. In this sandbox those held ubuntu.sources, docker.list, and the Ubuntu archive keyring files.
  3. Equivalent commands across systems:
Task apt dnf / pacman
refresh apt update dnf check-update
install apt install curl dnf install curl
remove apt remove curl dnf remove curl
search apt search curl dnf search curl
upgrade apt upgrade dnf upgrade
  1. On Arch the same five are pacman -Sy, pacman -S curl, pacman -R curl, pacman -Ss curl, pacman -Syu.
  2. On macOS: brew update, brew install curl, brew uninstall curl, brew search curl, brew upgrade.
  3. On Windows: winget search curl, winget install curl, winget upgrade --all.

PLAIN19.15.4 what is really happening inside#

  1. apt update downloads an index file listing every package, its version, its dependencies and a hash of its contents.
  2. That index is signed. The manager verifies the signature against keys you already trust, and refuses to proceed if it fails.
  3. apt install X reads the index and solves a puzzle: pick versions of X and everything it needs so that all constraints hold at once.
  4. This is genuinely a hard computational problem. Real solvers use SAT solving techniques, and they can fail with a message about held broken packages.
  5. It then downloads each chosen package, verifies each hash, and unpacks the files to their recorded locations.
  6. It runs the package’s own install scripts, and writes down which files were placed, so removal is exact.
  7. Homebrew works differently. It downloads pre-built binaries, called bottles, into its own directory tree and symlinks them into place, so it never touches Apple’s system files.
  8. Now the warning. You will see installation instructions of this shape:
curl -fsSL some-site.example/install.sh | sh
  1. That downloads a script and runs it immediately, with your permissions, with no chance to read it.
  2. There is no signature check, no record of what it installed, and no way to uninstall it cleanly.
  3. Worse, a hostile server can send different content to curl than it sends to a browser, so reading the page first proves nothing.
  4. The safer form is two steps: download to a file, read it, then run it. That is not paranoia, it is the minimum you would apply to any other code you were about to give full access to your account.

TECHNICAL19.15.5 the engineer’s version#

Manager Year System Format
dpkg 1994 Debian .deb
RPM 1995 Red Hat 2.0 .rpm
APT 1998 Debian wraps dpkg
pacman 2002 Arch Linux .pkg.tar
YUM 2002 RPM systems wraps rpm
Homebrew 2009 macOS formulae
DNF 2015 Fedora 22 wraps rpm
winget 2020 Windows manifests
  1. Precise history. Ian Murdock created dpkg for Debian in January 1994. Red Hat Linux 2.0 shipped RPM on 20 September 1995. APT 0.0.1 was released by Scott K. Ellis in 1998; APT 1.0 came on 1 April 2014.
  2. YUM was created by Seth Vidal and Michael Stenner at Duke University on 7 June 2002. DNF replaced it as Fedora’s default in May 2015 with Fedora 22.
  3. Judd Vinet created pacman alongside the launch of Arch Linux in March 2002.
  4. Max Howell created Homebrew on 21 May 2009. Version 1.0 arrived on 21 September 2016.
  5. Microsoft released winget in preview at Build on 19 May 2020, and version 1.0 on 27 May 2021. Chocolatey, the community predecessor, released 0.6.0 on 23 March 2011.
  6. Two distinct layers exist and confusing them causes real problems. System package managers own /usr and system libraries. Language package managers, pip, npm, cargo, gem, own their own trees.
  7. Installing a Python library with the system manager and another with pip into the same interpreter is a known way to break a machine. Use virtual environments, or pipx, or containers.
  8. Signature mechanics. Debian signs the Release file with OpenPGP; the file contains hashes of the index files, which contain hashes of the packages. So one signature check covers everything by chaining hashes.
  9. RPM signs individual packages as well. Both models are documented, both are sound, and both fail if you add an untrusted key without thinking.
  10. Homebrew does not sign bottles with OpenPGP. It relies on HTTPS transport and on checksums recorded in the formula in a public git repository. That is weaker than Debian’s model, and it is a fair criticism.
  11. Reproducibility is active work rather than settled fact. The Reproducible Builds project, running since 2013, aims to make a package’s binary byte-for-byte derivable from its source. Debian reports high but not complete coverage. Treat any claim of full reproducibility as a target, not a delivered guarantee.
  12. Supply chain attacks are the reason all of this matters. Real incidents include the event-stream npm package compromise in 2018 and the xz-utils backdoor discovered in March 2024, which reached Debian and Fedora testing branches before being caught.

WORDS19.15.6 remember these#

  1. Package — one installable unit of software — an archive plus metadata describing version, dependencies and file list.
  2. Repository — the server holding the catalogue — a signed index plus the package files it describes.
  3. Dependency resolution — working out what else must be installed — constraint solving over the version graph, often SAT-based.
  4. Signature — proof the files came from who they claim — an OpenPGP signature over the index or the package.
  5. Bottle — a pre-built Homebrew package — a relocatable binary archive built by Homebrew’s own infrastructure.
  6. Supply chain attack — hostile code inserted upstream — compromise of a package, its maintainer or its build system, rather than of your machine.

19.98 Common wrong ideas#

  1. Wrong: the terminal and the shell are the same program. Right: the terminal emulator draws the window; the shell runs inside it and can be swapped for another without changing the window.
  2. Wrong: ls understands the * wildcard. Right: the shell expands * into a list of filenames first; ls only ever receives real names.
  3. Wrong: Ctrl-C sends the letter C to the program. Right: the kernel’s line discipline sees byte 3 and sends SIGINT to the foreground process group; the byte itself is never delivered.
  4. Wrong: 2>&1 > file sends both streams to the file. Right: redirections are applied left to right, so stderr is aimed at the screen first and stays there. Write > file 2>&1.
  5. Wrong: a pipeline runs the first command fully, then the second. Right: all stages run at the same time, connected by a kernel buffer, with the writer blocking when it is full.
  6. Wrong: exit code 1 always means something crashed. Right: many tools use 1 for a normal negative answer. grep returns 1 when it searched correctly and found nothing.
  7. Wrong: a child process can change its parent’s environment variables. Right: the child gets a copy at exec time. Nothing it does can reach back. That is why cd must be a shell builtin.
  8. Wrong: editing ~/.zshrc changes shells that are already open. Right: it is read at startup. Existing shells keep the old values until you source the file or open a new window.
  9. Wrong: kill -9 is the normal way to stop a program. Right: kill sends SIGTERM, which lets the program flush data and clean up. SIGKILL cannot be handled and should be the second attempt, not the first.
  10. Wrong: piping a downloaded script into a shell is fine because you trust the website. Right: there is no signature, no record and no clean removal, and a server can serve different bytes to curl than to a browser.

19.99 Chapter summary in 20 lines#

  1. A terminal was a physical machine at the end of a wire, with a keyboard and a printer or screen, and almost no intelligence of its own.
  2. The Teletype Model 33 of 1963 ran at 110 bits per second, printed on paper, and is why UNIX commands have two-letter names.
  3. The DEC VT100 of 1978 popularized the escape sequences, standardized as ECMA-48 and ISO/IEC 6429, that still control your terminal today.
  4. The device file is called tty because the machine on the other end was a teletype.
  5. Terminal, terminal emulator, shell, console and command line are five different things; you use an emulator containing a shell.
  6. A pseudo-terminal is a kernel-made fake wire with a master end for the emulator and a slave end for the shell.
  7. The line discipline sits between them, doing echo, line editing and buffering, and turning Ctrl-C into SIGINT for the foreground process group.
  8. A shell reads a line, expands it, forks, execs and waits. That loop is the whole job.
  9. The shell family runs from Thompson 1971 and Bourne 1979 through bash 1989 to zsh 1990, which macOS made the default in Catalina in 2019.
  10. PowerShell, from 2006, is a different design entirely: it passes typed objects between commands instead of text.
  11. Every process starts with descriptors 0, 1 and 2 for input, output and errors, kept separate so results stay clean.
  12. Redirection is open plus dup2 performed between fork and exec, applied strictly left to right, which is why 2>&1 order matters.
  13. A pipe is a kernel buffer, 65536 bytes on Linux, joining two processes that run at the same time, with blocking as natural back pressure.
  14. When a reader exits early the writer gets SIGPIPE, signal 13, reported by the shell as exit status 141.
  15. Exit codes are 0 for success and 1 to 255 for failure, with 126 not executable, 127 not found, and 128 plus N for death by signal N.
  16. && runs the next command only on success, || only on failure, and ; always. Build systems test only these numbers.
  17. The environment is a copied list of strings; PATH is searched left to right, the result is cached, and ./ is required for the current directory.
  18. Windows keeps settings in one registry of hives, keys and typed values; macOS and Linux use plists, /etc and dotfiles, increasingly under the XDG directories.
  19. The shell rewrites your line in a fixed order, and word splitting happens after variable expansion, which is why you quote every expansion.
  20. set -euo pipefail at the top of a script turns silent wrong behaviour into a loud stop, which is the single highest-value line you can write.