Skip to content
Shell & TerminalDeep Dive Published Updated 6 min readViews unavailable

Shell exec: Replacing a Process Without Spawning Another Wrapper

How the shell exec builtin replaces its process, preserves PID and selected descriptors, changes signal and trap behavior, and simplifies service wrappers.

A shell wrapper often performs a little setup and then launches the real program. If it runs that program normally, the shell remains as a parent process waiting for the child. Signals, exit status, terminal control, and container shutdown now pass through an extra layer. Sometimes that layer is intentional. When the wrapper has no continuing job, the exec builtin can replace it with the target process.

The operating system keeps the same process ID while loading a new program image. On success, the shell does not return from exec; the target becomes that process. This simple transition has precise consequences for cleanup traps, file descriptors, signal state, argument handling, and what happens if replacement fails.

exec is replacement, not background launch

The portable shell form is exec utility arguments.... The shell resolves the utility and asks the operating system to execute it in the current process. There is no shell child left to call wait on the target.

#!/bin/sh
set -eu

prepare_runtime_directory
validate_configuration

exec /usr/local/bin/service --config /etc/service.conf

If replacement succeeds, the service inherits the wrapper’s PID. Its eventual exit status becomes the process status observed by the parent, service manager, or container runtime. A line after exec is reachable only when the shell could not execute the target.

Stable PID improves supervision

An init system or container runtime sends signals to the process it started. Without exec, that process may be a shell that must forward signals to its child correctly. A simplistic wrapper can absorb TERM, wait forever, or exit while the actual server continues running.

With exec, the server occupies the supervised PID directly. It receives signals according to its own handlers and reports its status without translation. Process listings and resource accounting also point at the real workload.

This does not replace a real supervisor. If several children need coordinated restart, log management, or dependency handling, the wrapper has ongoing responsibilities and should remain, with explicit signal and reap logic.

Cleanup traps do not run after successful replacement

Shell EXIT traps and code after exec cannot execute once the shell image is gone. Perform wrapper-owned cleanup before replacement, or transfer cleanup responsibility and paths to the target program.

A temporary file needed only during validation should be removed before exec. A runtime directory needed by the service must not be deleted by an EXIT trap that was designed for shell failure. Separate failure cleanup from long-lived resource ownership.

If the target should remove a resource, tell it explicitly through a safe argument, environment variable, or inherited descriptor. Do not assume it knows what the shell created.

Signal dispositions cross the boundary selectively

The kernel’s execution rules reset caught signal handlers when a new program image is loaded, while ignored dispositions generally remain ignored. Shell traps are not target-language callbacks. The target must install its own handlers during startup.

A wrapper that globally ignores TERM before exec can therefore cause the service to inherit an ignored termination signal. Avoid changing signal disposition unless needed, and restore the intended default before replacement where the shell supports it.

There is also a startup interval before the target installs handlers. Service managers should use normal signal and timeout policy rather than assuming application-level graceful behavior exists from the first instruction.

Open descriptors may survive exec

File descriptors without close-on-exec remain open across process replacement. Standard input, output, and error normally carry the wrapper’s terminal, pipe, or service-manager logging into the target. Extra descriptors can deliberately pass sockets, locks, secrets, or preopened files.

They can also leak authority. Close temporary descriptors and mark private descriptors close-on-exec in the program that creates them. A shell script should inventory every explicit descriptor before replacement:

exec 3>"$status_log"
printf '%s\n' 'starting service' >&3
exec 3>&-

exec /usr/local/bin/service

A target that waits for EOF on a pipe can hang forever if the replaced process unintentionally inherits another write end. Descriptor lifetime is part of the launch contract.

exec without a command changes the current shell

Shells also accept exec followed only by redirections. Because no new program is supplied, the redirections remain attached to the current shell:

exec 3>>"$log_file"
printf '%s\n' 'phase one' >&3
# ... later ...
exec 3>&-

Redirecting standard streams this way affects every later command in the script. Save a duplicate first if output must be restored. Always check that opening the file succeeded before discarding the original descriptor.

This form is useful but conceptually separate from process replacement. In reviews, comment whether an exec line is intended to mutate descriptors or end the shell.

Arguments should remain an argument vector

A generic entrypoint should preserve boundaries with "$@":

if [ "$#" -eq 0 ]; then
    printf '%s\n' 'no command supplied' >&2
    exit 64
fi

exec "$@"

Never use exec $* or concatenate arguments into a string. Word splitting and globbing can change filenames, and feeding the result to sh -c creates an injection boundary. Use an explicit -- where the chosen shell’s exec syntax supports it and a command could begin with a dash.

Bash adds options such as -a for argument-zero selection, -c for an empty environment, and -l for a login-style argument zero. They are not portable shell features. Declare Bash when using them.

Environment becomes target startup state

Exported variables cross exec; unexported shell variables and shell functions ordinarily do not form part of a generic program environment. Build a minimal environment instead of exporting everything inherited from an interactive login.

Avoid secrets in command-line arguments where process listings can expose them. Environment variables also have exposure risks through debugging and child inheritance. Prefer a protected inherited descriptor or the platform’s secret facility when the target supports it.

Set PATH explicitly in privileged wrappers and use an absolute target path. Replacement preserves the current directory, umask, resource limits, credentials, namespaces, and many other process attributes, so validation must cover more than the environment text.

Failure behavior deserves an explicit branch

If the target does not exist, lacks execute permission, has an invalid interpreter, or cannot load a required format, exec fails. Shell behavior after a failed special builtin varies with interactive mode, POSIX mode, and shell options. Do not rely on reaching an elaborate fallback after it.

Validate the executable and configuration first, then use a simple diagnostic failure path:

exec /usr/local/bin/service "$@"
status=$?
printf 'cannot execute service: status %s\n' "$status" >&2
exit "$status"

The diagnostic may not run in every strict shell mode, so the caller must still treat a nonzero launch result as failure. Do not fall back to another binary silently for a security-sensitive service.

Know when not to replace the shell

Keep the wrapper if it must wait for multiple processes, aggregate statuses, rotate credentials, restart a crashed child, forward signals to a process group, or perform cleanup after the service exits. In that case, implement those duties completely and test them under interruption.

Use exec when setup is finished and one program should own the process from then on. Test missing binary, invalid permissions, signal delivery, standard-stream closure, inherited extra descriptors, target crash, and target exit status. Confirm the supervisor sees the real program and receives its exact outcome.

The exec builtin is a process-lifecycle decision disguised as one shell word. It removes an unnecessary wrapper only when ownership has genuinely transferred. Clean up first, close unintended descriptors, preserve argument boundaries, and let the replacement process become the thing its parent believes it started.

Related:

Sources:

Comments