diff --git a/mk/tests.mk b/mk/tests.mk index 518eb7b0..392750c6 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -33,7 +33,7 @@ ELFUSE_HOST_NOFILE_MIN ?= $(shell bash "$(CURDIR)/tests/test-config.sh" --host-n test-sysroot-dotdot test-sysroot-openat2-walk \ test-sysroot-inotify-names test-sysroot-exec-names \ test-sysroot-interp-fallback test-sysroot-interp-cased \ - test-sysroot-absock-names test-absock-cleanup \ + test-sysroot-absock-names test-absock-cleanup test-registry-stale-pid \ test-linkat-symlink-fallback test-casefold-host \ test-casefold-walk-host test-absock-names-host \ test-wakeup-pipe-host test-guest-env-host \ @@ -340,6 +340,7 @@ check: $(ELFUSE_BIN) $(TEST_DEPS) check-syscall-coverage check-eintr-contract ch $(call run-lane,test-sysroot-interp-cased,PT_INTERP through an escaped path) $(call run-lane,test-sysroot-absock-names,pathname sockets across the escape boundary) $(call run-lane,test-absock-cleanup,absock namespace lifecycle) + $(call run-lane,test-registry-stale-pid,stale registry record on a reused host pid) $(call run-lane,test-sysroot-root,sysroot mounted at /) $(call run-lane,test-nosysroot-literal-names,literal names without a sysroot) $(call run-lane,test-sysroot-outside-names,literal names outside the sysroot) @@ -726,6 +727,39 @@ test-absock-cleanup: $(ELFUSE_BIN) $(BUILD_DIR)/test-absock-cleanup fi; \ $(ASSERT_NO_ABSOCK_LEAK) +# An exited member's registry record outlives it, and macOS can hand its host +# pid to another elfuse process. The recipe plants such a record, host pid of +# a live unrelated elfuse run with a start time it does not have, and the +# family's kill(99, 0) must still fail with ESRCH. +## registry ignores a record whose host pid was reused +test-registry-stale-pid: $(ELFUSE_BIN) $(BUILD_DIR)/test-registry-stale-pid + @tmp=$$(mktemp -d); xpid=; fpid=; \ + trap 'kill $$xpid $$fpid 2>/dev/null; rm -rf "$$tmp"' EXIT; \ + fail() { printf "FAIL: %s\n" "$$1"; exit 1; }; \ + printf " %-30s " "stale record on reused pid"; \ + mkfifo "$$tmp/go"; \ + $(ELFUSE_BIN) $(BUILD_DIR)/test-registry-stale-pid hold & \ + xpid=$$!; \ + $(ELFUSE_BIN) $(BUILD_DIR)/test-registry-stale-pid \ + < "$$tmp/go" > "$$tmp/out" & \ + fpid=$$!; \ + exec 4> "$$tmp/go"; \ + for i in $$(seq 1 50); do \ + grep -q READY "$$tmp/out" && break; \ + sleep 0.1; \ + done; \ + grep -q READY "$$tmp/out" || fail "family never reported READY"; \ + reg="$$(getconf DARWIN_USER_TEMP_DIR)elfuse-procs-$$fpid"; \ + [ -f "$$reg" ] || fail "no registry at $$reg"; \ + printf '%s 99 1 1\n' "$$xpid" >> "$$reg" || fail "cannot append to $$reg"; \ + echo go >&4; \ + exec 4>&-; \ + wait $$fpid; \ + kill -0 "$$xpid" 2>/dev/null || fail "holder exited before the lookup"; \ + verdict=$$(sed -n 's/^STALE=//p' "$$tmp/out"); \ + [ "$$verdict" = esrch ] || fail "kill(99, 0) $${verdict:-unreported}"; \ + printf "OK\n" + # PT_INTERP names the loader by the guest's spelling, and a rootfs may ship # it somewhere other than where the binary asks (store-style paths). The # interp resolver falls back to /lib/ when the asked-for path does diff --git a/src/syscall/proc.c b/src/syscall/proc.c index fe8d1946..315d1503 100644 --- a/src/syscall/proc.c +++ b/src/syscall/proc.c @@ -1157,11 +1157,14 @@ static void proc_registry_reset_if_owner(const char *path) unlink(path); } -/* One live member of a process group registry. */ +/* One live member of a process group registry. start_us is the host process's + * start time: a host pid alone matches whatever process macOS hands it to next. + */ typedef struct { pid_t host_pid; int64_t guest_pid; int64_t pgid; + uint64_t start_us; } registry_entry_t; #define REGISTRY_MAX_ENTRIES 4096 @@ -1178,10 +1181,10 @@ static int flock_retry(int fd, int op) /* Read @fd from its current offset and invoke @cb once per newline-terminated * record, passing a NUL-terminated copy. Records must fit in 159 bytes; both - * the registry ("hostpid guestpid pgid") and signal/control transport records - * use bounded numeric lines. Overlong records and an unterminated trailing - * token are dropped -- every writer appends a whole record under an exclusive - * lock, so a partial line only appears after a crash mid-write. + * the registry ("hostpid guestpid pgid startus") and signal/control transport + * records use bounded numeric lines. Overlong records and an unterminated + * trailing token are dropped -- every writer appends a whole record under an + * exclusive lock, so a partial line only appears after a crash mid-write. */ static void for_each_record(int fd, void (*cb)(char *rec, void *ctx), void *ctx) { @@ -1217,19 +1220,37 @@ typedef struct { bool truncated; } registry_parse_ctx_t; -/* Upsert one "hostpid guestpid pgid" record, keeping the latest guest_pid/pgid - * per LIVE host pid. Dead, malformed, and out-of-range records are dropped. +/* Start time of host process @pid in microseconds. + * + * Returns false once @pid has exited. + */ +static bool host_start_us(pid_t pid, uint64_t *out) +{ + struct proc_bsdinfo info; + if (proc_pidinfo(pid, PROC_PIDTBSDINFO, 0, &info, sizeof(info)) != + (int) sizeof(info)) + return false; + *out = info.pbi_start_tvsec * 1000000ULL + info.pbi_start_tvusec; + return true; +} + +/* Upsert one "hostpid guestpid pgid startus" record, keeping the latest + * guest_pid/pgid per LIVE host pid. A record whose start time differs from the + * running process's names an exited member whose host pid was reused, so it is + * dropped along with dead, malformed, and out-of-range records. */ static void registry_parse_cb(char *rec, void *vctx) { registry_parse_ctx_t *c = vctx; long hp; long long gp, pg; - if (sscanf(rec, "%ld %lld %lld", &hp, &gp, &pg) != 3) + unsigned long long st; + if (sscanf(rec, "%ld %lld %lld %llu", &hp, &gp, &pg, &st) != 4) return; if (hp <= 0 || hp > INT_MAX || pg < 0 || pg > INT_MAX) return; - if (kill((pid_t) hp, 0) != 0) + uint64_t live_us; + if (!host_start_us((pid_t) hp, &live_us) || live_us != (uint64_t) st) return; int idx = -1; for (int k = 0; k < c->n; k++) @@ -1244,6 +1265,7 @@ static void registry_parse_cb(char *rec, void *vctx) } idx = c->n++; c->entries[idx].host_pid = (pid_t) hp; + c->entries[idx].start_us = live_us; } c->entries[idx].guest_pid = (int64_t) gp; c->entries[idx].pgid = (int64_t) pg; @@ -1328,7 +1350,8 @@ static void proc_registry_publish(pid_t host_pid, idx = i; break; } - if (idx < 0) { + uint64_t start_us; + if (idx < 0 && host_start_us(host_pid, &start_us)) { if (n == REGISTRY_MAX_ENTRIES) /* No slot for a new live member: group signals (kill(-1), @@ -1342,6 +1365,7 @@ static void proc_registry_publish(pid_t host_pid, else { idx = n++; entries[idx].host_pid = host_pid; + entries[idx].start_us = start_us; } } if (idx >= 0) { @@ -1351,11 +1375,12 @@ static void proc_registry_publish(pid_t host_pid, if (ftruncate(fd, 0) == 0 && lseek(fd, 0, SEEK_SET) == 0) { for (int i = 0; i < n; i++) { - char lineb[64]; - int len = snprintf(lineb, sizeof(lineb), "%ld %lld %lld\n", + char lineb[96]; + int len = snprintf(lineb, sizeof(lineb), "%ld %lld %lld %llu\n", (long) entries[i].host_pid, (long long) entries[i].guest_pid, - (long long) entries[i].pgid); + (long long) entries[i].pgid, + (unsigned long long) entries[i].start_us); if (len > 0 && (size_t) len < sizeof(lineb) && write_all(fd, lineb, (size_t) len) < 0) break; @@ -1708,9 +1733,14 @@ int proc_set_child_pgid(int64_t guest_pid_val, int64_t pgid) return ret; } -int proc_get_namespace_targets(proc_signal_target_t *out, - int max, - int64_t pgid_filter) +/* Shared body for the group/broadcast collector and the single-pid lookup. + * guest_filter of 0 accepts every member; a positive value stops at the one + * member carrying that guest pid. + */ +static int registry_collect(proc_signal_target_t *out, + int max, + int64_t pgid_filter, + int64_t guest_filter) { /* No republish here: every group change already publishes (fork, setpgid, * setsid), and this reader excludes its own entry anyway. @@ -1749,6 +1779,8 @@ int proc_get_namespace_targets(proc_signal_target_t *out, continue; if (pgid_filter != PROC_PGID_ANY && entries[i].pgid != pgid_filter) continue; + if (guest_filter > 0 && entries[i].guest_pid != guest_filter) + continue; char ppath[PROC_PIDPATHINFO_MAXSIZE]; int plen = proc_pidpath(entries[i].host_pid, ppath, sizeof(ppath)); if (plen != our_len || memcmp(ppath, our_path, (size_t) our_len)) @@ -1760,6 +1792,21 @@ int proc_get_namespace_targets(proc_signal_target_t *out, return count; } +int proc_get_namespace_targets(proc_signal_target_t *out, + int max, + int64_t pgid_filter) +{ + return registry_collect(out, max, pgid_filter, 0); +} + +pid_t proc_namespace_host_pid(int64_t guest_pid) +{ + proc_signal_target_t target; + return registry_collect(&target, 1, PROC_PGID_ANY, guest_pid) > 0 + ? target.host_pid + : -1; +} + 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 f71663ad..309e5076 100644 --- a/src/syscall/proc.h +++ b/src/syscall/proc.h @@ -495,6 +495,16 @@ int proc_get_namespace_targets(proc_signal_target_t *out, int max, int64_t pgid_filter); +/* Resolve one guest pid to its host pid through the same fork-family registry + * proc_get_namespace_targets reads. The child table only holds descendants, so + * this is what lets a process signal a relative that is not its own child (its + * parent, most commonly). + * + * Returns the host pid, or -1 when the registry holds no live member with that + * guest pid. + */ +pid_t proc_namespace_host_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 12da9191..2f4cf5fc 100644 --- a/src/syscall/syscall.c +++ b/src/syscall/syscall.c @@ -1034,6 +1034,17 @@ 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, @@ -1083,7 +1094,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 = proc_guest_to_host_pid((int64_t) pid); + pid_t hpid = kill_resolve_host_pid((int64_t) pid); if (hpid > 0) r = (kill(hpid, 0) == 0) ? 0 : -LINUX_ESRCH; } @@ -1148,7 +1159,7 @@ static int64_t sc_kill(guest_t *g, signal_queue(sig); return 0; } - pid_t hpid = proc_guest_to_host_pid((int64_t) pid); + pid_t hpid = kill_resolve_host_pid((int64_t) pid); if (hpid > 0) return (proc_send_guest_signal(hpid, (int64_t) pid, sig) == 0) ? 0 diff --git a/tests/test-kill-parent.c b/tests/test-kill-parent.c new file mode 100644 index 00000000..83b628fd --- /dev/null +++ b/tests/test-kill-parent.c @@ -0,0 +1,74 @@ +/* + * Test kill(pid, sig) aimed at the caller's parent + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * A forked child probes its parent with kill(getppid(), 0) and then signals it + * with SIGUSR1. The parent is not in the child's own descendant table, so both + * calls exercise the single-pid lookup through the fork-family registry. + */ + +#include +#include +#include +#include +#include +#include +#include + +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; +} + +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 pid = fork(); + if (pid < 0) + return 1; + if (pid == 0) { + if (kill(getppid(), 0) != 0) + _exit(1); + if (kill(getppid(), SIGUSR1) != 0) + _exit(2); + _exit(0); + } + + if (!wait_flag(2000)) { + fprintf(stderr, "FAIL: child kill(getppid(), SIGUSR1) not delivered\n"); + failed++; + } + int status = 0; + if (waitpid(pid, &status, 0) != pid || !WIFEXITED(status)) { + fprintf(stderr, "FAIL: child did not exit cleanly\n"); + failed++; + } else if (WEXITSTATUS(status) == 1) { + fprintf(stderr, "FAIL: kill(getppid(), 0) failed in child\n"); + failed++; + } else if (WEXITSTATUS(status) == 2) { + fprintf(stderr, "FAIL: kill(getppid(), SIGUSR1) failed in child\n"); + failed++; + } + + printf("%s: %d failed\n", failed == 0 ? "PASS" : "FAIL", failed); + return failed == 0 ? 0 : 1; +} diff --git a/tests/test-matrix.sh b/tests/test-matrix.sh index d35ca4e9..14aeadc4 100755 --- a/tests/test-matrix.sh +++ b/tests/test-matrix.sh @@ -739,6 +739,8 @@ run_unit_tests() "$bindir/test-kill-broadcast" test_check "$runner" "test-kill-pgroup" "0 failed" \ "$bindir/test-kill-pgroup" + test_check "$runner" "test-kill-parent" "0 failed" \ + "$bindir/test-kill-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-registry-stale-pid.c b/tests/test-registry-stale-pid.c new file mode 100644 index 00000000..37577fb0 --- /dev/null +++ b/tests/test-registry-stale-pid.c @@ -0,0 +1,70 @@ +/* + * Stale fork-family registry record on a reused host pid + * + * Copyright 2026 elfuse contributors + * SPDX-License-Identifier: Apache-2.0 + * + * The test-registry-stale-pid recipe plants a record for guest pid 99 that + * carries the host pid of an unrelated live elfuse process and a start time + * that process does not have. kill(99, 0) must then fail with ESRCH, as it does + * on Linux for a pid that no longer exists. + * + * Modes: + * hold block until killed (the unrelated process) + * default fork a child so the family registry exists, print READY, wait + * for a line on stdin, then print STALE=esrch or STALE= + */ + +#include +#include +#include +#include +#include +#include + +int main(int argc, char **argv) +{ + char buf[16]; + if (argc > 1 && strcmp(argv[1], "hold") == 0) { + pause(); + return 0; + } + + /* The child reports on @ready once it runs guest code, which is after its + * registry record is published, and exits when @release closes. + */ + int ready[2], release[2]; + if (pipe(ready) != 0 || pipe(release) != 0) + return 1; + pid_t pid = fork(); + if (pid < 0) + return 1; + if (pid == 0) { + close(release[1]); + if (write(ready[1], "R", 1) != 1) + _exit(1); + while (read(release[0], buf, 1) > 0) + ; + _exit(0); + } + close(ready[1]); + close(release[0]); + if (read(ready[0], buf, 1) != 1) + return 1; + + printf("READY\n"); + fflush(stdout); + if (read(0, buf, sizeof(buf)) <= 0) + return 1; + + errno = 0; + int r = kill(99, 0); + if (r == -1 && errno == ESRCH) + printf("STALE=esrch\n"); + else + printf("STALE=%s\n", r == 0 ? "found" : strerror(errno)); + + close(release[1]); + waitpid(pid, NULL, 0); + return 0; +}