raw Software

Write a PID File Safely in Bash

Robert Eisele

Bash stores the process ID of the most recently started asynchronous command in $!. That makes the minimal case straightforward:

/usr/local/bin/program &
pid=$!
printf '%s\n' "$pid" > /run/example-worker.pid

Capture $! immediately. Starting another background command first overwrites it. The value identifies the asynchronous job created by the current shell; if the command forks and exits, it does not magically change to the final daemon process. Pipelines and subshell wrappers can likewise make it identify a process other than the application a caller expects.

The short snippet is enough for controlled temporary work, but it is not a complete service launcher. Concurrent starts can overwrite the file, readers can observe a partially written value, the file can remain after a crash, and a recycled PID can later refer to an unrelated process.

A PID File Is Not a Lock

A PID file records an identifier for status tools and cooperating scripts. Its existence does not prove that the process still runs, and kill -0 proves only that a PID currently exists and is signalable. It does not prove process identity.

Use an operating-system lock to prevent duplicate instances. On Linux, flock attaches the lock to an open file description and releases it when the last associated file descriptor is closed. This avoids stale-lock cleanup: a leftover lock-file pathname is harmless because the kernel lock, not the pathname, represents ownership.

A Safe Foreground Wrapper

The following Bash wrapper launches one foreground-style worker, publishes its child PID atomically, forwards common termination signals, waits for the child, and returns its exit status. It requires a private runtime directory owned by the service account. For a login session, XDG_RUNTIME_DIR is often suitable; a system service can use a directory below /run created with the correct owner before launch.

#!/usr/bin/env bash
set -u
set -o pipefail

if (( $# == 0 )); then
    printf 'Usage: %s command [argument ...]\n' "$0" >&2
    exit 64
fi

runtime_directory=${RUNTIME_DIRECTORY:-${XDG_RUNTIME_DIR:-}}

if [[ -z $runtime_directory || ! -d $runtime_directory ]]; then
    printf 'Set RUNTIME_DIRECTORY to an existing private directory.\n' >&2
    exit 73
fi

pid_file="$runtime_directory/example-worker.pid"
lock_file="$runtime_directory/example-worker.lock"

exec 9>"$lock_file"
if ! flock -n 9; then
    printf 'example-worker is already running.\n' >&2
    exit 75
fi

"$@" &
child_pid=$!
temporary_file="$pid_file.$$.tmp"
wait_interrupted=false

cleanup() {
    local recorded_pid=

    if [[ -r $pid_file ]]; then
        IFS= read -r recorded_pid < "$pid_file" || true
        if [[ $recorded_pid == "$child_pid" ]]; then
            rm -f -- "$pid_file"
        fi
    fi

    rm -f -- "$temporary_file"
}

forward_signal() {
  wait_interrupted=true
    kill -s "$1" -- "$child_pid" 2>/dev/null || true
}

wait_for_child() {
    local child_status

    while true; do
        wait "$child_pid"
        child_status=$?

        if [[ $wait_interrupted == false ]]; then
            return "$child_status"
        fi

        wait_interrupted=false
    done
}

trap cleanup EXIT
trap 'forward_signal HUP' HUP
trap 'forward_signal INT' INT
trap 'forward_signal TERM' TERM

umask 077
printf '%s\n' "$child_pid" > "$temporary_file"
mv -f -- "$temporary_file" "$pid_file"

wait_for_child
exit $?

Run it with a command that remains in the foreground:

install -d -m 0700 "$HOME/.run"
RUNTIME_DIRECTORY="$HOME/.run" ./run-with-pid /usr/local/bin/program --foreground

The lock is acquired before the child starts. The PID is first written to a same-directory temporary file and then moved over the public pathname, so readers see either the old complete file or the new complete file. The restrictive umask keeps other users from replacing the temporary contents through ordinary file access.

The cleanup function removes the PID file only if it still contains this wrapper's child PID. That check prevents an older exiting wrapper from deleting a file already replaced by a newer owner. The lock file itself is intentionally retained; deleting a lock pathname while another process still has it open can create two independently locked files.

The loop around wait matters because Bash runs a trapped signal after an interrupted wait. The handler marks that interruption, forwards the signal, and makes the wrapper wait again for the child's final status. This keeps the PID file and lock in place during a graceful shutdown without another process-identity check.

Read a PID File Defensively

A status script should validate the complete value before using it:

pid_file=/run/example-worker/example-worker.pid

if IFS= read -r pid < "$pid_file" && [[ $pid =~ ^[1-9][0-9]*$ ]]; then
    if kill -0 -- "$pid" 2>/dev/null; then
        printf 'A process with PID %s exists.\n' "$pid"
    else
        printf 'The PID file is stale or the process is not signalable.\n' >&2
    fi
else
    printf 'The PID file is missing or invalid.\n' >&2
fi

Do not turn that status check into an unconditional kill. PID reuse means a stale numeric value can name an unrelated process. A cooperating lock or a service manager is the authority for ownership. Linux-specific checks under /proc can add diagnostics, but a pathname such as /proc/$pid/exe still does not remove the race between checking a PID and acting on it.

Prefer a Service Manager for Daemons

A shell that backgrounds a process and then exits does not provide restart policy, ordered startup, resource limits, logging, or reliable signal delivery. A cron job that periodically inspects a PID file also has detection delays and PID-reuse races. On a systemd machine, keep the application in the foreground and let the service manager own its lifecycle:

[Unit]
Description=Example worker
After=network.target

[Service]
Type=exec
ExecStart=/usr/local/bin/program --foreground
Restart=on-failure
RestartSec=5s
NoNewPrivileges=true
PrivateTmp=true
ProtectHome=true
ProtectSystem=strict

[Install]
WantedBy=multi-user.target

With Type=exec, systemd tracks the launched process directly and reports an execution failure during startup. No PIDFile= directive is needed. Standard output and standard error go to the journal by default, while Restart=on-failure handles crashes without restarting after an intentional stop.

Use the Bash wrapper when another tool genuinely requires a PID file or for a small, controlled launcher. Use a service manager when the requirement is actually supervision.

References