Case study07of 08

Systems software · Source published

ArcShell

A small POSIX shell in C built directly on fork, exec, pipes, and signals.

A deliberately small shell that implements pipelines, redirection, background jobs, zombie-free reaping, and a defensible signal model from raw process-control syscalls.

Role
Design, C implementation, process and signal modeling, testing, and sanitizer verification.
Year
2026
Status
Source published
Stack
C · POSIX · Linux / WSL2 · gcc · Make · ASan / UBSan
ArcShell system diagram showing a raw input line flowing through tokenize and parse_line into a pipeline, then either run_builtin in the shell process or execute_pipeline with fork, dup2, and execvp per stage.
System diagramParser to executor flow

Overview

ArcShell is a small POSIX shell written in C to demonstrate operating-systems and process-control fundamentals: process creation, file descriptors, pipes, redirection, signals, exit status, background execution, and zombie-free child reaping.

It is not a Bash clone. The feature set is deliberately small so every behavior can be explained and defended.

Problem

Most shell work happens through a shell rather than on top of one. Writing one from fork(), execvp(), pipe(), dup2(), waitpid(), sigaction(), and setpgid() forces direct engagement with process lifecycle, descriptor ownership, and signal semantics that are otherwise easy to wave away.

Solution

ArcShell tokenizes and parses each line into a pipeline, runs cd, pwd, and exit directly in the shell process, and executes everything else by opening N−1 pipes, forking each stage, wiring descriptors with dup2(), and closing every pipe end it does not need.

Feature evidence

Pipelines and redirection

Multi-stage pipelines such as cat file | grep foo | wc -l, with per-stage <, >, and >> redirection.

Background execution

A trailing & runs the job in its own process group, reaped asynchronously by a SIGCHLD handler.

Signal-safe prompt

The shell survives Ctrl-C and Ctrl-\ while a foreground child remains interruptible.

Explicit error handling

Malformed syntax reports an error and returns to the prompt; exit status propagates as 0–255 or 128 + signal.

System layers

A raw line flows through tokenize() and parse_line() into a Pipeline of commands. A lone foreground builtin runs in the shell process; everything else goes to execute_pipeline(), which forks, wires, and waits for each stage.

  1. 01

    REPL input

    Interactive prompt, or silent when stdin is not a terminal.

  2. 02

    Tokenizer + parser

    Words, double quotes, pipes, redirection, and syntax errors.

  3. 03

    Builtins

    cd, pwd, and exit mutate shell-owned state.

  4. 04

    Pipeline executor

    pipe, fork, dup2, execvp, and waitpid per stage.

  5. 05

    Signal policy

    SIGINT/SIGQUIT handling, SIGCHLD reaping, and process groups.

Where the engineering concentrates.

Pipe descriptor ownership

Every child closes every pipe descriptor after dup2(), and the parent closes all of them once stages are spawned, so no stray write end keeps a reader from seeing EOF.

Reaping without races

SIGCHLD is blocked around each foreground fork and wait so the asynchronous reaper cannot race the foreground's explicit waitpid() calls.

Choices and tradeoffs.

01

Builtins only as a lone foreground stage

cd | wc and pwd & are rejected rather than given ambiguous fork-based semantics.

02

Reset signals before exec

Children restore SIGINT and SIGQUIT to their defaults so programs launched by the shell stay interruptible.

03

Process groups only for background jobs

setpgid() isolates background jobs from terminal-generated signals without implementing full job control.

Tradeoff: No fg, bg, or terminal handoff, in exchange for a small model that can be fully explained.

04

Small, explicit grammar

Unsupported syntax is rejected or left literal rather than silently reinterpreted.

Security, accessibility, and testing.

Security

  • Fixed compile-time limits reported as syntax errors, not crashes
  • No variable expansion, globbing, or command substitution to misinterpret input
  • Sanitizer-verified memory behavior

Accessibility

  • Non-interactive mode for scripted use
  • One-line, plain-text syntax errors

Testing

  • 41/41 functional tests passing
  • Signal tests, including a real Ctrl-C keystroke through a pty
  • File-descriptor and zombie regression test across repeated pipelines and background jobs
  • Clean AddressSanitizer and UndefinedBehaviorSanitizer run

Current boundaries

The current product has a deliberate scope. These boundaries define what it does not claim.

  • Linux and WSL2 only
  • No job control, fg/bg, or Ctrl-Z
  • No variables, globbing, &&/||, or command substitution
  • No history or line editing
  • Not Bash-compatible

The next credible steps.

  1. 01Add single quotes and backslash escapes to the grammar
  2. 02Add ; and &&/|| sequencing with explicit exit-status rules
  3. 03Explore full job control with tcsetpgrp() as a separate scoped step

What the implementation clarified.

  1. 01

    Closing every unused pipe descriptor in every process removes an entire class of pipeline deadlocks.

  2. 02

    A command's placement matters: state-changing builtins must run in the shell itself, so the shell rejects them where fork semantics would be ambiguous.

  3. 03

    Signal behavior needs its own tests, including a real keystroke through a pty, because unit-level checks cannot prove terminal interaction.