Building a shell with job control has four core layers: a command parser with fork/execvp, a job control layer with process group assignment via setpgid, a control layer with tcsetpgrp for terminal handoff, and a reaper layer for background jobs via a looping SIGCHLD handler using waitpid with WNOHANG. Most tutorials cover the command parser layer and stop there.
This guide includes all four layers, identifies where signal handling silently breaks, and provides recommendations derived from my experience building custom shells across systems work and security tooling.
Why Shell Job Control Is Completely Understandable
Most developers rely on job control commands like Ctrl+Z, fg, and bg regularly, without understanding their inner workings. This is perfectly fine until you are required to build something that behaves like a shell, and then your basic knowledge will cease to suffice.
Over the last eight years, I have worked with various enterprise systems, cybersecurity tooling, and AI infrastructure. I have encountered shells (custom, embedded, restricted, etc.) more than most developers would expect. The POSIX terminal model is not intuitive and most guides skip over the most production-bug triggering concepts (terminal ownership, process group race conditions, and signal handler design.)
The 2025 Stack Overflow Developer Survey reports that 69% of developers in the last year have spent time learning a new coding technique, while the world over most new coding techniques provide shell building capabilities, it is one of the most skipped exercises and one of the most elucidating. This guide aims to change that.
What Is Shell Job Control and Why It Matters
It is possible for a shell to execute multiple commands at once, shift commands to the foreground or background, and react to the Ctrl+C and Ctrl+Z commands, thanks to job control.
A shell without job control can only execute one command at once.
Job control allows the user to:
- Launch processes that run in the background by using the & token
- Suspend a process with the Ctrl+Z command token and reinstantiate it at a later time
- Bring background processes to the foreground with the fg command
- Send commands to process groups as a whole
The underlying machinery involves three POSIX concepts: process groups, sessions, and terminal control. Every command a shell executes belongs to a process group. Each command that the shell executes is part of a process group. Each process group is a part of a session. The terminal that is in control has one foreground process group at a time. Only the process group that is in the foreground can receive the signal from the keyboard.
Core Loop: Functionality Requirements
Building a usable shell starts with a basic loop. The loop does the following:- Print a prompt and read a command line from
stdin - Parse the input into an
argvlist - Check if the command is a shell built-in (
exit,jobs,fg,bg) - If not,
forka child process and callexecvpto run the command - Decide whether to wait for the child (foreground) or not (background)
For foreground processes, the parent shell calls waitpid(child_pid, &status, 0) and blocks until the child exits. For background processes, the shell does not wait, it returns immediately to the prompt. That is the core fork. Everything else in job control is about managing what happens to those background children after they exit.
Creating Process Groups and Controlling the Terminal
The main issue with most implementations of a custom shell is that creating a child process isn't enough. Your shell needs to put the child in a new process group and give control of the terminal to that group before running the child process.The following are the steps of launching a foreground job:
- Call
fork()to create the child process - In the child, call
setpgid(0, 0)to make the child its own process group leader - In the parent, call
setpgid(child_pid, child_pid)for the same reason (race condition protection, more on this below) - Open
/dev/ttyto get a file descriptor for the controlling terminal - Call
tcsetpgrp(tty_fd, child_pid)to give the terminal to the child's process group - Wait for the child with
waitpid - After the child exits, call
tcsetpgrp(tty_fd, shell_pgid)to take the terminal back
Most developers are surprised by step 3. It is not redundant to call setpgid in the parent and child processes. It actually fixes a race condition. After a fork system call, the parent and child processes may execute in any order. If the parent process calls setpgid first, and only the child process calls setpgid, the parent process may call tcsetpgrp before the child process has a chance to call setpgid. In both cases it is guaranteed that there will be no race condition.
Step 5 also requires special attention. In order to call tcsetpgrp in the parent process, to regain control of the terminal, the parent process temporarily has to ignore the SIGTTOU signal. According to the POSIX specification, when a background process calls tcsetpgrp, the background process receives SIGTTOU, which will stop it. The parent process has to ignore the signal, regain control of the terminal, and then restore the signal handler.
Steps 4-7 should be ignored for background processes. The child process will run in its own process group, and the shell will retain control of the terminal.
Implementing SIGCHLD and waitpid for Background Jobs
Children processes running in the background eventually terminate. When this happens, the kernel sends a SIGCHLD signal to the shell process. A shell must implement a signal handler that prevents background children processes that terminate from becoming zombie processes. Zombie processes use kernel resources perpetually.
The handler setup looks like this in C:
signal(SIGCHLD, sigchld_handler);
Inside the handler, the reaping logic needs to call waitpid to clean up exited children and update your internal job list.
Where Developers Get Signal Handling Wrong: The Single waitpid Call
The advice that I have to push back on the most when I see it on tutorials is this: "In your SIGCHLD handler, call waitpid(child_pid, &status, 0) one time."
This is incorrect and will leave zombie processes in production systems.
The justification for this is that signals, in general, are not
one-for-one like messages. If two background children processes
terminate in near proximity to each other, it is likely that only one SIGCHLD signal will be sent to the shell. Because waitpid reaps only one
child, any subsequently terminated background children will remain as
zombie processes until another SIGCHLD signal is sent to the shell,
which is extraordinarily unlikely.
The correct approach is a loop:
void sigchld_handler(int sig) {
int status;
pid_t pid;
while ((pid = waitpid(-1, &status, WNOHANG)) > 0) {
// update job list for pid
}
}
Two details matter here:
- -1 as the first argument tells waitpid to reap any child, not a specific one. Inside a SIGCHLD handler, you often do not know which child exited.
- WNOHANG as the third argument prevents the call from blocking if no child has exited yet. Without it, the loop blocks indefinitely if a child is still running.
The loop only exits when waitpid returns 0 (indicating there are no
more zombie children) or -1 (indicating an error). Following this
procedure ensures all children that exited are reaped even if during
loop processing the kernel coalesces numerous SIGCHLD signals.
I have seen this error create memory leaks in long-running shell sessions where many background children continuously become zombie children. It never instantly crashes. It just depletes the process table.
Flowchart: Foreground vs. Background Job Handling
Use this logic every time your shell launches a command:
Both paths share the setpgid step. That is intentional, every job, foreground or background, gets its own process group.
What I really suggest doing building this
Your starting step should be only allowing foreground execution. The
first step is completing the correct implementation of fork, execvp,
and waitpid. Implementing signal handling can come afterwards. Use
simple commands to test if your shell properly reaps child processes and
does not permit zombie processes.
Integrate the SIGCHLD handler next. Utilize the loop method shown.
The next set of tests should include background sleeper processes and to
verify the absence of zombie processes your shell's ps command.
The last step to implementing foreground and background process
manipulation should be the integrating the terminal control functions tcsetpgrp.
Finally, based on experience, I also suggest sigprocmask to block SIGCHLD while your loop updates job list. If a child process exits while
you are updating the SIGCHLD handler can corrupt the job list. Be sure
to block SIGCHLD , update the list, and then unblock. This is a very
simple fix for a widespread race condition issue.
Key Takeaways
- Every launched command needs its own process group. Set it with
setpgidin both parent and child immediately afterforkto avoid race conditions. tcsetpgrphands the terminal to a process group. Foreground jobs need this. Background jobs do not. Reclaim the terminal in the parent after the foreground job exits.- Your
SIGCHLDhandler must loop. A singlewaitpidcall is not enough. Usewaitpid(-1, &status, WNOHANG)in awhileloop to reap all simultaneously-exited children. - Block
SIGCHLDwhen modifying your job list. A signal arriving mid-update can corrupt the list silently. - Build in order. Foreground execution first, then background reaping, then terminal control. Testing each layer in isolation saves significant debugging time.
Frequently Asked Questions
What is the difference between a process group and a session in POSIX?
Process groups and sessions exist to manage groups of related processes. A process group contains processes that are typically commands within a single pipeline. A session contains process groups that exist as a result of a user login. A session has a controlling terminal, and that terminal has a single process group in the foreground.
Why does my shell create zombie processes when background jobs terminate?
Zombie processes most frequently occur when a shell has a SIGCHLD handler that is missing or incorrectly implemented. If a handler calls
waitpid once, instead of repeatedly in a loop using the WNOHANG option,
simultaneous exiting children will still be zombie processes. The next
most frequent occurrence is having no handler and allowing the default
behavior for SIGCHLD, which is to ignore exiting children and leave them
in a zombie state.
Is it necessary to call setpgid in both the parent and child after fork?
Yes, because after fork there is a race condition in which either the
parent or the child could run first. If only the child runs setpgid,
the parent could run tcsetpgrp before the child has set its own process
group, leading to an invalid state. setpgid should be called in both the
parent and the child to ensure the process group is created in the
child.
What happens if a background process attempts to read from the terminal?
Background processes that try to read from the terminal are sent a SIGTTIN by the controlling terminal. The default behavior for SIGTTIN is
to stop the process. This is why background processes that attempt terminal reads appear suspended, they never received terminal ownership from tcsetpgrp, so the kernel blocks them automatically.
Expert Take
Job control seems to be a shell feature, but it is a systems programming discipline. To create a shell that does not only work in a demo but is functional in the real world, the four layers of job control, process creation, group assignment, terminal ownership, and signal-driven reaping, must work in unison. Most tutorials provide you with the fork/execvp pattern for the control of job systems and leave you to figure the rest. However, those omitted parts are the ones that create difficult bugs: race conditions in terminal ownership, asynchronous signal delivery, or the coalescence of the SIGCHLD signal that corrupts the state of the terminal and creates zombie processes.
After creating and analyzing shell implementations in systems and security tooling, my professional opinion is that the signal handler loop is the most important and useful part of job control. If the signal handler in the shell is built incorrectly or inefficiently, all subsequent components of the shell will degrade and become unwieldy. The bugs caused by the signal handler will not be functional or easy to reproduce, and are the most difficult and onerous type of bug to grapple with. The implementation of the four layers of job control must be done in the order specified, and each layer must be tested in isolation with a sigprocmask around the job control. These three programming practices must be strictly adhered to, and these will prevent a huge amount of time spent debugging.
James Mitchell
He is the Founder and Editor-in-Chief of Techisane. He holds a Master of Science (MS) in Computer Science and a CISSP certification, with eight years of experience in enterprise technology. He began his career working with IT infrastructure before advancing into IT security and consulting. Mitchell brings firsthand experience to his writing, drawing on technologies he has implemented, tested, and worked with in real-world environments.

