In Unix-like shells (or Windows shells, e.g., Windows Command Prompt), you can pass the standard output of one program directly into the standard input of another:

ls -la | wc -l

This pipeline forms a basis to count the number of files in the working directory by letting wc read what ls writes. The | operator is called a pipe, and it is one of the simplest forms of inter-process communication (IPC) on Unix.

This exercise asks you to implement a minimal shell that supports pipelines, study the system calls that make pipes work, and demonstrate the result.

Study the Example Programs

Start by reading the example programs:

https://github.com/huichen-cs/OSClassExamples/tree/main/ipc/posix/pipe/average

The example program uses a pipe to connect two processes: one process produces numbers, the other consumes them through the pipe. Read it with the following questions in mind:

  • which system call creates the pipe,
  • which system calls connect the pipe ends to standard input and standard output,
  • how the two processes are created and synchronized,
  • what happens to file descriptors that are no longer needed.

Tasks

Part 1: Implement a Minimal Pipe Shell

Write a program (in C or another language of your choice, with the instructor’s approval) that reads a pipeline from the command line and executes it. Your shell should behave like the following session:

$ myshell
myshell $ ls -la | wc -l
...
myshell $ echo $?
...
myshell $ cat file.txt | grep error
...
myshell $ echo $?
...
myshell $ exit
$

Your shell supports three kinds of input:

  1. A pipeline, i.e., one or more stages separated by |.
  2. The builtin echo $?, which prints the exit status of the most recently executed pipeline.
  3. The builtin exit, which terminates the shell.

For a pipeline, your shell must:

  1. Print a prompt and read a line of input from the user.
  2. Create a pipe for each pair of adjacent stages.
  3. Fork a child for each stage.
  4. In each child, connect its standard input and standard output to the appropriate pipe ends using dup2.
  5. Close every file descriptor the child does not need.
  6. Wait for all children.
  7. Record the exit status of the last stage as the shell’s own exit status, to be reported by echo $?.

Part 2: Demo and Discuss

Prepare a short demo (about 5 minutes) that includes:

  1. A working pipeline. Run your shell on at least three different pipelines, including one that uses grep, one that uses sort, and one with at least three stages.
  2. The exit-status test. Demonstrate that your shell propagates the last stage’s exit status, using the false | true and true | false tests.
  3. A deliberate failure. Break your shell on purpose and show what happens. For instance:
    • Remove one of the close() calls. Run a pipeline that produces a lot of output, such as yes | head. What happens? Which process hangs, and why?
    • Remove the wait() calls. Run a pipeline and then echo $?. Does the exit status match what you expect? Why or why not?
  4. The system call review. Walk through the system calls your program uses, and explain what each one does in the kernel. Consider the following
    1. What does pipe() return, and how does the shell distinguish the read end from the write end?
    2. Why must the child call dup2() before exec()? What would happen if it called exec() first?
    3. Why must the child close the pipe ends it does not use? Give a concrete example of a pipeline that hangs if it does not.
    4. What does wait() return, and how does the shell use that value to determine the pipeline’s exit status?
    5. What is the difference between a pipe and a regular file? Name two system calls that behave differently when the descriptor refers to a pipe rather than a file.