From 2555e69a7e80f0216dc1c89a3e167eebaf9952dc Mon Sep 17 00:00:00 2001 From: xalestar Date: Sun, 13 Sep 2026 11:35:23 +0800 Subject: [PATCH 1/4] Let kill reach a relative that is not a child A guest process could not signal its own parent. sc_kill resolved a single pid through proc_guest_to_host_pid, which reads the child table, and that table holds descendants only, so kill(getppid(), sig) found nothing and returned ESRCH where Linux delivers. The fork-family registry already holds every live member, and the group and broadcast forms of kill already read it through proc_get_namespace_targets. This gives that walk a single-pid entry point and falls back to it when the child table has no answer, so the two forms resolve from the same source. tests/test-kill-parent.c has a forked child probe its parent with kill(getppid(), 0) and then deliver SIGUSR1 to the parent's handler. Both steps fail with ESRCH without this change. --- src/syscall/proc.c | 28 +++++++++++++-- src/syscall/proc.h | 10 ++++++ src/syscall/syscall.c | 15 ++++++-- tests/test-kill-parent.c | 74 ++++++++++++++++++++++++++++++++++++++++ tests/test-matrix.sh | 2 ++ 5 files changed, 124 insertions(+), 5 deletions(-) create mode 100644 tests/test-kill-parent.c diff --git a/src/syscall/proc.c b/src/syscall/proc.c index fe8d1946..aa0ffefd 100644 --- a/src/syscall/proc.c +++ b/src/syscall/proc.c @@ -1708,9 +1708,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 +1754,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 +1767,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" From e0012bb091f6bd91d54179acefff1347c55c4d0f Mon Sep 17 00:00:00 2001 From: xalestar Date: Fri, 18 Sep 2026 14:59:22 +0800 Subject: [PATCH 2/4] Tie a registry record to its process start time A fork-family registry record outlives the member that wrote it until the next publish compacts the file, and registry_parse_cb kept any record whose host pid answered kill(pid, 0). Once macOS handed that pid to another elfuse process, the record passed both that probe and the proc_pidpath check in registry_collect, so kill(G, 0) for an exited guest pid G returned 0 instead of ESRCH. The group and broadcast forms of kill read the same records and had the same gap. Each record now carries the host process start time from proc_pidinfo, and registry_parse_cb keeps a record only while the live process at that pid has the same start time. The check runs at read time, so a member killed before it could clean up is covered too. The test-registry-stale-pid lane plants such a record and expects ESRCH; without this change kill(99, 0) finds it. --- mk/tests.mk | 38 +++++++++++++++++- src/syscall/proc.c | 51 +++++++++++++++++------- tests/test-registry-stale-pid.c | 69 +++++++++++++++++++++++++++++++++ 3 files changed, 144 insertions(+), 14 deletions(-) create mode 100644 tests/test-registry-stale-pid.c diff --git a/mk/tests.mk b/mk/tests.mk index 518eb7b0..fc356fa3 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,41 @@ 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); \ + 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; \ + printf '%s 99 1 1\n' "$$xpid" \ + >> "$$(getconf DARWIN_USER_TEMP_DIR)elfuse-procs-$$fpid"; \ + echo go >&4; \ + wait $$fpid; \ + exec 4>&-; \ + kill $$xpid; \ + wait $$xpid 2>/dev/null; \ + verdict=$$(sed -n 's/^STALE=//p' "$$tmp/out"); \ + rm -rf "$$tmp"; \ + printf " %-30s " "stale record on reused pid"; \ + if [ "$$verdict" = esrch ]; then \ + printf "OK\n"; \ + else \ + printf "FAIL: kill(99, 0) %s\n" "$${verdict:-unreported}"; \ + exit 1; \ + fi + # 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 aa0ffefd..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; diff --git a/tests/test-registry-stale-pid.c b/tests/test-registry-stale-pid.c new file mode 100644 index 00000000..d2c4994a --- /dev/null +++ b/tests/test-registry-stale-pid.c @@ -0,0 +1,69 @@ +/* + * 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(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; +} From 464b77aec9b9f6b657c476fe12b7971bbcab221f Mon Sep 17 00:00:00 2001 From: xalestar Date: Fri, 18 Sep 2026 15:22:56 +0800 Subject: [PATCH 3/4] Fail the stale-registry lane on setup errors The lane could pass without testing anything. A wrong registry path made the append create a fresh file the family never reads, and a family that never printed READY was waited out and then run anyway; both still left kill(99, 0) returning ESRCH. The recipe now fails when READY does not appear or the registry file does not already exist. An EXIT trap stops both elfuse runs and removes the scratch directory, so an interrupted lane no longer leaves the paused holder behind. The guest parent closes its copy of the readiness pipe's write end, so a child that dies before reporting gives EOF instead of a hang. --- mk/tests.mk | 25 +++++++++++-------------- tests/test-registry-stale-pid.c | 1 + 2 files changed, 12 insertions(+), 14 deletions(-) diff --git a/mk/tests.mk b/mk/tests.mk index fc356fa3..c17aba59 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -733,7 +733,10 @@ test-absock-cleanup: $(ELFUSE_BIN) $(BUILD_DIR)/test-absock-cleanup # 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); \ + @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=$$!; \ @@ -745,22 +748,16 @@ test-registry-stale-pid: $(ELFUSE_BIN) $(BUILD_DIR)/test-registry-stale-pid grep -q READY "$$tmp/out" && break; \ sleep 0.1; \ done; \ - printf '%s 99 1 1\n' "$$xpid" \ - >> "$$(getconf DARWIN_USER_TEMP_DIR)elfuse-procs-$$fpid"; \ + 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; \ - wait $$fpid; \ exec 4>&-; \ - kill $$xpid; \ - wait $$xpid 2>/dev/null; \ + wait $$fpid; \ verdict=$$(sed -n 's/^STALE=//p' "$$tmp/out"); \ - rm -rf "$$tmp"; \ - printf " %-30s " "stale record on reused pid"; \ - if [ "$$verdict" = esrch ]; then \ - printf "OK\n"; \ - else \ - printf "FAIL: kill(99, 0) %s\n" "$${verdict:-unreported}"; \ - exit 1; \ - fi + [ "$$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 diff --git a/tests/test-registry-stale-pid.c b/tests/test-registry-stale-pid.c index d2c4994a..37577fb0 100644 --- a/tests/test-registry-stale-pid.c +++ b/tests/test-registry-stale-pid.c @@ -47,6 +47,7 @@ int main(int argc, char **argv) ; _exit(0); } + close(ready[1]); close(release[0]); if (read(ready[0], buf, 1) != 1) return 1; From 2cc9ea24add008b8cf56f4828ab1962db8ce37fc Mon Sep 17 00:00:00 2001 From: xalestar Date: Fri, 18 Sep 2026 15:44:32 +0800 Subject: [PATCH 4/4] Fail the lane if the stale-registry holder died A holder that exited before the family's kill(99, 0) leaves a record for a dead pid, which registry_parse_cb drops on liveness alone, so the lane passed without reaching the start-time comparison. The recipe now fails unless the holder is still alive once the family has exited, which covers the whole window the lookup ran in. --- mk/tests.mk | 1 + 1 file changed, 1 insertion(+) diff --git a/mk/tests.mk b/mk/tests.mk index c17aba59..392750c6 100644 --- a/mk/tests.mk +++ b/mk/tests.mk @@ -755,6 +755,7 @@ test-registry-stale-pid: $(ELFUSE_BIN) $(BUILD_DIR)/test-registry-stale-pid 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"