Skip to content

fix(process): retire epoch records whose pid was reused - #209

Merged
greenhat616 merged 7 commits into
mainfrom
fix/stale-pid-reuse
Oct 28, 2026
Merged

greenhat616 merged 7 commits into
mainfrom
fix/stale-pid-reuse

Conversation

@greenhat616

Copy link
Copy Markdown
Contributor

Fixes the root cause of libnyanpasu/clash-nyanpasu#6033: the app panics during setup on macOS because a stale core-<epoch>.pid points at a pid that now belongs to an unrelated process.

Problem

reap_record treated a live pid whose start token differs from the record as "cannot prove ownership" and returned PermissionDenied. A different start token means the recorded process exited and the pid was reused. The manager's startup sweep propagated the error, so CoreManager construction failed on every launch until the user deleted the record by hand.

Reading another user's process could not even reach that comparison:

  • macOS: sysinfo reads the start time through PROC_PIDTBSDINFO, which is refused for another uid; sysinfo then reports 0.
  • Linux: /proc/<pid>/exe is unreadable for another user's process, so process_identity returned an error.

Change

  • The start token alone decides reuse: a different token retires the record (AlreadyExited) before descendants are captured, so neither the process nor its tree is signaled. A matching token with a different or unreadable executable is still an error and the record is kept. recorded_process_is_alive follows the same three-way comparison.
  • ProcessIdentity.executable is optional. Linux reads the token from the world-readable stat file even when exe is denied.
  • macOS reads the token from sysctl(KERN_PROC_PID), which reports every uid and the same p_start.tv_sec that existing records hold. libc does not bind kinfo_proc; the module declares the prefix it reads, explains why in an FFI-BINDING comment (alternatives such as proc_pid_rusage, short BSD info and the private unique-identifier flavor were ruled out against XNU sources), and asserts its offsets at compile time.
  • Linux: with an equal start token, an observed <name> (deleted) matches the recorded name, so an orphan core left by an upgrade is reaped.
  • CI runs the process identity tests (previously only compiled by Clippy).
  • Version bump to 0.1.1 (separate commit).

Tests

  • Reused pid: record retired, the live child still answers on stdin; its descendant tree survives.
  • Same token, different executable: error, record kept, process alive.
  • Kill path is never invoked for a reused pid.
  • Unix: a record pointing at pid 1 (root) with another token is retired. Before this change this fails for an unprivileged run on both Linux and macOS.
  • macOS: native start time equals sysinfo's for the test process; launchd's start time is readable; a free pid reads as absent.

Verified locally on Windows and on Linux (WSL, also as nobody). macOS was only type-checked and Clippy-checked for both architectures; its behavior tests rely on this CI.

Known limitation

Windows: if the pid is reused by a protected (PPL) process, OpenProcess may be denied and the reap still errors.

A stale core-<epoch>.pid survives an unclean exit. Once its pid names
another process, the start token differs, which proves the recorded core
exited. Reaping treated that as unproven ownership and returned an error,
so the manager's startup sweep failed and the host could not start until
the record was deleted by hand (clash-nyanpasu#6033).

Decide reuse by the start token alone and retire such a record without
signaling the process or its descendants. A matching start token with a
different or unreadable executable stays an error.

Reading another user's process could not reach that decision. Linux
denies /proc/<pid>/exe across users, so the executable becomes optional
and the token is read from the world-readable stat file. macOS reports a
start time of 0 through sysinfo when PROC_PIDTBSDINFO is refused, so the
token now comes from sysctl(KERN_PROC_PID), which reports every uid and
the same p_start seconds. libc does not bind kinfo_proc; the module
declares the prefix it reads and checks its offsets at compile time.

On Linux an executable replaced after exec reads "<name> (deleted)"; with
an equal start token it still matches, so an orphan core left by an
upgrade is reaped instead of blocking startup.

CI now runs the process identity tests, which it previously only compiled.
@semanticdiff-com

semanticdiff-com Bot commented Oct 28, 2026 •

Copy link
Copy Markdown

Review changes with  SemanticDiff

Changed Files
File Status
  tests/process_pid_file.rs  2% smaller
  .github/workflows/ci.yml  0% smaller
  Cargo.lock Unsupported file format
  Cargo.toml Unsupported file format
  README.md Unsupported file format
  src/os/mod.rs  0% smaller
  src/process/pid_file.rs Unsupported file format

github-actions Bot and others added 5 commits October 28, 2026 06:58
tokio::fs::File completes writes on a background thread, so returning
after write_all can leave the truncated, empty file visible to a reader.
The legacy pid file tests read it right after spawn and fail on macOS
now that CI runs them.
`sh -c "sleep 30"` may exec into sleep after the test reads the child's
identity. The start token stays the same while the executable changes,
which correctly cannot prove ownership, so the test failed whenever the
exec won the race. It surfaced on macOS once CI started running it.
Without a pre_exec hook std spawns through glibc posix_spawn, which uses
clone(CLONE_VM | CLONE_VFORK). The kernel wakes the vfork parent in
exec_mmap() (mm_exit_exec_release) before it installs the new mm, so
spawn() can return while /proc/<pid>/exe still names the parent's
binary. processkit takes that path on Linux when no writable cgroup is
available, and PidFileGuard::write then failed with "executable ... does
not match".

Keep polling within the existing identity wait until the executable
matches. The start token does not change across exec, so the identity
that matches still belongs to the spawned process; a real mismatch is
reported after the bounded wait as before.
@greenhat616
greenhat616 merged commit 04f4ba1 into main Oct 28, 2026
4 checks passed
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.

1 participant