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
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.
- 01
REPL input
Interactive prompt, or silent when stdin is not a terminal.
- 02
Tokenizer + parser
Words, double quotes, pipes, redirection, and syntax errors.
- 03
Builtins
cd, pwd, and exit mutate shell-owned state.
- 04
Pipeline executor
pipe, fork, dup2, execvp, and waitpid per stage.
- 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.
Builtins only as a lone foreground stage
cd | wc and pwd & are rejected rather than given ambiguous fork-based semantics.
Reset signals before exec
Children restore SIGINT and SIGQUIT to their defaults so programs launched by the shell stay interruptible.
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.
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.
- 01Add single quotes and backslash escapes to the grammar
- 02Add ; and &&/|| sequencing with explicit exit-status rules
- 03Explore full job control with tcsetpgrp() as a separate scoped step
What the implementation clarified.
- 01
Closing every unused pipe descriptor in every process removes an entire class of pipeline deadlocks.
- 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.
- 03
Signal behavior needs its own tests, including a real keystroke through a pty, because unit-level checks cannot prove terminal interaction.