Skip to content

Latest commit

 

History

History
277 lines (206 loc) · 11.2 KB

stage_1.md

File metadata and controls

277 lines (206 loc) · 11.2 KB

fork/exec/wait

Ingredients

Instructions

Classically, the simplest possible shell is basically:

loop
    print "$ "; flush(stdout)
    line = getline(stdin).split()
    if eof(stdin): exit(0)
    pid = fork()
    if pid == 0:
      execvp(line)
    waitpid(pid)

(Warning: note that most of these functions can fail; you'll want error handling in your actual code.)

We'll do a bit of extra work in this stage, compared to many simple shell tutorials, since it will prepare us for the later stages.

Basics

You should

  • make your shell print the $ prompt;
  • accept a line of input, and split it on space;
  • if the first word is a builtin (a command your shell will handle itself instead of executing an external program), call it; otherwise
  • execute that as a command with space-delimited arguments;
  • repeat until you receive EOF on stdin.

Executing a command

First, we fork, which creates a child process whose state is a copy of our shell's; the memory looks the same, and any file descriptors open are the same. After the fork, there are two parallel universes happening: the parent (who gets the process ID of the child from fork) and the child (who gets 0 from fork).

In the child, we execve the command we want to run, with its arguments; this replaces the running process (the child copy of the shell) with the new command. So now we have both our shell and the command running, and the shell knows the process ID of the command. (See Patrick Mooney's talk On Wings of exec(2) for deeper details of what happens after we execve.)

The parent now waits for the child to complete; we do this with wait. This lets the shell sleep while the command runs, and wakes us up with the exit status of the command.

This part is explained in a lot of detail in the resources listed in the main README, so if this isn't clear, please refer to the books and tutorials cited.

Running the tests

This should be enough to get at least the first test of stage 1 to pass. Run ./validate ../path-to-my-shell/my-shell and see how far you get.

The test suite will try to execute various well-known POSIX commands inside your shell. Make sure actual binaries of true, false, cat, pwd, and echo are in /bin.

About the prompt: the test suite avoids testing the specific prompt, because it turns out this is something people like to have fun with, but I recommend emitting at least some prompt for each of the cases I mention, because this will make it easier for you to debug your shell, and eventually to use it. Unfortunately, if your default prompt and display is too elaborate (e.g.: zsh with RPROMPT, fish), the tests may not work, as they are not terribly robust.

The cd builtin

To that, we should add at least a builtin for cd, which will call chdir(2) with the supplied argument. Think about why we couldn't implement this as an external command. If you're not sure, try implementing cd as a standalone binary that invokes chdir and see what happens. You can exec pwd or call getcwd(3) to find out the current directory: you might want to print it as part of your prompt.

Searching PATH

We don't want to have to type the explicit path to every command you're executing. One of the crucial conveniences of every shell is that, if we find an unqualified command name (one that doesn't contain the / character), we look for it in every directory specified in the PATH environment variable.

Many languages provide versions of exec that do some of this extra work for you. That's probably fine as long as you know who is responsible for what. When you add completion in stage 5, you'll want to be able to search the path yourself.

We'll talk more about the enviroment in stage 4. For now, you can use whatever your language provides for accessing environment variables (getenv(3) in C) to get the value of PATH, then split it on the : character to get a list of directories to search.

For a more rigorous specification, see Command Search and Execution in the POSIX standard. (This also tells you when you're allowed to cache the location of a command.) PATH itself is described in Other Environment Variables.

Exit status and !

We mentioned that you get the exit status of the child from wait. Don't just throw this away; this will be a key part of the shell's power, and it's why there are commands like /bin/true and /bin/false.

There is an unfortunate amount of information packed into the status returned to us by wait, but we're only interested in exit status right now. The Unix convention is that a zero exit status represents success, and any non-zero exit status represents failure.

If the first word we read is !, we negate the exit status of what follows. Note that's word, not character, so ! true returns a non-zero exit code, but !true is not defined in POSIX, and is usually part of a history mechanism in ksh descendents.

Bonus: Change the prompt to red if the last command run exited with a non-zero status? (Don't worry about termcap and supporting every terminal under the sun just yet; just use ANSI escape codes for now.)

Lists of commands

Split your input on ;, &&, and ||. Commands separated by any of these are executed sequentially. For semicolons, that's all you need to do.

When two commands are separated by &&, you need to run the following command only if the exit status of the first command is 0. For ||, you'll run the second command only if the exit status is not 0.

Both of these echo bar:

false && echo foo || echo bar
true || echo foo && echo bar

The standard calls these sequential lists, AND lists, and OR lists, respectively. See section 2.9.3, Lists. We'll look at asynchronous lists in stage 3.

(If you want to add support for compound lists surrounded by braces, go ahead, but I didn't consider them important enough for an interactive shell to bother testing them.)

exec builtin

There are ways to use each half of this fork/exec pair without the other. The exec builtin causes your shell to exec without forking, which replaces the current process entirely.

This is mostly useful in scripting, where you want to throw away the memory occupied by the shell entirely because you know the next command is the last thing you're going to do.

The exec builtin has another use we will discuss in the next stage.

Subshells

Now, for the other half of fork/exec -- what if we fork (and wait), but don't exec? If a command is surrounded in parentheses, we fork, and in the parent, wait for the child as we normally would, and in the child, we process the command normally.

This allows us to do things in an isolated shell environment.

(cd /tmp && pwd); pwd

Should print /tmp and whatever your previous working directory was.

This isn't that important, relative to the other things we're going to talk about, but having enough of a parser in place that you can read the parentheses correctly will be helpful for the following stages.

Line continuation

Let's allow \ at the end of a line as a continuation character. Print > as the prompt while reading a continued line.

Likewise, if a line ends with && or ||, you'll want to continue reading on the next line as part of the same list.

Aside: parsing

See Token Recognition in the standard. Writing a tokenizer that follows this, ignoring the parts we aren't doing yet, will make writing your parser easier. I'll expand this section more, shortly.

You're faced with a tricky choice, here: I wrote this originally trying to put off quoting and other difficulties until later, just doing the simplest operations to get things going. But after writing a few shells, I feel that getting proper parsing working is worth the hassle. It's painful, but it makes everything else much easier.

Notes

This is actually, along with job control later, the most difficult part of writing a shell in any language but C, because such languages usually provide features like threads and signal handling out of the box. They also end up opening fds that might not have CLOEXEC set.

threads

fish is multithreaded. Ponder the following comment from ridiculous_fish:

An example of a problem we encountered: what if execve() fails? Then we want to print an error message, based on the value of errno. perror() is the preferred way to do this. But on Linux, perror() calls into gettext to show a localized message, and gettext takes a lock, and then you deadlock.

posix_spawn

As an alternative to the combination of fork and exec, it would make sense to use posix_spawn(3). On many systems, this provides a "safe" wrapper around vfork(2), which can be much more efficient. (To watch someone basically reinvent it, see "A much faster popen and system implementation for Linux")

One of the reasons I don't encourage it here is that it ends up being much more complex. Another quote from the author of fish:

The brilliance of fork/exec is in the realization that the tasks you do during process creation are the same as the tasks you do during normal execution, and therefore you can just re-use those functions.

Compare the various libc implementations of posix_spawn:

See also https://ewontfix.com/7/

Further Reading

Stevens, APUE, chapter 8. Kerrisk, LPI, chapters 24 through 28.

A fork() in the road: https://www.microsoft.com/en-us/research/publication/a-fork-in-the-road/ Introducing io_uring_spawn: https://lwn.net/Articles/908268/