From 94067f8ea7b25e4acd308917b633d95b865c88a1 Mon Sep 17 00:00:00 2001 From: xalestar Date: Mon, 21 Sep 2026 10:41:54 +0800 Subject: [PATCH 1/4] Let a pidfd name any relative kill can name Pid-directed kill resolves a guest pid through the fork-family registry, so a guest can signal its parent. The three pidfd call sites still went through the child table alone, which only holds descendants, so kill(getppid(), 0) succeeded while pidfd_open(getppid()) returned ESRCH and a pidfd on any non-descendant relative stayed unreachable. Linux draws no such line. Lift the two-step lookup out of sc_kill into proc_resolve_guest_pid and route pidfd_create, sys_pidfd_open and sys_pidfd_send_signal through it, so every caller that turns a guest-named pid into a host pid reaches the same set of processes. Routing pidfd_create matters beyond the open: it is what arms the exit monitor, so without it a pidfd on a sibling opens but never becomes readable. test-pidfd-parent covers both halves. A child opens a pidfd on its parent and signals through it, and a second child opens a pidfd on its sibling and polls it for the sibling's exit; pipe handshakes order the open before the exit so a slow fork cannot turn the test red. It fails on the parent commit, and reverting any one of the three call sites on its own also fails it. A qemu-aarch64 run confirms the Linux behavior it pins. --- src/syscall/proc-pidfd.c | 6 +- src/syscall/proc.c | 6 ++ src/syscall/proc.h | 11 +++ src/syscall/syscall.c | 15 +-- tests/test-matrix.sh | 2 + tests/test-pidfd-parent.c | 191 ++++++++++++++++++++++++++++++++++++++ 6 files changed, 215 insertions(+), 16 deletions(-) create mode 100644 tests/test-pidfd-parent.c diff --git a/src/syscall/proc-pidfd.c b/src/syscall/proc-pidfd.c index 9b882ce8..8b4f8036 100644 --- a/src/syscall/proc-pidfd.c +++ b/src/syscall/proc-pidfd.c @@ -141,7 +141,7 @@ int pidfd_create(guest_t *g, int64_t target_pid) entry->write_end = pfd[1]; pthread_mutex_unlock(&pidfd_lock); - pid_t host_pid = proc_guest_to_host_pid(target_pid); + pid_t host_pid = proc_resolve_guest_pid(target_pid); if (host_pid > 0) { bool monitor_ok = false; int64_t *ctx = malloc(2 * sizeof(int64_t)); @@ -207,7 +207,7 @@ int64_t sys_pidfd_open(guest_t *g, int64_t pid, unsigned int flags) if (pid == proc_get_pid()) return pidfd_create(g, pid); - if (proc_guest_to_host_pid(pid) > 0) + if (proc_resolve_guest_pid(pid) > 0) return pidfd_create(g, pid); return -LINUX_ESRCH; @@ -238,7 +238,7 @@ int64_t sys_pidfd_send_signal(guest_t *g, return 0; } - pid_t host_pid = proc_guest_to_host_pid(pid); + pid_t host_pid = proc_resolve_guest_pid(pid); if (host_pid > 0) { if (sig == 0) { if (kill(host_pid, 0) < 0) diff --git a/src/syscall/proc.c b/src/syscall/proc.c index a6bb2479..ceab7de9 100644 --- a/src/syscall/proc.c +++ b/src/syscall/proc.c @@ -1814,6 +1814,12 @@ pid_t proc_namespace_host_pid(int64_t guest_pid) : -1; } +pid_t proc_resolve_guest_pid(int64_t guest_pid) +{ + pid_t host_pid = proc_guest_to_host_pid(guest_pid); + return host_pid > 0 ? host_pid : proc_namespace_host_pid(guest_pid); +} + int64_t proc_host_to_guest_pid(pid_t host_pid) { pthread_mutex_lock(&pid_lock); diff --git a/src/syscall/proc.h b/src/syscall/proc.h index 309e5076..7ecb0787 100644 --- a/src/syscall/proc.h +++ b/src/syscall/proc.h @@ -505,6 +505,17 @@ int proc_get_namespace_targets(proc_signal_target_t *out, */ pid_t proc_namespace_host_pid(int64_t guest_pid); +/* Resolve one guest pid the way pid-directed signalling does: the child table + * answers for descendants, the fork-family registry for every other relative. + * Every caller that turns a guest pid the guest named into a host pid -- kill, + * pidfd_open, pidfd_send_signal -- goes through here, so they all reach the + * same set of processes. + * + * Returns the host pid, or -1 when no live fork-family member carries that + * guest pid. + */ +pid_t proc_resolve_guest_pid(int64_t guest_pid); + /* Publish the caller's current guest pid/pgid to the fork-family registry. */ void proc_registry_publish_self(void); diff --git a/src/syscall/syscall.c b/src/syscall/syscall.c index 2f4cf5fc..864db593 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -1034,17 +1034,6 @@ static int kill_deliver_targets(const proc_signal_target_t *targets, return delivered; } -/* Resolve a guest pid for kill(2). The child table answers for descendants. - * Every other member of the fork family, the caller's own parent above all, - * exists only in the namespace registry, the same source the group and - * broadcast forms already read. - */ -static pid_t kill_resolve_host_pid(int64_t gpid) -{ - pid_t hpid = proc_guest_to_host_pid(gpid); - return hpid > 0 ? hpid : proc_namespace_host_pid(gpid); -} - static int64_t sc_kill(guest_t *g, uint64_t x0, uint64_t x1, @@ -1094,7 +1083,7 @@ static int64_t sc_kill(guest_t *g, } int64_t r = (pid == (int) our_pid) ? 0 : -LINUX_ESRCH; if (r == -LINUX_ESRCH) { - pid_t hpid = kill_resolve_host_pid((int64_t) pid); + pid_t hpid = proc_resolve_guest_pid((int64_t) pid); if (hpid > 0) r = (kill(hpid, 0) == 0) ? 0 : -LINUX_ESRCH; } @@ -1159,7 +1148,7 @@ static int64_t sc_kill(guest_t *g, signal_queue(sig); return 0; } - pid_t hpid = kill_resolve_host_pid((int64_t) pid); + pid_t hpid = proc_resolve_guest_pid((int64_t) pid); if (hpid > 0) return (proc_send_guest_signal(hpid, (int64_t) pid, sig) == 0) ? 0 diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index 14aeadc4..d4e0d58a 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -741,6 +741,8 @@ run_unit_tests() "$bindir/test-kill-pgroup" test_check "$runner" "test-kill-parent" "0 failed" \ "$bindir/test-kill-parent" + test_check "$runner" "test-pidfd-parent" "0 failed" \ + "$bindir/test-pidfd-parent" test_rc "$runner" "test-sigio" 0 "$bindir/test-sigio" test_rc "$runner" "test-fault-signal-mt" 0 "$bindir/test-fault-signal-mt" test_rc "$runner" "test-exit-group-worker" 0 "$bindir/test-exit-group-worker" diff --git a/tests/test-pidfd-parent.c b/tests/test-pidfd-parent.c new file mode 100644 index 00000000..65b1e5e5 --- /dev/null +++ b/tests/test-pidfd-parent.c @@ -0,0 +1,191 @@ +/* + * Test pidfd_open/pidfd_send_signal aimed at relatives that are not descendants + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Two relatives outside the caller's descendant table. A forked child opens a + * pidfd on its parent and signals through it, and a second child opens a pidfd + * on its sibling and polls it for the sibling's exit. Both go through the same + * fork-family lookup kill(getppid(), sig) uses. On Linux they succeed; they + * must succeed here too. + * + * The sibling half is ordered by two pipe handshakes rather than by sleeps: the + * sibling exits only once the watcher reports its pidfd open, so a slow fork + * cannot turn the test red. + */ + +#include +#include +#include +#include +#include +#include +#include +#include +#include + +#include "raw-syscall.h" + +#define __NR_pidfd_open_nr 434 +#define __NR_pidfd_send_signal_nr 424 + +static volatile sig_atomic_t got_usr1 = 0; + +static void usr1_handler(int sig) +{ + (void) sig; + got_usr1 = 1; +} + +static bool wait_flag(int max_ms) +{ + for (int i = 0; i < max_ms && !got_usr1; i++) { + struct timespec ts = {0, 1000000}; /* 1 ms */ + nanosleep(&ts, NULL); + } + return got_usr1 != 0; +} + +/* Child 1: the parent is not in this child's descendant table, so both the + * probe and the signal resolve through the fork-family registry. + */ +static int signal_parent(void) +{ + if (kill(getppid(), 0) != 0) + return 1; + long pfd = raw_syscall2(__NR_pidfd_open_nr, (long) getppid(), 0); + if (pfd < 0) + return 2; + if (raw_syscall4(__NR_pidfd_send_signal_nr, pfd, 0, 0, 0) != 0) + return 3; + if (raw_syscall4(__NR_pidfd_send_signal_nr, pfd, SIGUSR1, 0, 0) != 0) + return 4; + return 0; +} + +/* Child 2: open a pidfd on the sibling whose pid arrives on @pid_in, report + * that it is open on @ready_out, then wait for the sibling's exit to make the + * pidfd readable. The sibling is neither ancestor nor descendant here. + */ +static int watch_sibling(int pid_in, int ready_out) +{ + int64_t sibling = 0; + if (read(pid_in, &sibling, sizeof(sibling)) != (ssize_t) sizeof(sibling)) + return 1; + long pfd = raw_syscall2(__NR_pidfd_open_nr, (long) sibling, 0); + if (pfd < 0) + return 2; + uint8_t byte = 0; + if (write(ready_out, &byte, 1) != 1) + return 1; + struct pollfd pv = {.fd = (int) pfd, .events = POLLIN}; + int n = poll(&pv, 1, 10000); + return n == 1 && (pv.revents & POLLIN) ? 0 : 3; +} + +/* Map a child exit status onto one line of output. Returns 1 if it failed. */ +static int report(const char *what, pid_t pid, const char *const *reasons) +{ + int status = 0; + if (waitpid(pid, &status, 0) != pid || !WIFEXITED(status)) { + fprintf(stderr, "FAIL: %s did not exit cleanly\n", what); + return 1; + } + int code = WEXITSTATUS(status); + if (code == 0) + return 0; + fprintf(stderr, "FAIL: %s: %s\n", what, reasons[code - 1]); + return 1; +} + +int main(void) +{ + int failed = 0; + struct sigaction sa; + memset(&sa, 0, sizeof(sa)); + sa.sa_handler = usr1_handler; + sigaction(SIGUSR1, &sa, NULL); + + pid_t signaller = fork(); + if (signaller < 0) + return 1; + if (signaller == 0) + _exit(signal_parent()); + + if (!wait_flag(5000)) { + fprintf(stderr, + "FAIL: SIGUSR1 through the parent pidfd not delivered\n"); + failed++; + } + static const char *const signal_reasons[] = { + "kill(getppid(), 0) failed", + "pidfd_open(getppid()) failed", + "pidfd_send_signal(pfd, 0) failed", + "pidfd_send_signal(pfd, SIGUSR1) failed", + }; + failed += report("parent signaller", signaller, signal_reasons); + + int pidp[2], readyp[2], gop[2]; + if (pipe(pidp) < 0 || pipe(readyp) < 0 || pipe(gop) < 0) + return 1; + + pid_t watcher = fork(); + if (watcher < 0) + return 1; + if (watcher == 0) { + close(pidp[1]); + close(readyp[0]); + close(gop[0]); + close(gop[1]); + _exit(watch_sibling(pidp[0], readyp[1])); + } + + pid_t sibling = fork(); + if (sibling < 0) + return 1; + if (sibling == 0) { + close(pidp[0]); + close(pidp[1]); + close(readyp[0]); + close(readyp[1]); + close(gop[1]); + + /* Exit only once the parent closes the other end, which it does after + * the watcher has its pidfd. + */ + uint8_t byte = 0; + (void) read(gop[0], &byte, 1); + _exit(0); + } + + close(pidp[0]); + close(readyp[1]); + close(gop[0]); + + int64_t sibling_pid = sibling; + if (write(pidp[1], &sibling_pid, sizeof(sibling_pid)) != + (ssize_t) sizeof(sibling_pid)) { + fprintf(stderr, + "FAIL: could not hand the sibling pid to the watcher\n"); + failed++; + } + close(pidp[1]); + + uint8_t ready = 0; + if (read(readyp[0], &ready, 1) != 1) + fprintf(stderr, "note: watcher never reported its pidfd open\n"); + close(readyp[0]); + close(gop[1]); + + waitpid(sibling, NULL, 0); + static const char *const watch_reasons[] = { + "pipe handshake with the parent failed", + "pidfd_open(sibling) failed", + "sibling pidfd never reported the exit", + }; + failed += report("sibling watcher", watcher, watch_reasons); + + printf("%s: %d failed\n", failed == 0 ? "PASS" : "FAIL", failed); + return failed == 0 ? 0 : 1; +} From aff1cc533b9443f71e140b7a72457280ece3ffa1 Mon Sep 17 00:00:00 2001 From: xalestar Date: Mon, 21 Sep 2026 16:11:02 +0800 Subject: [PATCH 2/4] Complete a pidfd whose target is already gone pidfd_create resolves the target a second time, after the caller has already resolved it. A non-descendant that exits in that window leaves the new pidfd with no monitor behind it, and nothing else ever marks it readable, so a guest polling for the target's exit waits forever. Linux returns a pidfd that reads as exited instead. Mark the fd exited whenever no monitor was armed, which covers both the lost target and a monitor thread that failed to start. A pidfd on the caller itself is the one target that legitimately has no monitor: a guest cannot observe its own exit through it. It now returns before the resolve rather than falling into the completion path, and test-pidfd-parent pins that a self pidfd does not poll readable. --- src/syscall/proc-pidfd.c | 18 +++++++++++++++--- tests/test-pidfd-parent.c | 19 +++++++++++++++++++ 2 files changed, 34 insertions(+), 3 deletions(-) diff --git a/src/syscall/proc-pidfd.c b/src/syscall/proc-pidfd.c index 8b4f8036..bc09df63 100644 --- a/src/syscall/proc-pidfd.c +++ b/src/syscall/proc-pidfd.c @@ -141,9 +141,15 @@ int pidfd_create(guest_t *g, int64_t target_pid) entry->write_end = pfd[1]; pthread_mutex_unlock(&pidfd_lock); + /* A pidfd on this process has nothing to watch: a guest cannot observe its + * own exit through it, so it stays unreadable for as long as it is open. + */ + if (target_pid == proc_get_pid()) + return gfd; + + bool monitor_ok = false; pid_t host_pid = proc_resolve_guest_pid(target_pid); if (host_pid > 0) { - bool monitor_ok = false; int64_t *ctx = malloc(2 * sizeof(int64_t)); if (ctx) { ctx[0] = target_pid; @@ -164,10 +170,16 @@ int pidfd_create(guest_t *g, int64_t target_pid) free(ctx); } } - if (!monitor_ok) - proc_pidfd_notify_exit(target_pid); } + /* No monitor means no one will ever mark this fd readable. A target that no + * longer resolves exited between the caller's lookup and this one, and + * Linux hands out a pidfd that reads as exited rather than one that waits + * forever, so complete it here. + */ + if (!monitor_ok) + proc_pidfd_notify_exit(target_pid); + return gfd; } diff --git a/tests/test-pidfd-parent.c b/tests/test-pidfd-parent.c index 65b1e5e5..0ab55f9e 100644 --- a/tests/test-pidfd-parent.c +++ b/tests/test-pidfd-parent.c @@ -10,6 +10,9 @@ * fork-family lookup kill(getppid(), sig) uses. On Linux they succeed; they * must succeed here too. * + * A pidfd on the caller itself is checked too: it is the one target with no + * monitor behind it, so it must stay unreadable rather than read as exited. + * * The sibling half is ordered by two pipe handshakes rather than by sleeps: the * sibling exits only once the watcher reports its pidfd open, so a slow fork * cannot turn the test red. @@ -126,6 +129,22 @@ int main(void) }; failed += report("parent signaller", signaller, signal_reasons); + /* A self-pidfd has nothing to watch, and a live process is not an exited + * one, so it must not poll readable. + */ + long selfpfd = raw_syscall2(__NR_pidfd_open_nr, (long) getpid(), 0); + if (selfpfd < 0) { + fprintf(stderr, "FAIL: pidfd_open(self) failed\n"); + failed++; + } else { + struct pollfd sv = {.fd = (int) selfpfd, .events = POLLIN}; + if (poll(&sv, 1, 100) != 0) { + fprintf(stderr, "FAIL: self pidfd reports the caller exited\n"); + failed++; + } + close((int) selfpfd); + } + int pidp[2], readyp[2], gop[2]; if (pipe(pidp) < 0 || pipe(readyp) < 0 || pipe(gop) < 0) return 1; From 655d6a0c4bcf8afa6ac70abf9e4690999dd2fb01 Mon Sep 17 00:00:00 2001 From: xalestar Date: Tue, 22 Sep 2026 02:14:49 +0800 Subject: [PATCH 3/4] Complete a pidfd only if something watched it The previous commit completed any pidfd that got no monitor, which is wrong for a target that has a guest tid but no host process of its own. clone3(CLONE_PIDFD | CLONE_VM) is exactly that: sys_clone_vm allocates a thread-table slot and neither registers a child nor publishes to the registry, so the target never resolves and the guest got a pidfd that read as exited from the moment clone3 returned. Let the caller say what to watch instead of resolving a second time. pidfd_create now takes the host pid, and a host pid of 0 means the target lives in this process and has nothing to watch: it is left unreadable, as before. A target that has already exited needs no special case either, since the monitor thread finds it gone and completes the fd on the spot, so the race this closes stays closed. pidfd_open also rejects a non-positive pid with EINVAL, matching the kernel, which refuses it before looking anything up. test-pidfd-targets covers the self and EINVAL cases; test-pidfd covers the CLONE_VM one, since a CLONE_VM child resumes on a fresh stack where the clone3 return value the child branch reads is undefined on a real kernel, and that test does not run against qemu. --- src/runtime/forkipc.c | 5 +- src/syscall/proc-pidfd.c | 32 +++++++----- src/syscall/proc-pidfd.h | 7 ++- tests/test-matrix.sh | 4 +- ...st-pidfd-parent.c => test-pidfd-targets.c} | 21 ++++++-- tests/test-pidfd.c | 50 +++++++++++++++++++ 6 files changed, 99 insertions(+), 20 deletions(-) rename tests/{test-pidfd-parent.c => test-pidfd-targets.c} (87%) diff --git a/src/runtime/forkipc.c b/src/runtime/forkipc.c index 403e6485..c3ac7306 100644 --- a/src/runtime/forkipc.c +++ b/src/runtime/forkipc.c @@ -2239,7 +2239,10 @@ int64_t sys_clone3(hv_vcpu_t vcpu, * child and write the guest FD number to ca.pidfd. */ if (ret > 0 && want_pidfd && ca.pidfd != 0) { - int pfd = pidfd_create(g, ret); + /* A CLONE_VM child has no host pid of its own, so it resolves to -1 and + * its pidfd carries no monitor. + */ + int pfd = pidfd_create(g, ret, proc_resolve_guest_pid(ret)); if (pfd >= 0) { int32_t pfd32 = (int32_t) pfd; if (guest_write_small(g, ca.pidfd, &pfd32, sizeof(pfd32)) < 0) { diff --git a/src/syscall/proc-pidfd.c b/src/syscall/proc-pidfd.c index bc09df63..91f1502d 100644 --- a/src/syscall/proc-pidfd.c +++ b/src/syscall/proc-pidfd.c @@ -105,7 +105,7 @@ static void *pidfd_monitor_thread(void *arg) return NULL; } -int pidfd_create(guest_t *g, int64_t target_pid) +int pidfd_create(guest_t *g, int64_t target_pid, pid_t host_pid) { (void) g; int pfd[2]; @@ -141,15 +141,16 @@ int pidfd_create(guest_t *g, int64_t target_pid) entry->write_end = pfd[1]; pthread_mutex_unlock(&pidfd_lock); - /* A pidfd on this process has nothing to watch: a guest cannot observe its - * own exit through it, so it stays unreadable for as long as it is open. + /* host_pid <= 0 means the target lives inside this host process -- the + * caller itself, or a CLONE_VM child, which holds a guest tid but no host + * pid of its own. Neither can be watched from here, and neither has exited, + * so the fd stays unreadable rather than being completed. */ - if (target_pid == proc_get_pid()) + if (host_pid <= 0) return gfd; bool monitor_ok = false; - pid_t host_pid = proc_resolve_guest_pid(target_pid); - if (host_pid > 0) { + { int64_t *ctx = malloc(2 * sizeof(int64_t)); if (ctx) { ctx[0] = target_pid; @@ -172,10 +173,10 @@ int pidfd_create(guest_t *g, int64_t target_pid) } } - /* No monitor means no one will ever mark this fd readable. A target that no - * longer resolves exited between the caller's lookup and this one, and - * Linux hands out a pidfd that reads as exited rather than one that waits - * forever, so complete it here. + /* Nothing will ever mark this fd readable without a monitor behind it, so + * complete it rather than leave the guest polling forever. A target that + * has already exited needs no special case: the monitor thread finds it + * gone and completes the fd the same way. */ if (!monitor_ok) proc_pidfd_notify_exit(target_pid); @@ -216,11 +217,16 @@ int64_t sys_pidfd_open(guest_t *g, int64_t pid, unsigned int flags) if (flags != 0) return -LINUX_EINVAL; + /* The kernel rejects a non-positive pid before it looks anything up. */ + if (pid <= 0) + return -LINUX_EINVAL; + if (pid == proc_get_pid()) - return pidfd_create(g, pid); + return pidfd_create(g, pid, 0); - if (proc_resolve_guest_pid(pid) > 0) - return pidfd_create(g, pid); + pid_t host_pid = proc_resolve_guest_pid(pid); + if (host_pid > 0) + return pidfd_create(g, pid, host_pid); return -LINUX_ESRCH; } diff --git a/src/syscall/proc-pidfd.h b/src/syscall/proc-pidfd.h index 98ad841b..d1315a3a 100644 --- a/src/syscall/proc-pidfd.h +++ b/src/syscall/proc-pidfd.h @@ -12,7 +12,12 @@ #include "core/guest.h" void pidfd_init(void); -int pidfd_create(guest_t *g, int64_t target_pid); + +/* Create a pidfd on @target_pid. @host_pid is the host process to watch for its + * exit, or <= 0 when the target lives in this host process and so cannot be + * watched (the caller itself, or a CLONE_VM child). + */ +int pidfd_create(guest_t *g, int64_t target_pid, pid_t host_pid); void proc_pidfd_notify_exit(int64_t exited_pid); int64_t proc_pidfd_lookup_pid(int guest_fd); int64_t sys_pidfd_open(guest_t *g, int64_t pid, unsigned int flags); diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index d4e0d58a..359037dc 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -741,8 +741,8 @@ run_unit_tests() "$bindir/test-kill-pgroup" test_check "$runner" "test-kill-parent" "0 failed" \ "$bindir/test-kill-parent" - test_check "$runner" "test-pidfd-parent" "0 failed" \ - "$bindir/test-pidfd-parent" + test_check "$runner" "test-pidfd-targets" "0 failed" \ + "$bindir/test-pidfd-targets" test_rc "$runner" "test-sigio" 0 "$bindir/test-sigio" test_rc "$runner" "test-fault-signal-mt" 0 "$bindir/test-fault-signal-mt" test_rc "$runner" "test-exit-group-worker" 0 "$bindir/test-exit-group-worker" diff --git a/tests/test-pidfd-parent.c b/tests/test-pidfd-targets.c similarity index 87% rename from tests/test-pidfd-parent.c rename to tests/test-pidfd-targets.c index 0ab55f9e..8940e3d8 100644 --- a/tests/test-pidfd-parent.c +++ b/tests/test-pidfd-targets.c @@ -1,5 +1,5 @@ /* - * Test pidfd_open/pidfd_send_signal aimed at relatives that are not descendants + * Test which targets pidfd_open accepts and which of them report an exit * * Copyright 2026 elfuse contributors * SPDX-License-Identifier: Apache-2.0 @@ -10,8 +10,10 @@ * fork-family lookup kill(getppid(), sig) uses. On Linux they succeed; they * must succeed here too. * - * A pidfd on the caller itself is checked too: it is the one target with no - * monitor behind it, so it must stay unreadable rather than read as exited. + * Two targets that legitimately have no exit monitor behind them are checked + * too, because both must stay unreadable rather than read as exited: the caller + * itself, and a CLONE_VM child, which holds a guest tid but no host process of + * its own. A non-positive pid is rejected before any lookup. * * The sibling half is ordered by two pipe handshakes rather than by sleeps: the * sibling exits only once the watcher reports its pidfd open, so a slow fork @@ -33,6 +35,7 @@ #define __NR_pidfd_open_nr 434 #define __NR_pidfd_send_signal_nr 424 + static volatile sig_atomic_t got_usr1 = 0; static void usr1_handler(int sig) @@ -145,6 +148,18 @@ int main(void) close((int) selfpfd); } + /* The kernel rejects a non-positive pid outright rather than reporting it + * as a missing process. + */ + if (raw_syscall2(__NR_pidfd_open_nr, 0, 0) != -22 /* EINVAL */) { + fprintf(stderr, "FAIL: pidfd_open(0) did not return EINVAL\n"); + failed++; + } + if (raw_syscall2(__NR_pidfd_open_nr, -1, 0) != -22 /* EINVAL */) { + fprintf(stderr, "FAIL: pidfd_open(-1) did not return EINVAL\n"); + failed++; + } + int pidp[2], readyp[2], gop[2]; if (pipe(pidp) < 0 || pipe(readyp) < 0 || pipe(gop) < 0) return 1; diff --git a/tests/test-pidfd.c b/tests/test-pidfd.c index 5f3562ca..5a445a62 100644 --- a/tests/test-pidfd.c +++ b/tests/test-pidfd.c @@ -28,6 +28,8 @@ #define __NR_wait4 260 #define CLONE_PIDFD 0x00001000 +#define CLONE_VM 0x00000100 +#define __NR_nanosleep 101 #define SIGCHLD 17 int passes = 0, fails = 0; @@ -148,6 +150,54 @@ int main(void) raw_syscall4(__NR_wait4, child, 0, 0, 0); } + /* clone3(CLONE_PIDFD | CLONE_VM): the child runs inside this process and so + * has a guest tid but no host pid of its own. Nothing outside can watch it, + * but it has not exited either, so its pidfd must stay unreadable while it + * runs rather than report an exit that never happened. + * + * This case cannot be cross-checked against a real kernel: a CLONE_VM child + * resumes on the fresh stack below, where the compiler's copy of the clone3 + * return value is undefined, so the child branch is only reliable under + * elfuse. Keep the child to raw syscalls for the same reason. + */ + TEST("clone3 CLONE_PIDFD|CLONE_VM pidfd not readable while child runs"); + { + void *stack = mmap(NULL, 65536, PROT_READ | PROT_WRITE, + MAP_PRIVATE | MAP_ANONYMOUS, -1, 0); + if (stack == MAP_FAILED) { + FAIL("mmap for stack failed"); + goto done; + } + + int32_t vm_pidfd = -1; + struct clone_args ca = {0}; + ca.flags = CLONE_PIDFD | CLONE_VM; + ca.pidfd = (uint64_t) &vm_pidfd; + ca.exit_signal = SIGCHLD; + ca.stack = (uint64_t) stack; + ca.stack_size = 65536; + + long child = raw_syscall2(__NR_clone3, (long) &ca, sizeof(ca)); + if (child == 0) { + for (;;) { + uint64_t ts[2] = {5, 0}; + raw_syscall2(__NR_nanosleep, (long) ts, 0); + } + } + if (child < 0) { + FAIL("clone3 CLONE_VM failed"); + goto done; + } + + struct linux_pollfd pf = {.fd = vm_pidfd, .events = 1, .revents = 0}; + uint64_t ts[2] = {0, 100000000}; /* 100 ms */ + long pr = raw_syscall5(__NR_ppoll, (long) &pf, 1, (long) ts, 0, 0); + EXPECT_TRUE(pr == 0 && pf.revents == 0, + "pidfd reports an exit while the CLONE_VM child runs"); + + raw_syscall1(__NR_close, vm_pidfd); + } + done: SUMMARY("test-pidfd"); return fails ? 1 : 0; From dbb1f57e1faf3694970a4568443bbd9f736dd84b Mon Sep 17 00:00:00 2001 From: xalestar Date: Tue, 22 Sep 2026 02:30:04 +0800 Subject: [PATCH 4/4] Check the CLONE_VM pidfd exists before polling it ppoll skips a negative fd and leaves revents alone, and clone3 writes the pidfd only once pidfd_create succeeds. The new CLONE_VM check therefore passed whether the guest got a working pidfd or none at all: with pidfd_create forced to fail, the "stays unreadable" assertion still reported OK. Assert the fd was written before polling it, the way the CLONE_PIDFD case above already does. The same forced failure now turns the check red. --- tests/test-pidfd.c | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/tests/test-pidfd.c b/tests/test-pidfd.c index 5a445a62..89d686f2 100644 --- a/tests/test-pidfd.c +++ b/tests/test-pidfd.c @@ -189,6 +189,14 @@ int main(void) goto done; } + /* Without this, the check below passes vacuously: ppoll skips a + * negative fd and reports nothing, so an unwritten pidfd would look + * like one that correctly stays unreadable. + */ + EXPECT_TRUE(vm_pidfd >= 0, + "clone3 wrote no pidfd for the CLONE_VM " + "child"); + struct linux_pollfd pf = {.fd = vm_pidfd, .events = 1, .revents = 0}; uint64_t ts[2] = {0, 100000000}; /* 100 ms */ long pr = raw_syscall5(__NR_ppoll, (long) &pf, 1, (long) ts, 0, 0);