writeonce/docs/plan/exploration/linux/10-pidfd.md

4.5 KiB
Raw Blame History

10 — pidfd

Process identifier as a file descriptor. pidfd_open(pid) returns an fd that becomes readable when the process exits — reaping is a read, not a waitpid race. Lets the supervisor track child processes on the same epoll loop that drives everything else, with no PID-reuse bugs (an fd can't be recycled to point at a different process).

Not on the runtime's critical path today; useful when the runtime grows a supervisor (spawning workers, running wo build subprocesses, managing a sharded engine's child processes). Worth knowing the shape now so Phase 9+ doesn't reinvent waitpid.

Kernel source

Path What
reference/linux/kernel/pid.c SYSCALL_DEFINE2(pidfd_open, ...), pidfd_create, pidfd_pid.
reference/linux/kernel/signal.c SYSCALL_DEFINE4(pidfd_send_signal, ...).
reference/linux/kernel/fork.c clone3 — the only way to get a pidfd atomically with spawn.
reference/linux/include/uapi/linux/pidfd.h PIDFD_* flags.

Man pages

man 2 pidfd_open, man 2 pidfd_send_signal, man 2 pidfd_getfd, man 2 clone3.

Rust FFI via libc

libc doesn't have direct wrappers for every pidfd syscall. Use libc::syscall with the numeric id:

use libc::{syscall, SYS_pidfd_open, SYS_pidfd_send_signal, SYS_pidfd_getfd};
use libc::{SYS_clone3, clone_args};       // clone3 also goes via syscall(SYS_clone3, ...)
use libc::{PIDFD_NONBLOCK, PIDFD_THREAD}; // Linux 5.10+

Direct-syscall example

unsafe {
    // Open a pidfd for an already-running child (race-prone: the child could
    // have exited and the PID been reused before this call — fine for the
    // self-pid, risky for arbitrary children)
    let pidfd = libc::syscall(libc::SYS_pidfd_open, child_pid, 0);
    if pidfd < 0 { return Err(io::Error::last_os_error()); }

    // Register on epoll. EPOLLIN fires exactly once, when the process exits.
    let mut ev = libc::epoll_event {
        events: libc::EPOLLIN as u32,
        u64:    child_pid as u64,     // your correlation key
    };
    libc::epoll_ctl(epfd, libc::EPOLL_CTL_ADD, pidfd as i32, &mut ev);

    // On the readiness event, waitid collects the exit status
    let mut info: libc::siginfo_t = std::mem::zeroed();
    libc::waitid(libc::P_PIDFD, pidfd as u32, &mut info, libc::WEXITED);

    // Send a signal via the fd — no PID race
    libc::syscall(libc::SYS_pidfd_send_signal, pidfd, libc::SIGTERM, std::ptr::null::<libc::siginfo_t>(), 0);

    libc::close(pidfd as i32);
}

For race-free child spawn, use clone3(CLONE_PIDFD):

let mut pidfd: i32 = -1;
let args = libc::clone_args {
    flags:    libc::CLONE_PIDFD as u64,
    pidfd:    &mut pidfd as *mut _ as u64,
    // ... stack, tls, etc.
    ..std::mem::zeroed()
};
let child = libc::syscall(libc::SYS_clone3, &args, std::mem::size_of::<libc::clone_args>());

Key flags

Flag Meaning
PIDFD_NONBLOCK Non-blocking reads; combine with epoll. Linux 5.10+.
PIDFD_THREAD Open a pidfd for a TID, not just a PID. Rarely needed.
CLONE_PIDFD Passed to clone3 — kernel stores the new pidfd at args.pidfd. The atomic way to get a pidfd without a race window.

Gotchas

  • Kernel version matters. pidfd_open is 5.3+; PIDFD_NONBLOCK is 5.10+; pidfd_getfd (steal an fd from another process) is 5.6+. Check your target range.
  • PID reuse race on manual pidfd_open. If the child exited and something else was spawned with the same PID between fork and pidfd_open, you hold a pidfd for the wrong process. clone3(CLONE_PIDFD) eliminates the window; for arbitrary external processes, pidfd_open is best-effort.
  • epoll fires once per exit. The pidfd stays readable forever after, which is sometimes useful (always-ready means always-wake-me), sometimes annoying (you must EPOLL_CTL_DEL or the loop spins).
  • pidfd_send_signal refuses to signal the init process (pid == 1). Not a concern unless running as PID 1 in a container.
  • Permissions. You can only pidfd-open a child of yours, or a process in the same session, or with CAP_KILL.

Used by

Not used in phases 02–08. Future supervisor work: the wo build subprocess, a hypothetical wo dev hot-reload supervisor, or a sharded engine's child monitoring — all would register child pidfds on the existing event loop.

v1 port source

None. V1 doesn't spawn processes.