Skip to content

fix(core): stop counting unreaped processes as running - #166

Closed
neumie wants to merge 1 commit into
refactorx/full-headlessfrom
fix/daemon-zombie-liveness
Closed

fix(core): stop counting unreaped processes as running#166
neumie wants to merge 1 commit into
refactorx/full-headlessfrom
fix/daemon-zombie-liveness

Conversation

@neumie

@neumie neumie commented Jul 21, 2026

Copy link
Copy Markdown
Contributor

Problem

okena_core::process::is_process_alive was a bare libc::kill(pid, 0) == 0 on unix. That succeeds for a zombie — a process that has exited but whose parent has not reaped it, so its pid stays in the table.

The desktop holds its spawned daemon's std::process::Child without waiting on it, so an exited UI-owned daemon stayed "alive" to every pid probe until that handle was finally reaped. Two consequences:

  • Clean quit burned its whole timeout. hand_off_ui_owned_daemon calls wait_for_pid_exit(daemon.pid, 3s), which polls only is_process_alive — so it could never observe its own child's exit. Every graceful shutdown sat out the full 3s, then logged UI-owned daemon pid N did not exit gracefully; reaping by pid and SIGKILLed a corpse.
  • A crashed daemon looked reachable. running_daemon_in reported it live, sending startup down the attach branch into Local daemon pid N did not respond to /health in time instead of falling through and respawning.

Every caller here means running — a zombie answers no requests, serves no port, and holds no lock (its fds close at exit). So zombies now count as dead. Windows already behaved this way: its process handle is signalled at exit, which is exactly the semantic unix was missing.

macOS

There is no direct zombie predicate, so this asks proc_pidinfo(PROC_PIDTBSDINFO) for the BSD record, which the kernel refuses once the process is gone. Measured on this machine:

pid state return errno
live 136 0
zombie 0 ESRCH
reaped / nonexistent 0 ESRCH
buffer too small 0 ENOMEM
not permitted (launchd) 0 EPERM

Apple's libc wrapper collapses the kernel's -1 into a return of 0 and reports the reason through errno, so the return value alone cannot separate "pid is gone" from "the call failed" — an earlier revision of this patch keyed on == 0 and would have read ENOMEM/EPERM as dead. Only ESRCH counts as exited; anything else keeps the pre-fix answer, since declaring a live daemon dead and respawning over the top of it is the worse error. errno is cleared before the call so an unrelated earlier failure can't be mistaken for ours.

Linux

Reads the state character from /proc/<pid>/stat — as bytes rather than a String, since comm is the raw executable name and need not be valid UTF-8. The scan starts after the last ) because comm is parenthesised and may itself contain spaces and parentheses.

Other unix targets (iOS, Android) keep the old behaviour.

Tests

  • An exited, unreaped child is not alive. Fails on the pre-fix code — verified by temporarily reverting the probe.
  • This process and a live child are alive.

Full build clean, clippy clean workspace-wide, 1367 tests pass. A 21-agent adversarial review returned zero confirmed findings; the ESRCH gating and the /proc byte read above came out of true-but-refuted observations it surfaced.

Notes

Based on #157 (refactorx/full-headless). Independent of #164 and #165 — this is the follow-up called out in #165's description.

🤖 Generated with Claude Code

https://claude.ai/code/session_01Y4Aq3X2s6ftafXzmqaN3A8

`is_process_alive` was a bare `kill(pid, 0)` on unix, which succeeds for
a zombie — a process that has exited but whose parent has not reaped it.
The desktop holds its spawned daemon's `Child` handle without waiting on
it, so an exited UI-owned daemon stayed "alive" to every pid probe until
that handle was finally reaped.

Two consequences. On a clean quit, `wait_for_pid_exit` could never
observe its own child's exit, so shutdown sat out the full 3s timeout,
logged a bogus "did not exit gracefully" and SIGKILLed a corpse. And a
crashed daemon still looked live to `running_daemon_in`, sending startup
down the attach branch into a hard error instead of respawning.

Every caller means *running* — a zombie answers no requests, serves no
port, and holds no lock (its fds close at exit) — so zombies now count as
dead. Windows already behaved this way: its process handle is signalled
at exit.

macOS has no direct zombie predicate, so this asks `proc_pidinfo` for the
BSD record, which the kernel refuses with ESRCH once the process is gone.
Apple's wrapper collapses the kernel's -1 into a return of 0 and reports
the reason through errno, so the return value alone cannot separate "pid
is gone" from "the call failed" — EPERM and ENOMEM also return 0. Only
ESRCH counts as exited; anything else keeps the pre-fix answer, since
declaring a live daemon dead and respawning over it is the worse error.

Linux reads the state character from /proc/<pid>/stat, as bytes rather
than a String — `comm` is the raw executable name and need not be valid
UTF-8. Other unix targets keep the old behaviour.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Y4Aq3X2s6ftafXzmqaN3A8
@matej21

matej21 commented Jul 21, 2026

Copy link
Copy Markdown
Member

Integrated into #157 as commit 113469c after review and combined validation.

@matej21 matej21 closed this Jul 21, 2026
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