Phone:

Hidden from the page source until you click: friction against scrapers, not a guarantee.

Email:

[email protected]

Category:

Systems Programming

Tags:
  • linux
  • pidfd
  • process-supervision
  • signals
  • epoll
  • systems-programming

Linux pidfds: Closing the PID Reuse Race in Supervisors

A traditional Unix process supervisor remembers a child as an integer. That is slightly alarming once you remember what a PID actually is: a temporary slot in a namespace, available for reuse after the process has been reaped.

Suppose a supervisor decides that PID 4217 has exceeded its shutdown deadline. It checks that the process has exited, another thread reaps it, Linux assigns 4217 to an unrelated process, and the supervisor calls kill(4217, SIGKILL). The time between the check and the signal can be tiny. It only needs to be non-zero.

Checking /proc/4217, reading the command name or comparing a start time does not make the final kill() atomic with that check. Those techniques can detect many mistakes, but there is always another lookup of the numerical PID at the end.

A pidfd changes the thing being retained. Instead of keeping the name of a process, the supervisor keeps a kernel-managed reference to that particular process.

A process handle that behaves like a file descriptor

A PID file descriptor, usually shortened to pidfd, is an ordinary file descriptor referring to one process. It can be closed, passed over a Unix socket, stored beside other descriptors and watched with poll() or epoll. It is created close-on-exec.

Linux added the pieces over several releases: pidfd_send_signal() appeared in Linux 5.1, CLONE_PIDFD in 5.2, pidfd_open() in 5.3, and waitid(P_PIDFD, ...) in 5.4. A supervisor wanting the complete polling, signalling and waiting model can sensibly treat Linux 5.4 as its minimum.

The descriptor remains tied to the original process even after that process exits. Calling pidfd_send_signal() after the target is gone produces ESRCH; it cannot silently redirect the signal to whichever process inherited the old number. The kernel documents this distinction explicitly in pidfd_send_signal(2).

Pidfds do not confer extra authority. Normal signal permission checks still apply. They are stable references, not magical root tokens, which is probably for the best.

Obtaining the descriptor without introducing another race

For an existing process, the direct interface is straightforward:

int pidfd = syscall(SYS_pidfd_open, pid, 0);
if (pidfd == -1) {
    perror("pidfd_open");
}

There is an awkward detail hiding here. If pid came from an old PID file or some other stale source, it might already identify the wrong process when pidfd_open() performs its lookup. A pidfd stabilises the process found at that moment; it does not prove that this was the process the caller originally meant.

For a child created with fork(), calling pidfd_open() immediately in the parent is safe under the usual child ownership rules. An exited child remains a zombie, and its PID cannot be reused until it is reaped. The pidfd_open(2) manual lists the qualifications: SIGCHLD must not be ignored, SA_NOCLDWAIT must not be active, and no other thread or signal handler may reap the child first.

That last condition is precisely the sort of condition which becomes fictional as a supervisor grows. Actually, this bit is interesting: the cleanest interface does not obtain a PID and then turn it into a descriptor. It asks the kernel to create both together.

int pidfd = -1;
struct clone_args args = {
    .flags = CLONE_PIDFD,
    .pidfd = (uintptr_t)&pidfd,
    .exit_signal = SIGCHLD,
};

pid_t pid = syscall(SYS_clone3, &args, sizeof(args));

With CLONE_PIDFD, clone3() places the new descriptor in pidfd as part of creating the child. There is no interval in which another component can reap the child before the descriptor is acquired. The details and version requirements are described in clone(2).

Polling, signalling and reaping one exact child

The following compact example launches sleep, gives it five seconds, sends SIGTERM through its pidfd if necessary, and reaps it using that same descriptor. It uses raw syscalls because libc availability varies. Production launch code also has to consider descriptor inheritance, reporting execve() failure to the parent and the restrictions after forking a multithreaded process.

#define _GNU_SOURCE
#include <errno.h>
#include <linux/sched.h>
#include <poll.h>
#include <signal.h>
#include <stdint.h>
#include <stdio.h>
#include <stdlib.h>
#include <sys/syscall.h>
#include <sys/wait.h>
#include <unistd.h>

static int
send_signal(int pidfd, int signal)
{
    return syscall(SYS_pidfd_send_signal, pidfd, signal, NULL, 0);
}

static int
wait_until_ready(int pidfd, int timeout_ms)
{
    struct pollfd descriptor = {
        .fd = pidfd,
        .events = POLLIN,
    };

    for (;;) {
        int result = poll(&descriptor, 1, timeout_ms);
        if (result == -1 && errno == EINTR) {
            continue;
        }
        return result;
    }
}

int
main(void)
{
    int pidfd = -1;
    struct clone_args args = {
        .flags = CLONE_PIDFD,
        .pidfd = (uintptr_t)&pidfd,
        .exit_signal = SIGCHLD,
    };

    pid_t pid = syscall(SYS_clone3, &args, sizeof(args));
    if (pid == -1) {
        perror("clone3");
        return EXIT_FAILURE;
    }

    if (pid == 0) {
        char *const child_argv[] = {"sleep", "30", NULL};
        execv("/usr/bin/sleep", child_argv);
        _exit(127);
    }

    int ready = wait_until_ready(pidfd, 5000);
    if (ready == -1) {
        perror("poll");
        close(pidfd);
        return EXIT_FAILURE;
    }

    if (ready == 0) {
        if (send_signal(pidfd, SIGTERM) == -1 && errno != ESRCH) {
            perror("pidfd_send_signal");
            close(pidfd);
            return EXIT_FAILURE;
        }

        if (wait_until_ready(pidfd, -1) == -1) {
            perror("poll");
            close(pidfd);
            return EXIT_FAILURE;
        }
    }

    siginfo_t status = {0};
    if (waitid(P_PIDFD, (id_t)pidfd, &status, WEXITED) == -1) {
        perror("waitid");
        close(pidfd);
        return EXIT_FAILURE;
    }

    if (status.si_code == CLD_EXITED) {
        printf("child exited with status %d\n", status.si_status);
    } else {
        printf("child ended after signal %d\n", status.si_status);
    }

    close(pidfd);
    return EXIT_SUCCESS;
}

A process pidfd becomes readable when the process exits and becomes a zombie, specifically when the last thread in its thread group exits. Nothing useful can be read from it; read() fails with EINVAL. Readability is the event. This makes pidfds fit naturally into an existing epoll loop without a SIGCHLD handler, self-pipe or periodic waitpid(..., WNOHANG) sweep.

Polling and reaping remain separate operations. The readiness event tells the supervisor that the child exited, while waitid(P_PIDFD, ..., WEXITED) collects its status and reaps it. The waitid(2) interface only permits this when the referenced process is a child of the caller. A pidfd opened for an unrelated process can be monitored and signalled, subject to permissions, but not reaped.

The edges pidfds do not tidy up

  • A pidfd identifies one process, not its descendants. If a service forks workers, terminating the leader through its pidfd does not automatically terminate the tree. Process groups and cgroups still matter.

  • Closing a pidfd does not reap the child and does not terminate it. Descriptor lifetime, process lifetime and zombie collection are related, but distinct.

  • Pidfds consume entries in the supervisor's file descriptor table. A large supervisor must include them in its RLIMIT_NOFILE calculations and close them after reaping.

  • clone3() can be unavailable because of an older kernel or a container's seccomp policy. A fallback using fork() followed immediately by pidfd_open() is reasonable if the supervisor controls reaping and preserves zombies during that interval.

  • Seeing ESRCH from pidfd_send_signal() is normally a benign race with process exit. Unlike the old PID race, it fails safely.

The numerical PID remains useful for logs, diagnostics and interfaces which have not acquired pidfd support. It simply stops being the supervisor's authority for destructive actions. Keep the PID for humans; keep the pidfd for the kernel.