fix(process): unify subprocess ownership and child lifecycle safety (#614)

## Summary

- make subprocess-registry detach atomic so exactly one closer owns each
entry
- make cancellation cleanup release only the detached node it owns
- replace `vfork`/`execve` child setup with `posix_spawn` and explicit
file actions
- mark every pipe and stdio-collision duplicate close-on-exec
- restore the child's default SIGPIPE disposition while preserving the
parent's handling
- bound TERM/KILL reaping and park unreaped children in a fixed-capacity
sweep list
- harden PHP script-server slot ownership, startup, restart, signal,
descriptor, and cleanup paths
- retire and restart failed or oversized reads so stale response tails
cannot poison later data sources
- revalidate every command retry and cap recovery handshakes at two
seconds

This folds the child-process lifecycle portion of #597 into the narrower
ownership PR. It does not absorb unrelated poll-host, SQL, SNMP, ICMP,
or build-infrastructure work.

## Review-driven corrections

- close-on-exec is explicitly restored after `dup()` in both read and
write stdio-collision branches
- the source invariant check now uses portable POSIX character classes
- direct tests cover duplicate flags, both collision branches, failure
after pipe creation, abandoned-child sweeping, and capacity overflow
- collision cleanup preserves current-child redirects even when an older
registry entry occupies fd 0 or 1
- script children receive SIGTERM before SIGKILL and unreaped PHP
children transfer to the bounded sweep registry
- the Docker integration suite now starts a real PHP script server,
verifies its startup protocol and database result, and checks that
shutdown leaves no zombie PHP process

## Validation

- Ubuntu 24.04 container with GCC and GNU ld: `make check`, 6/6 targets
passed with no skips, including `test_php_runtime`
- real PHP script-server Docker integration: 6/6 checks passed; exact
value `42` reached `poller_output` and no zombie remained
- macOS Homebrew dependency build passed
- macOS `make check`: 5/5 available binaries passed; GNU `--wrap`
runtime suite is Linux-only
- PHP fixture syntax checked through mise PHP 8.3
- actionlint, shellcheck, and `git diff --check` passed

Closes #610.

---------

Signed-off-by: Thomas Vincent <thomasvincent@gmail.com>
Co-authored-by: TheWitness <thewitness@cacti.net>
This commit is contained in:
Thomas VincentandTheWitness authored and GitHub committed 2026-09-22 17:24:04 -04:00
1 parent f989722458
commit 90da94b94f
19 files changed
+2749 -270

No files matched your search

+66
View File
@@ -49,6 +49,8 @@
******************************************************************************
*/
#include <spawn.h>
/*!
* The nft_popen() function forks a command in a child process, and returns
* a pipe that is connected to the child's standard input and output. It is
@@ -94,4 +96,68 @@ extern int nft_pchild(int fd);
*/
extern int nft_pclose(int fd);
/*!
* spine_set_cloexec
*
* Mark a descriptor close-on-exec.
*
* Returns 0 on success, -1 on failure with errno set by fcntl().
*/
extern int spine_set_cloexec(int fd);
/* Duplicate a descriptor and mark the duplicate close-on-exec. On failure,
* no descriptor is returned and errno describes dup() or fcntl(). */
extern int spine_dup_cloexec(int fd);
/*!
* spine_open_pipe_cloexec
*
* Open a pipe whose descriptors are not inherited across exec. Spine spawns
* children from several threads, so a descriptor left inheritable is held by
* an unrelated child and the reader never sees EOF.
*
* Returns TRUE on success. On failure the descriptors are closed and FALSE is
* returned, so the caller owns nothing.
*/
extern int spine_open_pipe_cloexec(int pdes[2]);
/* Restore SIGPIPE's default disposition in posix_spawned children while the
* Spine parent handles broken pipes itself. */
extern int spine_spawnattr_sigpipe_default(posix_spawnattr_t *attr);
/*!
* spine_reap_child_bounded
*
* Reap a child with WNOHANG, sleeping between attempts, so a wedged script
* cannot pin a poller thread indefinitely.
*
* Returns 0 when the child was reaped, 1 when it is still running after
* attempts, and -1 on a waitpid() error other than EINTR or ECHILD.
*/
extern int spine_reap_child_bounded(pid_t pid, int *pstat, int attempts);
/*!
* The cap on parked pids. Past it a child is logged and dropped, because an
* unbounded list would trade a pid leak for a memory leak.
*/
#define NFT_ABANDONED_MAX 64
/*!
* nft_abandon_child
*
* Record a child that outlived nft_pclose()'s kill budget. Nothing else in
* spine reaps, so a dropped child would stay a zombie for the daemon's
* lifetime; parked pids are swept on the next script poll. The pid and the
* reason are logged either way.
*/
extern void nft_abandon_child(pid_t pid, const char *reason);
/*!
* nft_abandoned_pending
*
* Sweep the parked pids with WNOHANG and return how many are still running.
* Zero means nothing is leaking.
*/
extern int nft_abandoned_pending(void);
#endif /* SPINE_NFT_POPEN_H */