Skip to content

Latest commit

 

History

History
357 lines (292 loc) · 17.3 KB

File metadata and controls

357 lines (292 loc) · 17.3 KB

knowledge.md — minishell: what this project was for, and how it was built

Aim of the project

The goal was not just "have a working shell." The goal was to use building a shell as a vehicle to revise core OS/Linux concepts — processes, file descriptors, syscalls, signals — the same concepts a university OS course or the well-known 42 School "minishell" project are built around, in a resume-worthy, from-scratch way.

The explicit working agreement for how this project was built:

  • Requirements were decided before code, one step at a time.
  • For each step: the concept/requirement was explained first (why this piece exists in a real shell), then it was designed, then code for only that step was written — modular, one concern at a time.
  • No jumping ahead. Each step was tested and confirmed working before moving to the next.
  • The code itself was generated by an LLM. The learning was in deciding what to build and why, reading and questioning the generated code, choosing the scope, finding/understanding bugs, and connecting each piece back to the underlying OS concept. This document is the record of that reasoning — not a claim that the code was hand-typed.

How the requirements were decided

Before writing anything, three scoping questions were answered:

  1. Language: C — chosen specifically because it stays close to real syscalls (fork, execve, pipe, dup2), unlike a higher-level language that would hide them.
  2. How much of the full 42-style "minishell" spec to attempt: rather than either the full spec (very large) or a toy version, a deliberately reduced but still "real" scope was chosen — every core OS concept, without the long tail of bash edge cases that add grind but not learning:
    • In scope: REPL + readline, lexer/parser with quoting, $VAR / $? expansion, fork/execve/PATH execution, builtins (cd, pwd, echo, export, unset, env, exit), pipes, redirections (<, >, >>, <<), signal handling, exit-status propagation, error handling.
    • Out of scope (stretch goals, skipped on purpose): &&/||, wildcards (*), subshells (()), a fully custom envp array.
  3. Starting comfort level with C/syscalls: already comfortable — so the priority was structure and sequencing of the build, not basic C syntax.

Step-by-step build log

Each step below follows the same three-part shape: what we built, a challenge/bug actually hit, what it taught.

Step 1 — Project skeleton + minimal REPL

Built: A Makefile (-Wall -Wextra -Werror, links -lreadline) and a loop that prints a prompt, reads a line with readline(), adds it to history, and exits on exit or Ctrl-D.

Challenge: Distinguishing "empty line" from "end of input." readline() returns an empty string "" for a blank Enter press, but returns NULL specifically on EOF (Ctrl-D / closed input stream). Treating those the same would make the shell either never exit on Ctrl-D, or exit on every blank line.

Learned: EOF is a real, distinct signal at the syscall level too — read() returns 0 at end-of-stream, not an error and not "empty data." readline's NULL is the library-level version of the same idea. Also: why a Makefile matters at all — once there's more than one translation unit, make recompiles only what changed and relinks consistently, instead of a manually-typed gcc command per file.

Step 2 — Lexer (tokenizer)

Built: Converts a raw input line into a linked list of tokens: TOKEN_WORD, TOKEN_PIPE, TOKEN_REDIR_IN, TOKEN_REDIR_OUT, TOKEN_APPEND, TOKEN_HEREDOC. Handles 'single' and "double" quotes, including quoted and unquoted text glued into a single word (ab"cd"ef → one token abcdef).

Challenge: Quoting isn't just "strip the quote characters" — which quote was used has to be remembered per-character, because it changes what's allowed to happen later (expansion). An unterminated quote (echo 'oops) also has to be detected and reported as a syntax error instead of reading past the end of the string.

Learned: Real shells track quoting as metadata alongside the text, not just as a one-time strip. That's why each token carries an expand_mask string (per-character: was this char inside single quotes?) in parallel with its value — a small preview of how real parsers separate "the data" from "the rules about the data."

Step 3 — Parser

Built: Turns the flat token list into a pipeline of t_cmd nodes — each with an argv array and a linked list of redirections. Detects syntax errors: empty command around a pipe (| cmd, cmd |), and a redirection with no target (cmd < with nothing after it).

Challenge: Deciding where validation belongs. Catching "no command between two pipes" has to happen while walking tokens (there's no tree yet to inspect afterward), which meant tracking "has the current command accumulated anything yet" as parsing proceeds, not as a separate pass.

Learned: Parsing and validation are naturally interleaved for a simple grammar like this — you don't always need a two-pass "parse-then-validate" design; the shape of the input tells you when an error condition (like an empty command) has become impossible to fix later.

Step 4 — Expansion ($VAR, $?)

Built: Walks each word's characters alongside its quote mask and substitutes $? → last exit status, $NAME → getenv("NAME") (or empty if unset), while leaving anything inside single quotes untouched.

Challenge: The expanded result can be a completely different length than the original word ($? might become "127", an unset $FOO becomes "") — so it can't be built in-place in the original buffer. It also has to not expand $ inside single quotes, but must expand it inside double quotes — two different rules living in the same word.

Learned: Why '...' and "..." are genuinely different in every real shell (not just cosmetic): single quotes mean "no substitution at all," double quotes mean "still substitute variables." Also a small, concrete lesson in dynamic-buffer growth (t_dstr: doubling capacity instead of reallocating one byte at a time).

Step 5 — Execution engine (single command)

Built: find_executable() resolves a command name against $PATH (or uses it directly if it contains a /); a child process is fork()'d, and execve() replaces its image with the target program.

Challenge: Understanding that execve replaces the calling process rather than starting a new one alongside it — so anything after a successful execve() call in the code is unreachable, and if execve() returns at all, it already failed.

Learned: The fork → execve → wait pattern that essentially every Unix shell, container runtime, and job scheduler is built on: to run something and keep control of your own process, you must duplicate yourself first (fork), then optionally replace the copy's program image (execve), then collect its result (wait/waitpid).

Step 6 — Builtins

Built: cd, pwd, echo, export, unset, env, exit — each one dispatched and run in the shell's own process, never forked.

Challenge: Realizing why cd can't be forked. A forked child has its own independent working-directory state; if cd ran in a child, chdir() would change that throwaway child's directory and have zero effect on the shell itself the moment the child exits.

Learned: The process/child boundary isn't just about performance — some operations are only meaningful if they mutate the calling process's own state (current directory, environment variables, or terminating the shell itself via exit). This is a direct, hands-on encounter with the idea of process state as something that doesn't automatically propagate from parent to child or back.

Step 7 — Pipes

Built: execute_pipeline() — wires 1..N commands together with pipe() + dup2(), forking one child per command, closing unused fd ends, and waiting for all of them.

Challenge: Getting the order of operations right in each child: duplicate the correct pipe end onto stdin/stdout before closing any pipe fds, and close every pipe fd the child doesn't need — a forgotten close() on the write end of a pipe is a classic way to make a reader hang forever (it never sees EOF because a writable copy of the fd is still open somewhere).

Learned: A pipe's "EOF" isn't automatic — it happens when every copy of the write-end fd across every process is closed. This is the core of why pipeline code always looks like "dup2, then close everything else, in every child."

Step 8 — Redirections

Built: apply_redirs() — opens the right fd for <, >, >>, duplicates it onto fd 0 or fd 1 via dup2(). Multiple redirections of the same direction on one command correctly let the last one win (matching bash).

Real bug hit — worth remembering: printf()/echo output doesn't go to the file descriptor immediately — it sits in a userspace buffer inside libc (stdio buffering) and flushes later. When a builtin ran with a redirect and then the original stdout was restored via dup2(), that restore happened before the buffer had flushed — so the text ended up going to the now-restored terminal fd instead of the file it was supposed to go to. Fix: fflush(stdout) while the redirect is still active, before restoring the original fd.

Learned: "Write" at the C standard-library level and "write" at the syscall level are two different things with a buffer sitting between them. This is a genuinely common real-world class of bug, not a toy-project quirk.

Step 9 — Heredocs (<<)

Built: read_heredoc_body() reads lines interactively until one exactly matches the delimiter, writes the captured body to a temp file (tmpfile()), and dup2()s that onto stdin. Quoted delimiters (<<'EOF') disable $VAR expansion inside the body; unquoted ones allow it — matching bash exactly.

Challenge: Deciding when to collect the heredoc body. collect_heredocs() runs in the parent shell, before any fork(), specifically because a forked child's stdin might already be wired to a pipe by the time it exists — the terminal has to be read from while the shell itself still owns fd 0.

Learned: Why a temp file was used instead of a live pipe: a pipe has a limited kernel buffer, so a large heredoc body could deadlock (the writer blocks on a full pipe, waiting for a reader that hasn't started yet). A temp file has no such size ceiling.

Step 10 — Signal handling

Built: Two signal "modes": set_signals_interactive() while sitting at the prompt (Ctrl-C redraws the prompt instead of quitting; Ctrl-\ is ignored, matching bash), and set_signals_executing() while a child runs (the shell ignores both signals; each child resets them to default right after fork(), so it is the one that reacts).

Challenge: Getting Ctrl-C to kill a running child without killing the shell that's waiting on it — the naive approach (shell and child sharing the same signal disposition) kills both.

Learned: Signal dispositions are per-process and are inherited at fork() time, then can diverge — this is exactly how real shells give "the thing you're running" and "the shell itself" independent reactions to the same keypress. Also: exit-status convention 128 + signal_number (e.g. Ctrl-C → SIGINT (2) → exit code 130) is bash's real convention, not something invented for this project.

Step 11 — Error handling & hardening

Built: Fixed a real, reproducible crash: a line that is only a redirection with no command at all (> file.txt) segfaulted, because the code path called is_builtin(argv[0]) while argv was NULL (no words were ever added to it). Added a dedicated apply_redirs_only() path for this case, applied the same guard inside a pipeline stage (> file.txt | cat), and added checks for pipe()/fork() failing (instead of assuming they always succeed and later calling waitpid() on a garbage/negative pid).

Challenge: This bug only shows up for an input that's syntactically valid in real bash but easy to overlook when you're mentally testing "a command, maybe with redirections" — not "redirections with zero commands." Finding it required deliberately trying inputs shaped like edge cases, not just the happy path.

Learned: NULL propagation is the most common crash shape in C — argv being NULL is a valid, reachable state in this parser (a command can have redirections and no words), and every place that reads argv[0] has to either know that's impossible for that call site, or check first. This is the same class of bug that null-pointer checks and defensive programming exist to catch in any C codebase.

The compilation steps behind make

Running make on this project actually goes through four distinct stages of the C compilation pipeline (this is what the single gcc invocation in the Makefile hides):

  1. Preprocessing — #includes are textually inlined, macros expanded. <stdio.h>, <readline/readline.h>, etc. get pulled in here.
  2. Compilation — the preprocessed C source is translated into assembly for the target architecture. This is where -Wall -Wextra -Werror does its job: warnings about implicit conversions, unused variables, or missing prototypes are caught here, and -Werror promotes every one of them to a hard build failure — which is why keeping the build "clean" (zero warnings) was treated as a hard requirement throughout, not a nice-to-have.
  3. Assembly — the assembly is turned into machine code, producing an object file (main.o) — machine code plus a table of symbols this file defines and symbols it needs but doesn't define (like readline, execve, fork).
  4. Linking — the object file(s) are combined with the needed libraries. -lreadline tells the linker to resolve the readline symbol against libreadline; the C library (fork, execve, pipe, dup2, waitpid, ...) is linked in automatically. Output: the final minishell executable.

make's actual value here: after the first build, only files whose source changed get re-compiled; unaffected object files are reused and just relinked. For a single-file project this is a smaller win, but it's the same mechanism that makes larger multi-file C/C++ projects buildable at all in reasonable time.

POSIX / OS concepts revisited

  • Process creation & replacement: fork() duplicates the calling process; execve() replaces a process's program image in place; waitpid() reaps a child and retrieves its exit status.
  • File descriptors as the universal handle: stdin/stdout/stderr are just fds 0/1/2 by convention — dup2() is how both pipes and redirections work, because both are really "make this fd number point at that open file/pipe end instead."
  • Pipes & EOF semantics: a pipe's read end sees EOF only once every writable copy of the write end, across every process, is closed — not automatically when "the writer is done."
  • Signal disposition & inheritance: signal handlers/dispositions are per-process, inherited at fork(), and can be changed independently afterward — the mechanism behind "Ctrl-C kills the child, not the shell."
  • Exit status conventions: normal exit → the given code (0–255); killed by signal → 128 + signal number (bash's convention, reproduced here).
  • Environment as inherited process state: child processes get a copy of the parent's environment at execve() time — which is exactly why export/unset in this shell must modify the shell's own long-lived environment, not something scoped to a child that will vanish anyway.
  • Buffered I/O vs. syscall I/O: the stdio layer (printf, echo) buffers in userspace before it reaches an actual write() syscall — a distinct layer from the fd-level redirection work, and the source of the fflush() bug in Step 8.

A note on how this was actually built

The code in main.c was generated by an LLM, one modular step at a time. That's stated plainly rather than glossed over. What made this a real learning exercise rather than copy-pasting an answer:

  • Every step's requirement was decided first, based on "what does a real shell need to do here, and why" — not by asking for a finished project.
  • Each generated piece was tested immediately (piped test inputs, valgrind, deliberately-broken inputs) before moving on, and several real bugs (the stdio-buffering issue, the NULL argv crash, unchecked fork/pipe failures) were found and understood by testing rather than assumed away.
  • The reasoning connecting each step to an OS/POSIX concept — the content of this document — is the actual deliverable of the learning process; the compiled binary is a side effect of it.

Final status

Implemented and tested: REPL, lexer, parser, expansion, execution, builtins, pipes, redirections (including heredocs), signal handling, and error hardening against the crashes described above.

Deliberately not implemented (see README.md for the reasoning): &&/||, wildcards, subshells, a fully custom envp array. The project was intentionally stopped here — every core OS concept on the original roadmap had been exercised, and further scope would have added bash-compatibility edge cases without adding new concepts.