// lesson: the-terminal-is-a-file

The Terminal Is a File

Run a program in a terminal and three file descriptors are already open: 0 (stdin), 1 (stdout), 2 (stderr). Usually all three point at the same kernel object โ€” a tty device. isatty(0) asks whether fd 0 is one:

#include <unistd.h>

if (!isatty(STDIN_FILENO)) {
    // stdin is a pipe or a file โ€” an interactive editor can't run here.
}

The name is a fossil: tty = teletype, the electromechanical printing terminals of the 1960s. The terminal "screen" your editor will draw on is, as far as the kernel is concerned, a serial device you write(2) bytes to and read(2) bytes from. There is no "move the cursor" system call, no "key event" structure. Bytes in, bytes out. Everything a full-screen program does โ€” vim, htop, less โ€” is built on exactly two tricks:

  • Output: certain byte sequences, when written to the terminal, are interpreted as commands (move cursor, clear line, change color) instead of text. Lesson 3 is about those.
  • Input: keys arrive as bytes, and you reconfigure the tty so they arrive immediately and unmodified. That is this lesson.

Canonical mode: the line discipline is editing before you are

By default a tty is in canonical mode. The kernel's line discipline โ€” a layer between the device and your process โ€” buffers input until the user presses Enter, and implements its own primitive line editor: backspace erases, Ctrl-U kills the line, and only the finished line is delivered to read(). That's why a plain std::getline program feels "line based": the kernel is doing the editing, not the C++ runtime.

The line discipline also echoes every typed character back to the screen, translates carriage returns to newlines, and turns certain bytes into signals: Ctrl-C sends SIGINT, Ctrl-Z sends SIGTSTP, Ctrl-\ sends SIGQUIT. Useful defaults for a shell session; fatal for an editor. If the kernel echoes keystrokes, every j the user types to move down gets printed into your carefully painted screen. If Ctrl-C kills you, you can't bind it. An editor needs the terminal in raw mode: every byte delivered as typed, nothing echoed, nothing translated, no signals.

termios, flag by flag

The tty's configuration lives in a struct termios (POSIX, <termios.h>), read with tcgetattr(fd, &t) and written with tcsetattr(fd, TCSAFLUSH, &t). It has four flag words โ€” input (c_iflag), output (c_oflag), control (c_cflag), local (c_lflag) โ€” plus an array c_cc of control characters and thresholds. "Raw mode" is not a single switch (well, non-portably there is cfmakeraw); it is a specific set of flags to clear, and knowing why each one matters is knowing what the terminal actually does for you:

  • ECHO (local): kernel echoes input back to the display. Off โ€” the editor decides what appears on screen.
  • ICANON (local): canonical (line-buffered) mode. Off โ€” read() returns bytes as they are typed, not lines.
  • ISIG (local): Ctrl-C โ†’ SIGINT, Ctrl-Z โ†’ SIGTSTP. Off โ€” those bytes (0x03, 0x1A) are delivered to you like any other key, so vi-style bindings can use them.
  • IXON (input): software flow control โ€” Ctrl-S freezes output until Ctrl-Q resumes it. A gift to 1970s serial links, a trap today (everyone has "frozen" a terminal with a stray Ctrl-S). Off, so Ctrl-S is just a byte.
  • IEXTEN (local): extended input processing โ€” on many systems Ctrl-V means "quote the next character" (and on macOS Ctrl-O is swallowed). Off.
  • ICRNL (input): translate carriage return (0x0D, what the Enter key actually sends) into newline (0x0A). Off โ€” you want to see the real byte, and to tell Enter (0x0D) apart from Ctrl-J (0x0A).
  • OPOST (output): output post-processing, in practice \n โ†’ \r\n translation. Off โ€” from now on, when you want the cursor at the start of the next line you write "\r\n" yourself. (Forget this and your output staircases across the screen.)
  • BRKINT, INPCK, ISTRIP (input): break-condition SIGINT, parity checking, and stripping the 8th bit. All relics of real serial hardware; all off, both for tradition and because ISTRIP would mangle UTF-8.
  • CS8 (control): a two-bit field, set rather than cleared โ€” 8-bit characters. Almost certainly already set.

Two entries of c_cc control when read() returns in non-canonical mode, and they are worth internalizing because they define your event loop's personality:

  • VMIN โ€” minimum bytes before read() may return.
  • VTIME โ€” read timeout in tenths of a second.

VMIN=1, VTIME=0 makes read() block until at least one byte arrives โ€” a pure event-driven loop. VMIN=0, VTIME=1 makes read() return after at most 100 ms even with no input โ€” a polling loop that gives you a natural place to handle "no key pressed" work (like noticing the window was resized, or timing out a lone ESC โ€” a plot point in the next lesson). We'll use VMIN=0, VTIME=1.

In real code, entering raw mode looks like this โ€” and note that we modify a copy of the original settings rather than building a termios from zero, because the struct has fields we don't understand and must not clobber:

#include <termios.h>
#include <unistd.h>

termios orig;                       // saved so we can restore at exit
void enter_raw() {
    tcgetattr(STDIN_FILENO, &orig);
    termios raw = orig;             // copy, then surgically edit
    raw.c_iflag &= ~(BRKINT | ICRNL | INPCK | ISTRIP | IXON);
    raw.c_oflag &= ~(OPOST);
    raw.c_cflag |= CS8;
    raw.c_lflag &= ~(ECHO | ICANON | IEXTEN | ISIG);
    raw.c_cc[VMIN] = 0;
    raw.c_cc[VTIME] = 1;
    tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
}

TCSAFLUSH applies the change after pending output drains and discards unread input โ€” so a half-typed line from before raw mode doesn't leak into the editor as phantom keystrokes.

Leaving the terminal the way you found it

Exit your editor without restoring the original termios and the user's shell inherits raw mode: nothing echoes, Enter doesn't work, and they're typing reset blind. Restoration must happen on every exit path โ€” normal quit, early return, exception. C++ has a dedicated idiom for "must happen on every exit path": RAII (Resource Acquisition Is Initialization). Acquire in a constructor, release in the destructor, and let scope exit โ€” however it happens โ€” do the bookkeeping:

class RawMode {
public:
    RawMode() {
        tcgetattr(STDIN_FILENO, &orig_);
        termios raw = orig_;
        make_raw(raw);                       // this lesson's challenge
        tcsetattr(STDIN_FILENO, TCSAFLUSH, &raw);
    }
    ~RawMode() { tcsetattr(STDIN_FILENO, TCSAFLUSH, &orig_); }

    RawMode(const RawMode&) = delete;             // copying makes no sense:
    RawMode& operator=(const RawMode&) = delete;  // who restores, and when?

private:
    termios orig_;
};

int main() {
    RawMode raw;          // terminal is raw from here on
    run_editor();         // may return early, may throw
}                         // ...restored here, no matter what

A raw-mode guard is the canonical RAII example for a reason: the resource isn't memory, it's terminal state, and leaking it hurts a human immediately. Two design points worth dwelling on:

  • Copying is deleted. If two RawMode objects existed, the second's constructor would save an orig_ that is already raw โ€” restoring it would "restore" rawness. Deleting the copy operations makes the broken program not compile. This is the rule of five in action: since we wrote a destructor with side effects, we must decide what copy/move mean, and here the right answer is "they don't".
  • The flip side is the rule of zero: classes that don't own a resource should define none of the five special members and let the compiler-generated ones work. Most classes you write in this course โ€” positions, ranges, buffers built on std::string โ€” follow the rule of zero. Only resource guards need the full ceremony.

The exit paths RAII cannot catch

Destructors run on returns and exceptions โ€” but not on std::abort, not on _exit, and not when a signal kills the process. Two signals need explicit handling in a real editor, and both are pure terminal lore worth knowing:

  • SIGTSTP / SIGCONT (Ctrl-Z suspend, fg resume): with ISIG off you won't get Ctrl-Z from the keyboard, but kill -TSTP can still arrive. The polite dance: on SIGTSTP, restore the original termios, then re-raise SIGTSTP with default disposition so the shell actually suspends you; on SIGCONT, re-enter raw mode and repaint the whole screen (the shell owned the display while you slept).
  • Crash safety: register the restore with atexit, and keep signal handler paths async-signal-safe โ€” tcsetattr and write are on the safe list; std::cout and malloc are not.

The grader has no tty (and that's a feature)

Everything above is glue you'll assemble in your own editor binary and verify by running it in a real terminal. The grader, though, runs your code headless โ€” no tty at all โ€” which enforces a discipline this whole course is built on: separate the OS glue from the logic, and make the logic pure. The first challenge tests your raw-mode flag surgery against a stand-in Termios struct with the same field names and (Linux) flag values as the real one โ€” swap Termios for termios and the identical function body drops into your editor. The second distills the RAII guard into a reusable, tty-free scope guard.

โ€บ Raw Mode, Flag by Flag

10 pts

Implement make_raw, which edits a Termios in place to configure raw mode exactly as specified:

  • Clear in c_lflag: kECHO, kICANON, kISIG, kIEXTEN.
  • Clear in c_iflag: kBRKINT, kICRNL, kINPCK, kISTRIP, kIXON.
  • Clear in c_oflag: kOPOST.
  • Set in c_cflag: kCS8 (set the whole two-bit field).
  • Set c_cc[kVMIN] = 0 and c_cc[kVTIME] = 1.

Every bit you were not told to touch must survive unchanged โ€” the tests plant unrelated bits in every flag word and check they're still there. This mirrors the real API contract: termios carries settings you don't own, so you edit, never overwrite.

Log in to submit a solution and earn points.

โ€บ A Scope Guard

10 pts

Implement ScopedAction: it stores a callable at construction and invokes it exactly once when the guard dies โ€” unless disarmed. This is the lesson's RawMode with the terminal dependency injected, and it's the shape of every "undo this on scope exit" problem you'll meet later (leave the alternate screen, re-show the cursor, restore termios before suspending).

Unlike RawMode, this guard is movable: a factory function like ScopedAction enter_raw_mode() must be able to return the guard to its caller, handing off responsibility for the cleanup. That makes it a worked example of the rule of five: destructor, deleted copies, and move operations that keep the invariant the action runs exactly once.

Semantics to implement:

  • ScopedAction(std::function<void()> f) stores f; the destructor invokes it if the guard is still armed.
  • release() disarms: after it, the destructor does nothing.
  • Move constructor: transfers the action; the moved-from guard is disarmed (the action must not run twice).
  • Move assignment: the target first runs its own pending action (it is being destroyed, morally), then takes over the source's; the source is disarmed. Self-assignment is a no-op.
  • Copying is deleted โ€” two owners of one cleanup is the bug this class exists to prevent.
  • armed() reports whether the destructor would fire.

Log in to submit a solution and earn points.