Skip to content

Let kill reach a relative that is not a child - #388

Open
xalestar wants to merge 4 commits into
sysprog21:mainfrom
xalestar:kill-parent-via-registry
Open

xalestar wants to merge 4 commits into
sysprog21:mainfrom
xalestar:kill-parent-via-registry

Conversation

@xalestar

@xalestar xalestar commented Sep 18, 2026

Copy link
Copy Markdown
Collaborator

A forked guest process gets ESRCH from kill(getppid(), sig), where Linux delivers the signal. sc_kill resolves a single pid only through the child table, which holds the caller's descendants, so the parent is never found. The group and broadcast forms of kill already read the fork-family registry through proc_get_namespace_targets; the first commit falls back to the same registry when the child table has no answer.

The second commit closes a gap that registry already had. An exited member's record stays until the next publish compacts the file, and a reader kept any record whose host pid was alive, so once macOS reused that pid for another elfuse process, kill on the exited guest pid returned 0 instead of ESRCH. Each record now carries the host process start time from proc_pidinfo, and a record is kept only while the process at that pid has the same start time.

Reproduction: tests/test-kill-parent.c has the child call kill(getppid(), 0) and then kill(getppid(), SIGUSR1); on main both return ESRCH (FAIL: 2 failed), with this change the parent's handler runs (PASS: 0 failed). make test-registry-stale-pid plants a record with a live elfuse host pid and a wrong start time; without the second commit kill(99, 0) finds it, with it the call returns ESRCH.

Rebased on main at c0458b4. make check passes. make test-matrix passes test-kill-parent, test-kill-pgroup, and test-kill-broadcast in every mode; its one failure, the Rosetta audit-known-limitations case (a Rosetta assertion in rt_sigreturn, rc=133), fails the same way on main.

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.
cubic-dev-ai[bot]

This comment was marked as resolved.

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.
cubic-dev-ai[bot]

This comment was marked as resolved.

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.
cubic-dev-ai[bot]

This comment was marked as resolved.

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.
Comment thread mk/tests.mk
printf '%s 99 1 1\n' "$$xpid" >> "$$reg" || fail "cannot append to $$reg"; \
echo go >&4; \
exec 4>&-; \
wait $$fpid; \

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

wait $$fpid has no bound, so a regression that leaves the guest parked (the read(0, ...) on the fifo, or the forked child's read(release[0])) hangs this recipe instead of failing it. The EXIT trap never runs, so make check stops here with both elfuse roots still alive and the scratch directory left behind. Every other lane in this file reaches the guest through a runner that wraps the invocation in timeout; this recipe launches $(ELFUSE_BIN) directly. Wrapping both launches in timeout $(TEST_TIMEOUT) and failing when fpid outlives it keeps a hang reportable.

Comment thread src/syscall/proc.c
*
* Returns false once @pid has exited.
*/
static bool host_start_us(pid_t pid, uint64_t *out)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nothing on the reverse lookup uses this. registry_find_by_host_cb still parses three fields, and proc_host_to_guest_pid still accepts a hit on host pid plus a matching proc_pidpath, which is the exact identity test this commit demonstrates is not sufficient. Once macOS hands an exited member's host pid to another elfuse process, F_GETLK reports the exited member's guest pid as the conflicting lock holder, the same stale-record class the second commit closes for kill. Parsing the fourth field in registry_find_by_host_cb and comparing it against host_start_us closes it on that path too.

Comment thread src/syscall/proc.c
Comment on lines +1802 to +1804
pid_t proc_namespace_host_pid(int64_t guest_pid)
{
proc_signal_target_t target;

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Two inputs break the contract proc.h states for this function. A guest_pid of 0 or negative leaves guest_filter > 0 false inside registry_collect, which disables the filter entirely and returns the first non-self member, so the caller gets an arbitrary host pid where the header promises -1. The caller's own guest pid returns -1 even though it is a live member, because registry_collect skips host_pid == self. sc_kill screens both cases before it calls, so nothing is wrong today, but this is exported as a general resolver and the next caller has no reason to know it must screen.

Suggested change
pid_t proc_namespace_host_pid(int64_t guest_pid)
{
proc_signal_target_t target;
pid_t proc_namespace_host_pid(int64_t guest_pid)
{
if (guest_pid <= 0)
return -1;
proc_signal_target_t target;

Comment thread src/syscall/proc.h
* 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);

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

sys_pidfd_open and sys_pidfd_send_signal still resolve through proc_guest_to_host_pid alone, so after this change kill(getppid(), 0) succeeds while pidfd_open(getppid()) still returns ESRCH, and a pidfd on any relative that is not a descendant stays unreachable. Linux draws no such line. Worth a follow-up routing the three proc_guest_to_host_pid call sites in src/syscall/proc-pidfd.c through the same resolver.

Comment thread src/syscall/syscall.c
* 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)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This puts pid-directed kill on the registry, where before only the group and broadcast forms read it, and the registry's file name is keyed on absock_get_namespace_id(), which falls back to the root's host pid. macOS recycles that pid like any other: once a family's root exits with a child still running, a new elfuse root that lands on the reused pid passes the owner test in proc_registry_reset_if_owner, unlinks the orphan family's registry, and then publishes into the same path. The start time added by the second commit proves the target is live and proc_pidpath proves it runs the same binary, but neither proves it is in the same family, so the orphan's kill(1, SIGKILL) reaches the unrelated root, and proc_send_guest_signal passes it through because the namespace tag it checks is that same colliding value. Keying the registry on something that is not a recycled pid, a nonce carried through the existing fork IPC state for instance, would close it for the group forms as well.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants