Fix Linux child lifecycle semantics#218
Conversation
8b8e05a to
41d3533
Compare
41d3533 to
e7fa664
Compare
| lifecycle_entry_t *child = ®istry->entries[i]; | ||
| if (child->guest_pid == self_pid || child->ppid != self_pid) | ||
| continue; | ||
| child->ppid = adopter_pid; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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) |
There was a problem hiding this comment.
Please consider using #221 after this PR is merged. Thanks.
| return 0; | ||
| } | ||
|
|
||
| static int read_full(int fd, void *buf, size_t len) |
|
Is TID in scope for this PR to also address? |
|
@henrybear327 Yes — the narrow PID/TID allocation-uniqueness part is in scope.
Commit This does not expand the PR into full |
jserv
left a comment
There was a problem hiding this comment.
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.
| int options, | ||
| uint64_t rusage_gva) | ||
| { | ||
| lifecycle_import_children(); |
There was a problem hiding this comment.
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.
| bool subreaper; | ||
| bool exited; | ||
| bool reparent_pending; | ||
| int exit_status; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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
| if (registry) { | ||
| lifecycle_entry_t *entry = lifecycle_upsert(registry, guest_pid); | ||
| if (entry) { | ||
| entry->host_pid = 0; |
There was a problem hiding this comment.
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>
| */ | ||
| #define LIFECYCLE_MAGIC 0x454C464CU /* "ELFL" */ | ||
| #define LIFECYCLE_VERSION 3 | ||
| #define LIFECYCLE_MAX_ENTRIES PROC_TABLE_SIZE |
There was a problem hiding this comment.
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>
| #define LIFECYCLE_MAX_ENTRIES PROC_TABLE_SIZE | |
| #define LIFECYCLE_MAX_ENTRIES (PROC_TABLE_SIZE * 4) |
Summary
waitid(WNOWAIT)observationsSIGCHLDfrom child exit and implement the explicitSIG_IGN/SA_NOCLDWAITno-zombie dispositionsfork()withEAGAINinstead of allowing an untrackable child to runSIGKILLof a compute-bound childDesign 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 returnsEAGAINrather 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; blockingwait4andwaitidalso re-import adopted children during their retry loops and before returningECHILD.The shared ID allocator is used by process-style
fork(),clone(CLONE_THREAD), and waitableclone(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 treatingCLONE_THREADworkers 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
SIGCHLDdelivery 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-eventwaitid(WNOHANG)clears the complete 128-byte guestsiginfo_t.Testing
At commit
1117b5a:make test-matrix-elfuse-aarch64: 240 passed, 0 failed, 4 skippedmake check: all 69 internal tests passed; BusyBox reported 81 passed, 1 failed, 2 skipped, with the sole failure beingnslookup example.combecause no DNS server was reachable both inside and outside the sandboxmake lint: exit 0, existing warnings onlygit diff --check: passedThe portable lifecycle cases cover nested process PID uniqueness; process/thread PID-TID uniqueness;
WNOHANG; repeatedWNOWAIT; normal and auto-reapwaitid(P_PGID)matching; complete no-eventsiginfo_tclearing; normal-exit versus parent-directed signal status; delayed and reverse-order zombie reaping; 65 retained zombies; explicit no-zombie SIGCHLD dispositions; exit-timeSIGCHLDdelivery; 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
wait*()or explicitly select no-zombie semantics withSIGCHLD = 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_PDEATHSIGsupport: 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.SIGHUPfollowed bySIGCONTrule remains a separate job-control follow-up.SIG_IGNtransition: changingSIGCHLDfromSIG_DFLto explicitSIG_IGNdoes not immediately flush already-terminal children; they are removed by the next wait path. This is lower-priority follow-up work.errno/ERANGEexactly like the structured parser. An overflowed token is dropped; this is an inconsistency, not an exploitable routing issue.flock. This is a scaling limit rather than a correctness failure.