writeonce/docs/plan/exploration/linux/10-pidfd.md
shoney.arickathil d7304f23ec docs: status board at docs/00-status.md; gap-closure spec applied; recover lost doc
- Board renamed docs/plan/00-kanban.md -> docs/00-status.md and rebuilt: ▶ NEXT
  PLAN pointer (iteration 4 — emitter, corpus, `woc build`) then six buckets —
  stories, in progress, done, pending, discarded, learnings. It covered only the
  Rust runtime before, so the whole OOP track was invisible. All 16 inbound refs
  repointed; `Kanban:` banners renamed to `Status:`.
- New discarded.md (settled rejections with reasons: inheritance, `abstract`,
  Money/SKU/Float, Dynamic/cast/macro/extern, AOT-to-C, Menhir, shared engine
  state) and learnings.md (plumbed≠enforced, vacuous goldens, exit-0-wrong-
  output, malloc-path ASan trick, deferred checks that never reach the VM).
- RECOVERED docs/plan/exploration/blue-green-vm/00-vision.md — gone from disk,
  never committed (gitignored path), cited by five docs incl. principle 12.
  Root cause was broader: all seven forward-roadmap plans in
  docs/superpowers/plans/ were untracked and ignored, on one disk only. Dropped
  the docs ignore rules with a do-not-re-add note; added __pycache__/*.pyc.
- Repaired broken links across docs/, 270 -> 36: fixes a regression from the
  earlier reference/ -> .dev/reference/ move (relative paths at ../../ and
  deeper were skipped), plus depth and reorg drift. The 36 residual point at
  content that does not exist and need decisions, not paths.
- New spec docs/superpowers/specs/2026-08-10-logwatcher-gap-closure-design.md,
  applied: `and`/`or` verdict row; Part 3 gains `env` (six modules), swaps
  time.mono for iso/local, adds 22 bare core builtins; throw/time.mono/is cut
  (0 uses in the sample). Plan 8: Task 2 gains and/or, Task 5 drops throw,
  abstract+`is` task deleted, 8/9 renumber to 7/8. Plan 9 gains core builtins.
  Plan 10 gains the 307 -> 0 diagnostic gate. WO-E205 re-filed unreachable-by-
  design. types.ml header drops its false satisfaction-set claim. 00-code-
  review.md reduced to a stub — its rival Phase 1-4 roadmap retired.
2026-08-10 23:42:26 +02:00

4.6 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.