Skip to content

Fix Linux child lifecycle semantics#218

Open
Xalestar wants to merge 3 commits into
sysprog21:mainfrom
Xalestar:fix/process-lifecycle-linux-semantics
Open

Fix Linux child lifecycle semantics#218
Xalestar wants to merge 3 commits into
sysprog21:mainfrom
Xalestar:fix/process-lifecycle-linux-semantics

Conversation

@Xalestar

@Xalestar Xalestar commented Jul 17, 2026

Copy link
Copy Markdown
Contributor

Summary

  • allocate guest process IDs and thread IDs across one elfuse invocation's entire fork family instead of resetting the allocator in every host process
  • retain zombie status and resource usage until a consuming wait, including repeatable waitid(WNOWAIT) observations
  • deliver SIGCHLD from child exit and implement the explicit SIG_IGN / SA_NOCLDWAIT no-zombie dispositions
  • track process lifecycle state across elfuse host processes so live and zombie orphans are adopted by the nearest living child subreaper, or guest PID 1
  • reserve local and shared child bookkeeping before host spawn, failing fork() with EAGAIN instead of allowing an untrackable child to run
  • preserve Linux signal-termination wait status, including parent-directed SIGKILL of a compute-bound child
  • add one portable 19-case lifecycle test shared by the elfuse and QEMU/Linux matrix runners

Design notes

elfuse implements each guest fork() with a separate macOS host process. Parent-local state is therefore insufficient for nested PID/TID allocation, retained zombie state, or orphan adoption.

This change adds invocation-scoped, flock-serialized PID and lifecycle registries in the per-user private temporary directory. The lifecycle registry retains guest PID/PPID/PGID, subreaper state, Linux wait-format terminal status, and resource usage until a consuming guest wait removes the entry. Shared PID/TID allocation is fail-closed: path, open, lock, read, or write failure returns EAGAIN rather than falling back to an unsynchronized process-local counter.

Live reparenting uses the existing cross-process control transport; a registry pending/acknowledgment handshake and a post-bootstrap authoritative sync close the window where the fork header's original PPID could otherwise overwrite a newly selected adopter. When an already-terminal child is reparented, the adopter receives SIGCHLD; blocking wait4 and waitid also re-import adopted children during their retry loops and before returning ECHILD.

The shared ID allocator is used by process-style fork(), clone(CLONE_THREAD), and waitable clone(CLONE_VM) children. This keeps live process PIDs and thread TIDs in one invocation-wide ID space; the lifecycle registry itself continues to track process children rather than treating CLONE_THREAD workers as orphanable/waitable processes.

Fork admission is transactional. The parent reserves both a local process-table slot and a shared lifecycle entry before spawning the host helper. The child remains behind an admission byte after IPC initialization and enters guest code only after parent-side registration commits. The two tables use the same fixed capacity, and allocation or reservation failure is returned to the guest as EAGAIN.

Fatal guest signals store Linux signal/core wait bits rather than being folded into a normal exit code. Signal preemption and teardown track Hypervisor.framework vCPU ownership with a separate validity flag because handle value zero is valid; this allows a parent-directed signal to interrupt the first, compute-bound vCPU correctly.

Exit-time SIGCHLD delivery triggers targeted automatic reaping only for children already marked terminal in the lifecycle registry. Parent-side child registration preserves lifecycle state that the child or a reparent transaction published first. waitid(P_PGID, ...) and the auto-reap wait path use process-group-aware matching, and a no-event waitid(WNOHANG) clears the complete 128-byte guest siginfo_t.

Testing

At commit 1117b5a:

  • targeted lifecycle test under elfuse: 19 passed, 0 failed
  • targeted lifecycle test under QEMU/Linux: 19 passed, 0 failed
  • 100 consecutive host-level lifecycle runs: 100/100 passed, with all 19 cases passing each run
  • make test-matrix-elfuse-aarch64: 240 passed, 0 failed, 4 skipped
  • make check: all 69 internal tests passed; BusyBox reported 81 passed, 1 failed, 2 skipped, with the sole failure being nslookup example.com because no DNS server was reachable both inside and outside the sandbox
  • make lint: exit 0, existing warnings only
  • C format check and git diff --check: passed

The portable lifecycle cases cover nested process PID uniqueness; process/thread PID-TID uniqueness; WNOHANG; repeated WNOWAIT; normal and auto-reap waitid(P_PGID) matching; complete no-event siginfo_t clearing; normal-exit versus parent-directed signal status; delayed and reverse-order zombie reaping; 65 retained zombies; explicit no-zombie SIGCHLD dispositions; exit-time SIGCHLD delivery; PID 1 reparenting; live/zombie child-subreaper adoption; a bounded non-reaping PID 1 case; blocking-wait adoption wakeup; and adopted signal status/resource-usage retention.

Out of scope / follow-ups

  • No hidden PID 1 reaper: elfuse does not insert an init process or automatically discard adopted exit statuses merely because their new parent is guest PID 1. As on Linux, guest PID 1 must call wait*() or explicitly select no-zombie semantics with SIGCHLD = SIG_IGN / SA_NOCLDWAIT. An application runtime used directly as guest PID 1 may therefore retain adopted statuses indefinitely, and a direct macOS child may remain a host zombie until the guest waits, changes its SIGCHLD disposition, or exits. A future host-only reaper could collect macOS children while preserving guest-visible status.
  • PR_SET_PDEATHSIG support: this opt-in parent-death API is not required for the zombie retention, wait ownership, orphan reparenting, or child-subreaper semantics fixed here. It should be tested and implemented separately.
  • Stopped orphaned process groups: the Linux/POSIX SIGHUP followed by SIGCONT rule remains a separate job-control follow-up.
  • Existing-zombie flush on an explicit SIG_IGN transition: changing SIGCHLD from SIG_DFL to explicit SIG_IGN does not immediately flush already-terminal children; they are removed by the next wait path. This is lower-priority follow-up work.
  • Bare-signal fallback parsing: the fallback parser does not reset errno/ERANGE exactly like the structured parser. An overflowed token is dropped; this is an inconsistency, not an exploitable routing issue.
  • Registry scaling: lifecycle mutations rewrite the fixed registry under the global flock. This is a scaling limit rather than a correctness failure.

@Xalestar
Xalestar force-pushed the fix/process-lifecycle-linux-semantics branch from 8b8e05a to 41d3533 Compare July 17, 2026 17:16
cubic-dev-ai[bot]

This comment was marked as resolved.

@jserv
jserv requested a review from Max042004 July 17, 2026 17:22
@Xalestar
Xalestar force-pushed the fix/process-lifecycle-linux-semantics branch from 41d3533 to e7fa664 Compare July 17, 2026 17:51
Comment thread src/syscall/proc.c
lifecycle_entry_t *child = &registry->entries[i];
if (child->guest_pid == self_pid || child->ppid != self_pid)
continue;
child->ppid = adopter_pid;

@Max042004 Max042004 Jul 18, 2026

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Reparenting an orphan to guest PID 1 is not sufficient on its own: the adopted child is only imported and consumed when the new parent calls wait*(), or explicitly opts into SIGCHLD=SIG_IGN / SA_NOCLDWAIT. In the common case, guest PID 1 is an application runtime (JVM, Python, Node.js, etc.), not an init process, and it may never reap adopted descendants.

As a result, this change can retain orphan exit statuses indefinitely (and may retain host zombies for direct children) even though the PPID has been updated. We need either an elfuse-managed init/reaper for guest PID 1, automatic reaping semantics for adopted children, or a clearly documented limitation. Please add a regression test where a non-reaping guest PID 1 adopts an orphan that exits.

If choose elfuse-managed init/reaper process, it necessary to measure how much cost is introduced in startup time.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks — agreed that this needed explicit regression coverage and documentation.

I chose the documented-limitation path rather than automatically discarding guest-visible exit status. Commit 3d7bee adds a regression test for the non-reaping guest PID 1 case (O-06 in test-process-lifecycle.c), where:

  • a leaf becomes orphaned and is adopted by guest PID 1;
  • PID 1 deliberately performs no consuming wait for a bounded interval;
  • two waitid(P_PID, ..., WEXITED | WNOHANG | WNOWAIT) calls observe the same PID and exit status;
  • a final waitpid() consumes the status only to clean up the test.

In the QEMU/Linux reference lane, the test process is normally not PID 1, so it verifies that system init owns the orphan and that the former ancestor receives ECHILD.

docs/internals.md and the PR description now explicitly document that elfuse does not insert a hidden init/reaper. An application used directly as guest PID 1 must call wait*() or select SIGCHLD=SIG_IGN / SA_NOCLDWAIT. A direct macOS child may otherwise remain a host zombie.

Automatically discarding the guest status solely because the new parent is PID 1 would change Linux wait semantics. A host-only reaper that collects the macOS process while retaining guest-visible status remains possible as separate hardening. Since this PR does not add a managed init/reaper, it introduces no associated startup-time cost.

Local validation:

  • targeted lifecycle test: 16 passed, 0 failed
  • 100/100 stress runs, all 16 cases passing each run
  • make check
  • make lint (exit 0; existing warnings only)
  • elfuse matrix: 240 passed, 0 failed, 4 skipped
  • QEMU matrix: 219 passed, 0 failed, 25 skipped


int passes = 0, fails = 0;

static int write_full(int fd, const void *buf, size_t len)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

Please consider using #221 after this PR is merged. Thanks.

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

Thanks. I kept this PR focused and left the helper de-duplication to #221. After #218 is merged, the new full-read/full-write helpers here can migrate to the shared read_all/write_all implementation.

return 0;
}

static int read_full(int fd, void *buf, size_t len)

Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

ditto

@henrybear327

Copy link
Copy Markdown
Collaborator

Is TID in scope for this PR to also address?

@Xalestar

Copy link
Copy Markdown
Contributor Author

@henrybear327 Yes — the narrow PID/TID allocation-uniqueness part is in scope.

proc_alloc_pid() is shared by process-style fork(), clone(CLONE_THREAD), and waitable clone(CLONE_VM), so moving it to an invocation-wide allocator also directly affects TIDs.

Commit 3d7bee adds a regression test for process/thread ID uniqueness (PID-02 in test-process-lifecycle.c). A fork child creates a live worker thread and verifies that the thread-group leader's TID equals its PID, while the worker TID does not collide with either the parent or child process PID. The same test passes under elfuse and QEMU/Linux.

This does not expand the PR into full CLONE_THREAD lifecycle or orphan/wait semantics; those remain process-child lifecycle concerns.

@jserv jserv left a comment

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.

The cross-process flock read-modify-write discipline is sound. Findings below concern lifecycle edge cases. Three lower-priority items not worth their own anchor: installing SIG_IGN when the prior disposition was SIG_DFL doesn't discard existing zombies immediately (the flush in signal.c is gated on the old action, so zombies linger until the next wait*); sig_drain_cb's bare-signal fallback parse skips the errno/ERANGE reset the structured path does (an overflowed token just drops the record — not exploitable, but inconsistent); and lifecycle_save_locked rewrites the entire fixed registry under the global flock on every fork/setpgid/setsid/subreaper change, a scaling cliff rather than a bug.

Comment thread src/syscall/proc.c
int options,
uint64_t rusage_gva)
{
lifecycle_import_children();

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.

lifecycle_import_children() runs once at the top of sys_wait4/sys_waitid, outside the retry loop, and proc_process_exit (:1261) sends SIGCHLD only to self->ppid while skipping reparented exited children (:1263). A subreaper blocked in wait4(-1) gets no signal for a zombie an unrelated exit reparents to it, and never re-imports it; if it has no live children it can return ECHILD while a waitable zombie for it sits in the registry. Re-import inside the wait loop, and send SIGCHLD to adopter_pid for each reparented exited child.

Comment thread src/syscall/proc.c Outdated
Comment thread src/syscall/proc.c
bool subreaper;
bool exited;
bool reparent_pending;
int exit_status;

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.

lifecycle_entry_t stores only exit_status, no struct rusage, and proc_process_exit (:1210) publishes only status. A direct parent host-reaps real rusage, but an adopted zombie read from this registry returns zero rusage — contradicting the "retain zombie status and resource usage" claim. Store rusage in the registry before the original host parent loses waitability, or narrow the documented behavior and add a test for adopted-zombie rusage.

Comment thread src/syscall/proc.c Outdated
Comment thread src/syscall/proc.c Outdated
Comment thread src/syscall/proc.c Outdated

@cubic-dev-ai cubic-dev-ai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

2 issues found across 14 files (changes from recent commits).

Prompt for AI agents (unresolved issues)

Check if these issues are valid — if so, understand the root cause of each and fix them. If appropriate, use sub-agents to investigate and fix each issue separately.


<file name="src/syscall/proc.c">

<violation number="1" location="src/syscall/proc.c:552">
P2: The lifecycle cap reserves one of the 1024 records for self, so a process cannot use its advertised 1024-child table capacity. Give lifecycle state room for self and the fork family rather than tying its total record count directly to the local-child limit.</violation>

<violation number="2" location="src/syscall/proc.c:685">
P1: A concurrent wait during fork admission can create a phantom adopted child, then commit a second entry for the same PID. Exclude `host_pid == 0` reservations from lifecycle import, or make import recognize the local reserved slot, so one child has exactly one wait record.</violation>
</file>

Tip: Review your code locally with the cubic CLI to iterate faster.

Re-trigger cubic

Comment thread src/syscall/proc.c
if (registry) {
lifecycle_entry_t *entry = lifecycle_upsert(registry, guest_pid);
if (entry) {
entry->host_pid = 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: A concurrent wait during fork admission can create a phantom adopted child, then commit a second entry for the same PID. Exclude host_pid == 0 reservations from lifecycle import, or make import recognize the local reserved slot, so one child has exactly one wait record.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/proc.c, line 685:

<comment>A concurrent wait during fork admission can create a phantom adopted child, then commit a second entry for the same PID. Exclude `host_pid == 0` reservations from lifecycle import, or make import recognize the local reserved slot, so one child has exactly one wait record.</comment>

<file context>
@@ -580,43 +669,63 @@ static void lifecycle_unlock_close(int fd)
+    if (registry) {
+        lifecycle_entry_t *entry = lifecycle_upsert(registry, guest_pid);
+        if (entry) {
+            entry->host_pid = 0;
+            entry->ppid = ppid;
+            entry->pgid = pgid;
</file context>

Comment thread src/syscall/proc.c
*/
#define LIFECYCLE_MAGIC 0x454C464CU /* "ELFL" */
#define LIFECYCLE_VERSION 3
#define LIFECYCLE_MAX_ENTRIES PROC_TABLE_SIZE

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: The lifecycle cap reserves one of the 1024 records for self, so a process cannot use its advertised 1024-child table capacity. Give lifecycle state room for self and the fork family rather than tying its total record count directly to the local-child limit.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. At src/syscall/proc.c, line 552:

<comment>The lifecycle cap reserves one of the 1024 records for self, so a process cannot use its advertised 1024-child table capacity. Give lifecycle state room for self and the fork family rather than tying its total record count directly to the local-child limit.</comment>

<file context>
@@ -461,8 +548,8 @@ static bool process_registry_path(char *out, size_t out_size)
-#define LIFECYCLE_VERSION 2
-#define LIFECYCLE_MAX_ENTRIES (PROC_TABLE_SIZE * 4)
+#define LIFECYCLE_VERSION 3
+#define LIFECYCLE_MAX_ENTRIES PROC_TABLE_SIZE
 
 typedef struct {
</file context>
Suggested change
#define LIFECYCLE_MAX_ENTRIES PROC_TABLE_SIZE
#define LIFECYCLE_MAX_ENTRIES (PROC_TABLE_SIZE * 4)

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.

4 participants